diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index bb4765429..7815af695 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -24,6 +24,7 @@ type BuildToolKind = | 'ceiling' | 'roof' | 'stair' + | 'measurement' | 'elevator' | 'door' | 'window' @@ -74,6 +75,7 @@ const BUILD_TYPES: BuildType[] = [ { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'measurement', label: 'Measure', iconSrc: '/icons/blueprint.webp', kind: 'measurement' }, { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index ddd2c8217..27a27b52a 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -34,6 +34,7 @@ import { Grid2X2, Magnet, PenLine, + Ruler, SlidersHorizontal, Sparkles, SwatchBook, @@ -293,6 +294,8 @@ function DisplayMenu() { const setShadows = useViewer((state) => state.setShadows) const magneticSnap = useEditor((state) => state.magneticSnap) const setMagneticSnap = useEditor((state) => state.setMagneticSnap) + const showMeasurements = useEditor((state) => state.showMeasurements) + const setShowMeasurements = useEditor((state) => state.setShowMeasurements) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -341,6 +344,15 @@ function DisplayMenu() { {magneticSnap ? 'On' : 'Off'} + keepOpen(e, () => setShowMeasurements(!showMeasurements))} + > + + Measurements + + {showMeasurements ? 'On' : 'Off'} + + keepOpen(e, () => setShadows(!shadows))}> Shadows diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 18fb18206..2a2ec4173 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { + allowedDevOrigins: ['127.0.0.1'], logging: { browserToTerminal: true, }, diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index c58f1dec6..e04cee32c 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -26,6 +26,7 @@ import type { LevelNode, LinesetNode, LiquidLineNode, + MeasurementNode, PipeFittingNode, PipeSegmentNode, PipeTrapNode, @@ -129,6 +130,7 @@ export type PipeFittingEvent = NodeEvent export type PipeTrapEvent = NodeEvent export type LinesetEvent = NodeEvent export type LiquidLineEvent = NodeEvent +export type MeasurementEvent = NodeEvent // 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 & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 39a84ec32..7fd23ae3e 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -44,6 +44,10 @@ export const sceneRegistry = { // Master lookup: ID -> Object3D nodes: new Map(), + // Reverse ownership lookup used to keep nested R3F events attached to the + // closest registered scene node instead of bubbling into container nodes. + nodeIds: new WeakMap(), + // Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind // gets a Set on first touch — no hardcoded list. byType: byTypeProxy, @@ -51,6 +55,7 @@ export const sceneRegistry = { /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ clear() { this.nodes.clear() + this.nodeIds = new WeakMap() for (const set of byTypeStore.values()) { set.clear() } @@ -64,6 +69,7 @@ export function useRegistry(id: string, type: string, ref: React.RefObject { sceneRegistry.nodes.delete(id) + sceneRegistry.nodeIds.delete(obj) sceneRegistry.byType[type]!.delete(id) } }, [id, type, ref]) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 830915fb0..8a68bbf4a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -94,6 +94,15 @@ export type { LazyComponent, LiveTransformLike, McpOverrides, + MeasurementDefinition, + MeasurementDefinitionArea, + MeasurementDefinitionDirectLength, + MeasurementDefinitionPerimeter, + MeasurementDefinitionPoint, + MeasurementDefinitionSnapAnchor, + MeasurementDefinitionSnapGeometry, + MeasurementDefinitionSnapKind, + MeasurementDefinitionSnapSegment, Modifiers, MovableConfig, MovableParentFrame, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 55fcc6f80..614dfcbf1 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -757,6 +757,84 @@ export type SnapProfile = 'item' | 'structural' */ export type BakePolicy = 'static' | 'strip' | 'replace' +export type MeasurementDefinitionPoint = readonly [number, number, number] + +export type MeasurementDefinitionSnapKind = + | 'center' + | 'edge' + | 'endpoint' + | 'grid' + | 'guide' + | 'intersection' + | 'measurement' + | 'midpoint' + | 'surface' + | 'vertex' + +export type MeasurementDefinitionSnapAnchor = { + kind?: MeasurementDefinitionSnapKind + label: string + point: MeasurementDefinitionPoint + priority?: number + targetLine?: { + end: MeasurementDefinitionPoint + start: MeasurementDefinitionPoint + } +} + +export type MeasurementDefinitionSnapSegment = { + kind?: MeasurementDefinitionSnapKind + label: string + sourceId?: string + start: MeasurementDefinitionPoint + end: MeasurementDefinitionPoint + priority?: number +} + +export type MeasurementDefinitionSnapGeometry = { + anchors?: MeasurementDefinitionSnapAnchor[] + segments?: MeasurementDefinitionSnapSegment[] +} + +export type MeasurementDefinitionDirectLength = { + end: MeasurementDefinitionPoint + measuredDistanceMeters: number + start: MeasurementDefinitionPoint +} + +export type MeasurementDefinitionArea = { + areaSquareMeters: number + boundaryPoints?: ReadonlyArray + labelPoint: MeasurementDefinitionPoint +} + +export type MeasurementDefinitionPerimeter = { + boundaryPoints?: ReadonlyArray + labelPoint: MeasurementDefinitionPoint + lengthMeters: number +} + +export type MeasurementOwnerResolveArgs = { + node: N + ctx: GeometryContext + worldPoint: MeasurementDefinitionPoint +} + +export type MeasurementDefinition = { + area?: (node: N, ctx: GeometryContext) => MeasurementDefinitionArea | null + applyLiveTransform?: (node: N, live: LiveTransformLike) => AnyNode + directLength?: ( + node: N, + ctx: GeometryContext, + cursorPoint?: MeasurementDefinitionPoint | null, + cursorNormal?: MeasurementDefinitionPoint | null, + ) => MeasurementDefinitionDirectLength | null + perimeter?: (node: N, ctx: GeometryContext) => MeasurementDefinitionPerimeter | null + pick3D?: boolean + resolveOwnerId?: (args: MeasurementOwnerResolveArgs) => AnyNodeId | null + snapGeometry?: (node: N, ctx: GeometryContext) => MeasurementDefinitionSnapGeometry | null +} + export type ExportAnimationContext = { node: N object: Object3D @@ -806,6 +884,12 @@ export type NodeDefinition> = { migrate?: Record unknown> capabilities: Capabilities + /** + * Per-kind geometry for editor measurement tools. This keeps measurement + * snapping, direct length, area, and perimeter behavior registry-driven: + * editor tools call this hook instead of branching on concrete node kinds. + */ + measurement?: MeasurementDefinition> relations?: Relations parametrics?: ParametricDescriptor> diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 73eb57f41..5d67dbafc 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -95,6 +95,7 @@ export { export { LevelNode } from './nodes/level' export { LinesetNode } from './nodes/lineset' export { LiquidLineNode } from './nodes/liquid-line' +export { MeasurementNode, MeasurementPointAttachment } from './nodes/measurement' export { PipeFittingNode } from './nodes/pipe-fitting' export { PipeSegmentNode } from './nodes/pipe-segment' export { PipeTrapNode } from './nodes/pipe-trap' diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index e4a4e470f..6c001e19f 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -7,7 +7,9 @@ import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), + children: z + .array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, objectId('measurement')])) + .default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index df8a53a5a..1fef324b3 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -112,7 +112,7 @@ export const CabinetNode = BaseNode.extend({ id: objectId('cabinet'), type: nodeType('cabinet'), runTier: z.enum(['base', 'wall', 'tall']).default('base'), - children: z.array(objectId('cabinet-module')).default([]), + children: z.array(z.union([objectId('cabinet-module'), objectId('measurement')])).default([]), // Raised bar counter along one run edge: a knee wall topped by a slab at // bar height. Run-level because it spans modules like the countertop. barLedge: z @@ -130,7 +130,9 @@ export const CabinetNode = BaseNode.extend({ export const CabinetModuleNode = BaseNode.extend({ id: objectId('cabinet-module'), type: nodeType('cabinet-module'), - children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]), + children: z + .array(z.union([objectId('cabinet-module'), objectId('cabinet'), objectId('measurement')])) + .default([]), cabinetType: z.enum(['base', 'tall']).default('base'), // Discriminator for specialty units (corner L-shape, sink base, appliance // gap, open shelving). 'standard' modules use the compartment stack as-is; diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index b94aaaa2b..d9412afa2 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -8,7 +8,7 @@ import { SurfaceHoleMetadata } from './surface-hole-metadata' export const CeilingNode = BaseNode.extend({ id: objectId('ceiling'), type: nodeType('ceiling'), - children: z.array(ItemNode.shape.id).default([]), + children: z.array(z.union([ItemNode.shape.id, objectId('measurement')])).default([]), material: MaterialSchema.optional(), materialPreset: z.string().optional(), // Per-slot material overrides on the unified slot model, mirroring diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8732482b..05d3b6d9a 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -130,7 +130,7 @@ export const ItemNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), side: z.enum(['front', 'back']).optional(), - children: z.array(objectId('item')).default([]), + children: z.array(z.union([objectId('item'), objectId('measurement')])).default([]), // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") wallId: z.string().optional(), diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index d0bd589de..6ef060d23 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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' @@ -25,6 +26,7 @@ export const LevelNode = BaseNode.extend({ FenceNode.shape.id, ColumnNode.shape.id, ItemNode.shape.id, + MeasurementNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, diff --git a/packages/core/src/schema/nodes/measurement.ts b/packages/core/src/schema/nodes/measurement.ts new file mode 100644 index 000000000..aba6ea828 --- /dev/null +++ b/packages/core/src/schema/nodes/measurement.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +const MeasurementPoint = z.tuple([z.number(), z.number(), z.number()]) + +export const MeasurementPointAttachment = z.object({ + buildingId: z.string().optional(), + feature: z.discriminatedUnion('kind', [ + z.object({ index: z.number().int().nonnegative(), kind: z.literal('plan-anchor') }), + z.object({ + index: z.number().int().nonnegative(), + kind: z.literal('plan-segment'), + t: z.number().min(0).max(1), + }), + z.object({ kind: z.literal('node-bounds'), normalized: MeasurementPoint }), + ]), + nodeId: z.string(), + ownerNodeId: z.string().optional(), +}) + +export const MeasurementNode = BaseNode.extend({ + id: objectId('measurement'), + type: nodeType('measurement'), + measurementId: z.string(), + start: MeasurementPoint, + end: MeasurementPoint, + view: z.enum(['2d', '3d']), + measuredDistanceMeters: z.number().positive().optional(), + startAttachment: MeasurementPointAttachment.optional(), + endAttachment: MeasurementPointAttachment.optional(), +}) + +export type MeasurementNode = z.infer +export type MeasurementPointAttachment = z.infer diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 89d806b28..0074cb641 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -26,7 +26,7 @@ export const RoofNode = BaseNode.extend({ // Rotation around Y axis in radians rotation: z.number().default(0), // Child roof segment IDs - children: z.array(RoofSegmentNode.shape.id).default([]), + children: z.array(z.union([RoofSegmentNode.shape.id, objectId('measurement')])).default([]), }).describe( dedent` Roof node - a container for roof segments. diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 3e9b74b2a..fcbe28735 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -40,7 +40,7 @@ export const ShelfNode = BaseNode.extend({ // not be rendered, making the commit look like "the item went // somewhere else"). The action's parent-update branch needs the field // present at parse-time so the children array is always defined. - children: z.array(ItemNode.shape.id).default([]), + children: z.array(z.union([ItemNode.shape.id, objectId('measurement')])).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/stair-segment.ts b/packages/core/src/schema/nodes/stair-segment.ts index 749675a73..7cb3ab33d 100644 --- a/packages/core/src/schema/nodes/stair-segment.ts +++ b/packages/core/src/schema/nodes/stair-segment.ts @@ -29,6 +29,7 @@ export const StairSegmentNode = BaseNode.extend({ height: z.number().default(2.5), // Number of steps (only used for stair type) stepCount: z.number().default(10), + children: z.array(objectId('measurement')).default([]), // Which side of the previous segment to attach to attachmentSide: AttachmentSide.default('front'), // Whether to fill the underside down to floor level diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index abc1fe54a..c96576a0a 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -56,7 +56,7 @@ export const StairNode = BaseNode.extend({ railingMode: StairRailingMode.default('none'), railingHeight: z.number().default(0.92), // Child stair segment IDs - children: z.array(StairSegmentNode.shape.id).default([]), + children: z.array(z.union([StairSegmentNode.shape.id, objectId('measurement')])).default([]), }).describe( dedent` Stair node - a container for stair segments. diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 4bfaa0634..f52e5612e 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -131,7 +131,9 @@ export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), children: z - .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) + .array( + z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id, objectId('measurement')]), + ) .default([]), // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 69547d5f5..8c1e96cf5 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -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' @@ -83,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [ HvacEquipmentNode, LinesetNode, LiquidLineNode, + MeasurementNode, PipeSegmentNode, PipeFittingNode, PipeTrapNode, diff --git a/packages/core/src/systems/stair/stair-opening-preview.ts b/packages/core/src/systems/stair/stair-opening-preview.ts index 24b464189..704dc2977 100644 --- a/packages/core/src/systems/stair/stair-opening-preview.ts +++ b/packages/core/src/systems/stair/stair-opening-preview.ts @@ -49,7 +49,7 @@ export function getNodesWithLiveStairOpeningInputs( liveTransforms: ReadonlyMap, liveOverrides: ReadonlyMap, previewSurfaceIds: ReadonlySet, -) { +): Record { const nextNodes: Record = { ...nodes } for (const [nodeId, override] of liveOverrides) { diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index a987866b6..dca9a4099 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -610,6 +610,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB // which lag the `nodes` commit by a frame or two. const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { + void meshEpoch if (selectedIds.length < 2 || !levelId) return null const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, @@ -627,7 +628,6 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB width: Math.abs(max.x - min.x), depth: Math.abs(max.z - min.z), } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [selectedIds, levelId, nodes, meshEpoch]) if (!box || movingNode || mode === 'delete') return null diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts new file mode 100644 index 000000000..cb0c62bec --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts @@ -0,0 +1,1508 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type GridEvent, useScene } from '@pascal-app/core' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { registerMeasurementTestNodes } from '../../lib/register-measurement-test-nodes' +import { + DEFAULT_MEASUREMENT_SNAP_SETTINGS, + type MeasurementSnapKind, + useMeasurementTool, +} from '../../store/use-measurement-tool' +import { + getFloorplanAngleMeasurementLayout, + getFloorplanEndpointHandleMetrics, + getFloorplanMeasurementColor, + handleFloorplanMeasurementGridClick, + handleFloorplanMeasurementGridMove, + handleFloorplanMeasurementNodeClick2D, + previewFloorplanMeasurementNode2D, + staggerFloorplanMeasurementLabels, +} from './floorplan-measurement-tool-layer' +import { + FloorplanMeasurementsLayer, + getFloorplanMeasurementPillMetrics, +} from './renderers/floorplan-measurements-layer' + +beforeAll(() => { + class TestCanvas {} + globalThis.HTMLCanvasElement = TestCanvas as never + registerMeasurementTestNodes() +}) + +beforeEach(() => { + useScene.getState().clearScene() + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + resetSnapSettings() +}) + +afterEach(() => { + useScene.getState().clearScene() + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + resetSnapSettings() +}) + +function resetSnapSettings() { + for (const [kind, enabled] of Object.entries(DEFAULT_MEASUREMENT_SNAP_SETTINGS)) { + useMeasurementTool.getState().setSnapKindEnabled(kind as MeasurementSnapKind, enabled) + } +} + +test('builds floorplan angle measurement as a wedge with a pill anchor', () => { + const layout = getFloorplanAngleMeasurementLayout({ + first: [1, 0, 0], + id: 'angle-layout', + second: [0, 0, 1], + vertex: [0, 0, 0], + view: '2d', + }) + + expect(layout).not.toBeNull() + expect(layout?.wedgePath.startsWith('M 0 0 L 0.35 0')).toBe(true) + expect(layout?.arcPath.startsWith('M 0.35 0')).toBe(true) + expect(layout?.arcRadials).toHaveLength(2) + expect(layout?.arcRadials[0]).toEqual({ x1: 0, x2: 0.35, y1: 0, y2: 0 }) + expect(layout?.arcRadials[1]?.x1).toBe(0) + expect(layout?.arcRadials[1]?.x2).toBeCloseTo(0) + expect(layout?.arcRadials[1]?.y1).toBe(0) + expect(layout?.arcRadials[1]?.y2).toBeCloseTo(0.35) + expect(layout?.label.x).toBeCloseTo(0.403) + expect(layout?.label.y).toBeCloseTo(0.403) +}) + +function gridEvent( + localPosition: [number, number, number], + options: { altKey?: boolean; shiftKey?: boolean; target?: unknown } = {}, +): GridEvent { + return { + position: localPosition, + localPosition, + nativeEvent: { + altKey: options.altKey ?? false, + shiftKey: options.shiftKey ?? false, + target: options.target ?? {}, + } as never, + } +} + +function zoneNode(): AnyNode { + return { + id: 'zone_measurement_2d', + type: 'zone', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + } as never +} + +function areaOutlinedPerimeterNode(): AnyNode { + return { + ...zoneNode(), + id: 'area_outlined_perimeter_measurement_2d', + type: 'surface-perimeter-without-boundary', + } as never +} + +function slabWithHoleNode(): AnyNode { + return { + id: 'slab_measurement_hole_2d', + type: 'slab', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + elevation: 0, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + } as never +} + +function siteNode(): AnyNode { + return { + id: 'site_measurement_2d', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: { + type: 'polygon', + points: [ + [-2, -1], + [2, -1], + [2, 1], + [-2, 1], + ], + }, + } as never +} + +function wallNode(): AnyNode { + return { + id: 'wall_measurement_2d', + type: 'wall', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + } as never +} + +function curvedWallNode(): AnyNode { + return { + ...wallNode(), + id: 'wall_measurement_curved_2d', + curveOffset: 1, + } as never +} + +function splineFenceNode(): AnyNode { + return { + id: 'fence_measurement_spline_2d', + type: 'fence', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + path: [ + [0, 0], + [2, 1], + [4, 0], + ], + } as never +} + +function windowNode(): AnyNode { + return { + id: 'window_measurement_2d', + type: 'window', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + wallId: 'wall_measurement_2d', + position: [2, 1, 0], + width: 1, + height: 1, + } as never +} + +function shelfNode(): AnyNode { + return { + id: 'shelf_measurement_2d', + type: 'shelf', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + position: [2, 0, 3], + rotation: [0, 0, 0], + width: 2, + depth: 0.6, + height: 1.2, + } as never +} + +function looseSkylightNode(): AnyNode { + return { + id: 'loose_skylight_measurement_2d', + type: 'skylight', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + position: [5, 0, 6], + rotation: 0, + width: 0.9, + height: 1.2, + } as never +} + +function solarPanelNode(): AnyNode { + return { + id: 'solar_panel_measurement_2d', + type: 'solar-panel', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + position: [8, 0, 4], + rotation: 0, + rows: 2, + columns: 3, + panelWidth: 1, + panelHeight: 1.65, + gapX: 0.02, + gapY: 0.02, + } as never +} + +function crossingWallNode(): AnyNode { + return { + id: 'wall_measurement_crossing_2d', + type: 'wall', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [2, -2], + end: [2, 2], + } as never +} + +function stairNode(): AnyNode { + return { + id: 'stair_measurement_2d', + type: 'stair', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['stair_segment_measurement_2d'], + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + } as never +} + +function stairSegmentNode(): AnyNode { + return { + id: 'stair_segment_measurement_2d', + type: 'stair-segment', + object: 'node', + parentId: 'stair_measurement_2d', + visible: true, + metadata: {}, + children: [], + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + } as never +} + +function ductSegmentNode(): AnyNode { + return { + id: 'duct_segment_measurement_2d', + type: 'duct-segment', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + path: [ + [0, 2.4, 0], + [2, 2.4, 0], + [2, 2.4, 3], + ], + } as never +} + +function roofNode(overrides: Partial = {}): AnyNode { + return { + id: 'roof_measurement_2d', + type: 'roof', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['roof_segment_measurement_2d'], + position: [0, 0, 0], + rotation: 0, + ...overrides, + } as never +} + +function roofSegmentNode(overrides: Partial = {}): AnyNode { + return { + id: 'roof_segment_measurement_2d', + type: 'roof-segment', + object: 'node', + parentId: 'roof_measurement_2d', + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 4, + depth: 2, + pitch: 40, + wallHeight: 0.5, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + gambrelLowerWidthRatio: 0.5, + mansardSteepWidthRatio: 0.15, + dutchHipWidthRatio: 0.25, + dutchWaistLengthRatio: 0.98, + ...overrides, + } as never +} + +function skylightNode(): AnyNode { + return { + id: 'skylight_measurement_2d', + type: 'skylight', + object: 'node', + parentId: 'roof_segment_measurement_2d', + visible: true, + metadata: {}, + children: [], + roofSegmentId: 'roof_segment_measurement_2d', + position: [0, 0, 0], + rotation: 0, + width: 2, + height: 1, + } as never +} + +function seedScene(nodes: AnyNode[]) { + useScene.setState({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes.map((node) => node.id as AnyNodeId), + dirtyNodes: new Set(), + collections: {}, + } as never) +} + +function measurementOverlay(id: string, labelX: number, labelY: number) { + return { + id, + label: id, + labelX, + labelY, + labelAngleDeg: 0, + dimensionLineStart: { x1: 0, y1: 0, x2: 1, y2: 0 }, + dimensionLineEnd: { x1: 1, y1: 0, x2: 2, y2: 0 }, + extensionStart: { x1: 0, y1: 0, x2: 0, y2: 0 }, + extensionEnd: { x1: 2, y1: 0, x2: 2, y2: 0 }, + } +} + +describe('floorplan measurement grid handlers', () => { + test('uses violet as the floorplan measurement color', () => { + expect(getFloorplanMeasurementColor()).toBe('#8b5cf6') + }) + + test('renders linear measurement labels as zoom-stable pills', () => { + const markup = renderToStaticMarkup( + createElement( + 'svg', + null, + createElement(FloorplanMeasurementsLayer, { + className: 'floorplan-measurement-tool', + measurements: [ + { + ...measurementOverlay('12.34 m', 1, 1), + isSelected: true, + label: '12.34 m', + }, + ], + palette: { + measurementStroke: 'rgb(14, 165, 233)', + }, + sceneRotationDeg: 0, + }), + ), + ) + + expect(markup).toContain(' { + const metrics = getFloorplanMeasurementPillMetrics('12.34 m', 0.01) + + expect(metrics.fontSize).toBe(0.12) + expect(metrics.height).toBeCloseTo(0.28) + expect(metrics.pixelHeight).toBe(28) + expect(metrics.pixelWidth).toBeCloseTo(79.04) + expect(metrics.radius).toBeCloseTo(0.14) + expect(metrics.strokeWidth).toBeCloseTo(0.01) + expect(metrics.width).toBeCloseTo(0.7904) + }) + + test('keeps shared HTML pill dimensions stable across floorplan zoom', () => { + const base = getFloorplanMeasurementPillMetrics('12.34 m', 0.01) + const zoomed = getFloorplanMeasurementPillMetrics('12.34 m', 0.05) + + expect(zoomed.pixelHeight).toBe(base.pixelHeight) + expect(zoomed.pixelWidth).toBe(base.pixelWidth) + expect(zoomed.height / 0.05).toBeCloseTo(base.height / 0.01) + expect(zoomed.width / 0.05).toBeCloseTo(base.width / 0.01) + }) + + test('keeps endpoint handle hit and visible radii tied to screen zoom', () => { + expect(getFloorplanEndpointHandleMetrics(0.01, false)).toEqual({ + handleRadius: 0.055, + hitRadius: 0.16, + }) + expect(getFloorplanEndpointHandleMetrics(0.01, true)).toEqual({ + handleRadius: 0.07, + hitRadius: 0.16, + }) + }) + + test('staggers overlapping 2D measurement labels', () => { + const overlays = staggerFloorplanMeasurementLabels([ + measurementOverlay('a', 1, 1), + measurementOverlay('b', 1.02, 1.01), + measurementOverlay('c', 4, 4), + ]) + + expect(overlays[0]?.labelX).toBe(1) + expect(overlays[0]?.labelY).toBe(1) + expect(overlays[1]?.labelX).toBeCloseTo(1.02) + expect(overlays[1]?.labelY).toBeGreaterThan(1.01) + expect(overlays[2]?.labelX).toBe(4) + expect(overlays[2]?.labelY).toBe(4) + }) + + test('commits a 2D point-to-point distance from two grid clicks', () => { + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridMove(gridEvent([3, 0, 4])) + handleFloorplanMeasurementGridClick(gridEvent([3, 0, 4])) + + const state = useMeasurementTool.getState() + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [3, 0, 4], + view: '2d', + }) + expect(state.selectedId).toBe(state.segments[0]?.id ?? null) + }) + + test('updates the draft endpoint while moving the pointer', () => { + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridMove(gridEvent([2, 0, 0])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 0], + view: '2d', + }) + }) + + test('drags a saved 2D measurement endpoint through snapping', () => { + seedScene([wallNode()]) + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.setSnapKindEnabled('guide', false) + measurement.startSegmentEndpointDrag(segmentId, 'end') + handleFloorplanMeasurementGridMove(gridEvent([3.9, 0, 0.08])) + handleFloorplanMeasurementGridClick(gridEvent([3.9, 0, 0.08])) + + const segment = useMeasurementTool.getState().segments[0] + expect(segment).toMatchObject({ + start: [1, 0, 1], + measuredDistanceMeters: undefined, + view: '2d', + }) + expect(segment?.end[0]).toBeCloseTo(4) + expect(segment?.end[1]).toBeCloseTo(0) + expect(segment?.end[2]).toBeCloseTo(0) + expect(segment?.endAttachment).toMatchObject({ + feature: { kind: 'plan-anchor' }, + nodeId: 'wall_measurement_2d', + }) + expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull() + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + point: [4, 0, 0], + view: '2d', + }) + }) + + test('Alt detaches a linked 2D measurement endpoint while dragging', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [1, 0, 1], [2, 0, 1], 1) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('2d', [2, 0, 1], [2, 0, 3], 2) + const secondId = useMeasurementTool.getState().segments[1]!.id + + measurement.startSegmentEndpointDrag(firstId, 'end') + handleFloorplanMeasurementGridMove(gridEvent([3, 0, 1], { altKey: true })) + handleFloorplanMeasurementGridClick(gridEvent([3, 0, 1], { altKey: true })) + + const segments = useMeasurementTool.getState().segments + expect(segments.find((segment) => segment.id === firstId)).toMatchObject({ + end: [3, 0, 1], + measuredDistanceMeters: undefined, + }) + expect(segments.find((segment) => segment.id === secondId)).toMatchObject({ + measuredDistanceMeters: 2, + start: [2, 0, 1], + }) + }) + + test('does not start a new 2D measurement from the click after endpoint drag release', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'end') + handleFloorplanMeasurementGridMove(gridEvent([3, 0, 1])) + measurement.endSegmentEndpointDrag({ suppressNextClick: true }) + handleFloorplanMeasurementGridClick(gridEvent([3, 0, 1])) + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.draft).toBeNull() + expect(state.segments[0]).toMatchObject({ + end: [3, 0, 1], + start: [1, 0, 1], + view: '2d', + }) + }) + + test('shift does not axis-lock the 2D draft endpoint while drawing', () => { + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridMove(gridEvent([1, 0, 4], { shiftKey: true })) + handleFloorplanMeasurementGridClick(gridEvent([1, 0, 4], { shiftKey: true })) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [1, 0, 4], + view: '2d', + }) + }) + + test('snaps the first 2D placement point to nearby wall anchors', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 0.06])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + label: 'Endpoint', + point: [0, 0, 0], + view: '2d', + }) + }) + + test('does not snap 2D placement to endpoints when endpoint snaps are disabled', () => { + seedScene([wallNode()]) + useMeasurementTool.getState().setSnapKindEnabled('endpoint', false) + useMeasurementTool.getState().setSnapKindEnabled('edge', false) + useMeasurementTool.getState().setSnapKindEnabled('grid', false) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 0.06])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.08, 0, 0.06], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.kind).not.toBe('endpoint') + }) + + test('snaps the committed 2D endpoint to nearby wall anchors', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 0.06])) + handleFloorplanMeasurementGridMove(gridEvent([3.9, 0, 0.08])) + handleFloorplanMeasurementGridClick(gridEvent([3.9, 0, 0.08])) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + view: '2d', + }) + }) + + test('tracks 2D snap target while moving before first placement', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridMove(gridEvent([2.04, 0, 0.04])) + + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Midpoint', + point: [2, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to wall edge projections', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([1.25, 0, 0.08])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.25, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Wall edge', + point: [1.25, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to sampled curved wall edges', () => { + seedScene([curvedWallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.8819660112501051, 0, -0.7360679774997898])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.8819660112501051, 0, -0.7360679774997898], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Wall edge', + point: [0.8819660112501051, 0, -0.7360679774997898], + view: '2d', + }) + }) + + test('snaps 2D placement points to sampled spline fence edges', () => { + seedScene([splineFenceNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.9704915028125263, 0, 0.625])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.9704915028125263, 0, 0.625], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Fence edge', + point: [0.9704915028125263, 0, 0.625], + view: '2d', + }) + }) + + test('snaps 2D placement points to hosted opening endpoints', () => { + seedScene([wallNode(), windowNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([1.52, 0, 0.03])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.5, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + label: 'Opening endpoint', + point: [1.5, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to hosted opening centers', () => { + seedScene([wallNode(), windowNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([2.03, 0, 0.04])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [2, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'center', + label: 'Opening center', + point: [2, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to hosted opening edges', () => { + seedScene([wallNode(), windowNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([1.75, 0, 0.08])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.75, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Opening edge', + point: [1.75, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to composite stair footprint corners', () => { + seedScene([stairNode(), stairSegmentNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([-0.48, 0, 2.96])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [-0.5, 0, 3], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Stair corner', + point: [-0.5, 0, 3], + view: '2d', + }) + }) + + test('snaps 2D placement points to MEP run path edges', () => { + seedScene([ductSegmentNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([2.08, 0, 1.25])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [2, 0, 1.25], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Run edge', + point: [2, 0, 1.25], + view: '2d', + }) + }) + + test('snaps 2D placement points to surface opening hole edges', () => { + seedScene([slabWithHoleNode()]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleFloorplanMeasurementGridClick(gridEvent([1.4, 0, 1.08])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.4, 0, 1], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Surface opening edge', + point: [1.4, 0, 1], + view: '2d', + }) + }) + + test('snaps 2D placement points to site property lines', () => { + seedScene([siteNode()]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleFloorplanMeasurementGridClick(gridEvent([0.4, 0, -1.08])) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0.4) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(-1) + expect(draft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Property line edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(-1) + }) + + test('snaps 2D placement points to roof ridge edges', () => { + seedScene([roofNode(), roofSegmentNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([1.25, 0, 0.08])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.25, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + point: [1.25, 0, 0], + view: '2d', + }) + }) + + test('snaps 2D placement points to roof eave corners', () => { + seedScene([roofNode(), roofSegmentNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([-2.03, 0, -1.04])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [-2, 0, -1], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Roof eave corner', + point: [-2, 0, -1], + view: '2d', + }) + }) + + test('snaps 2D placement points to rotated roof ridge edges', () => { + seedScene([roofNode(), roofSegmentNode({ rotation: Math.PI / 2 } as never)]) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, -1.25])) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(-1.25) + expect(draft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(-1.25) + }) + + test('snaps 2D placement points to roof accessory edges', () => { + seedScene([ + roofNode({ position: [10, 0, 20], rotation: Math.PI / 2 } as never), + roofSegmentNode({ position: [2, 0, 0] } as never), + skylightNode(), + ]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleFloorplanMeasurementGridClick(gridEvent([10.58, 0, 18.2])) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(10.5) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(18.2) + expect(draft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Skylight edge', + view: '2d', + }) + }) + + test('snaps 2D placement points to hip roof edges', () => { + seedScene([roofNode(), roofSegmentNode({ roofType: 'hip' } as never)]) + + handleFloorplanMeasurementGridClick(gridEvent([-1.2, 0, 0.2])) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(-1.2) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(0.2) + expect(draft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof hip edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(-1.2) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.2) + }) + + test('snaps 2D placement points to mansard roof break edges', () => { + seedScene([roofNode(), roofSegmentNode({ roofType: 'mansard' } as never)]) + + handleFloorplanMeasurementGridClick(gridEvent([0.6, 0, 0.7])) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0.6) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(0.7) + expect(draft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof break edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.6) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.7) + }) + + test('snaps 2D placement points to dutch roof ridge and break edges', () => { + seedScene([roofNode(), roofSegmentNode({ roofType: 'dutch' } as never)]) + + handleFloorplanMeasurementGridClick(gridEvent([0.4, 0, 0.08])) + + const ridgeDraft = useMeasurementTool.getState().draft + expect(ridgeDraft?.start[0]).toBeCloseTo(0.4) + expect(ridgeDraft?.start[1]).toBeCloseTo(0) + expect(ridgeDraft?.start[2]).toBeCloseTo(0) + expect(ridgeDraft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0) + + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + + handleFloorplanMeasurementGridClick(gridEvent([0.4, 0, 0.5])) + + const breakDraft = useMeasurementTool.getState().draft + expect(breakDraft?.start[0]).toBeCloseTo(0.4) + expect(breakDraft?.start[1]).toBeCloseTo(0) + expect(breakDraft?.start[2]).toBeCloseTo(0.5) + expect(breakDraft?.view).toBe('2d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof break edge', + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.5) + }) + + test('prefers 2D endpoints over closer edge projections in crowded snaps', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.12, 0, 0.04])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + label: 'Endpoint', + point: [0, 0, 0], + view: '2d', + }) + }) + + test('prefers 2D wall intersections when segments cross nearby', () => { + seedScene([wallNode(), crossingWallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([2.03, 0, 0.04])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [2, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'intersection', + label: 'Intersection', + point: [2, 0, 0], + view: '2d', + }) + }) + + test('falls back to 2D grid snapping when no object target is nearby', () => { + handleFloorplanMeasurementGridClick(gridEvent([1.04, 0, 1.96])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1, 0, 2], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'grid', + label: 'Grid', + point: [1, 0, 2], + view: '2d', + }) + }) + + test('does not snap 2D points to anchors outside the snap radius', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([1.04, 0, 1.96])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1, 0, 2], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Grid', + point: [1, 0, 2], + view: '2d', + }) + }) + + test('snaps 2D points to saved measurement midpoints', () => { + useMeasurementTool.getState().addSegment('2d', [0, 0, 0], [3, 0, 0]) + + handleFloorplanMeasurementGridClick(gridEvent([1.52, 0, 0.04])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.5, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'measurement', + label: 'Measurement midpoint', + point: [1.5, 0, 0], + view: '2d', + }) + }) + + test('constrains 2D distance endpoints parallel to a nearby host edge', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 0.06])) + handleFloorplanMeasurementGridMove(gridEvent([2, 0, 0.08])) + handleFloorplanMeasurementGridClick(gridEvent([2, 0, 0.08])) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + guideLine: { + end: [2, 0, 0], + start: [-2, 0, 0], + }, + kind: 'guide', + label: 'Parallel', + point: [2, 0, 0], + view: '2d', + }) + }) + + test('constrains 2D distance endpoints perpendicular to a nearby host edge', () => { + seedScene([wallNode()]) + + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 0.06])) + handleFloorplanMeasurementGridMove(gridEvent([0.08, 0, 2])) + handleFloorplanMeasurementGridClick(gridEvent([0.08, 0, 2])) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [0, 0, 2], + view: '2d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Perpendicular', + point: [0, 0, 2], + view: '2d', + }) + }) + + test('snaps 2D angle points to nearby wall anchors', () => { + seedScene([wallNode()]) + useMeasurementTool.getState().setMode('angle') + + handleFloorplanMeasurementGridClick(gridEvent([2.06, 0, 0.04])) + handleFloorplanMeasurementGridClick(gridEvent([3.92, 0, 0.05])) + + expect(useMeasurementTool.getState().angles[0]).toMatchObject({ + vertex: [2, 0, 0], + second: [4, 0, 0], + view: '2d', + }) + expect(useMeasurementTool.getState().angles[0]?.first).toEqual([4, 0, 0]) + }) + + test('uses the adjacent 2D corner edge as the angle reference', () => { + seedScene([zoneNode()]) + useMeasurementTool.getState().setMode('angle') + + handleFloorplanMeasurementGridClick(gridEvent([0.06, 0, 0.04])) + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 3])) + + expect(useMeasurementTool.getState().angles[0]).toMatchObject({ + first: [3, 0, 0], + vertex: [0, 0, 0], + second: [0, 0, 3], + view: '2d', + }) + }) + + test('commits a 2D angle in angle mode', () => { + const measurement = useMeasurementTool.getState() + measurement.setMode('angle') + + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridMove(gridEvent([0, 0, 1])) + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 1])) + + const state = useMeasurementTool.getState() + expect(state.angleDraft).toBeNull() + expect(state.angles).toHaveLength(1) + expect(state.angles[0]).toMatchObject({ + first: [1, 0, 0], + vertex: [0, 0, 0], + second: [0, 0, 1], + view: '2d', + }) + }) + + test('ignores events that came from the 3D canvas', () => { + const canvasTarget = new globalThis.HTMLCanvasElement() + + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0], { target: canvasTarget })) + + expect(useMeasurementTool.getState().draft).toBeNull() + expect(useMeasurementTool.getState().segments).toHaveLength(0) + }) + + test('adds 2D surface area in area mode', () => { + useMeasurementTool.getState().setMode('area') + + const handled = handleFloorplanMeasurementNodeClick2D(zoneNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.areas).toHaveLength(1) + expect(state.areas[0]).toMatchObject({ + areaSquareMeters: 12, + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + labelPoint: [2, 0, 1.5], + view: '2d', + }) + expect(state.perimeters).toHaveLength(0) + }) + + test('hovering a 2D surface previews area in area mode without saving it', () => { + useMeasurementTool.getState().setMode('area') + + const handled = previewFloorplanMeasurementNode2D(zoneNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewArea).toMatchObject({ + areaSquareMeters: 12, + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + view: '2d', + }) + expect(state.areas).toHaveLength(0) + }) + + test('commits a freeform 2D area polygon by clicking the first point again', () => { + useMeasurementTool.getState().setMode('area') + + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridClick(gridEvent([4, 0, 0])) + handleFloorplanMeasurementGridClick(gridEvent([4, 0, 3])) + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.polygonDraft).toBeNull() + expect(state.areas).toHaveLength(1) + expect(state.areas[0]).toMatchObject({ + areaSquareMeters: 6, + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + ], + view: '2d', + }) + }) + + test('adds 2D surface perimeter in perimeter mode', () => { + useMeasurementTool.getState().setMode('perimeter') + + const handled = handleFloorplanMeasurementNodeClick2D(zoneNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.perimeters).toHaveLength(1) + expect(state.perimeters[0]).toMatchObject({ + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + labelPoint: [2, 0, 1.5], + lengthMeters: 14, + view: '2d', + }) + expect(state.areas).toHaveLength(0) + }) + + test('hovering a 2D surface previews perimeter in perimeter mode without saving it', () => { + useMeasurementTool.getState().setMode('perimeter') + + const handled = previewFloorplanMeasurementNode2D(zoneNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewPerimeter).toMatchObject({ + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + lengthMeters: 14, + view: '2d', + }) + expect(state.perimeters).toHaveLength(0) + }) + + test('2D perimeter reuses the area outline when its perimeter has no boundary points', () => { + useMeasurementTool.getState().setMode('perimeter') + + const handled = previewFloorplanMeasurementNode2D(areaOutlinedPerimeterNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewPerimeter).toMatchObject({ + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + lengthMeters: 14, + view: '2d', + }) + }) + + test('commits a freeform 2D perimeter polygon by clicking the first point again', () => { + useMeasurementTool.getState().setMode('perimeter') + + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + handleFloorplanMeasurementGridClick(gridEvent([4, 0, 0])) + handleFloorplanMeasurementGridClick(gridEvent([4, 0, 3])) + handleFloorplanMeasurementGridClick(gridEvent([0, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.polygonDraft).toBeNull() + expect(state.perimeters).toHaveLength(1) + expect(state.perimeters[0]).toMatchObject({ + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + ], + lengthMeters: 12, + view: '2d', + }) + }) + + test('alt-click on a 2D surface adds perimeter only', () => { + const handled = handleFloorplanMeasurementNodeClick2D(zoneNode() as never, { + altKey: true, + }) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.perimeters).toHaveLength(1) + expect(state.areas).toHaveLength(0) + }) + + test('normal click on a 2D measurable wall leaves drawing to the grid handler', () => { + const handled = handleFloorplanMeasurementNodeClick2D(wallNode() as never) + + expect(handled).toBe(false) + expect(useMeasurementTool.getState().segments).toHaveLength(0) + }) + + test('hovering a 2D measurable wall previews its direct length without saving it', () => { + const handled = previewFloorplanMeasurementNode2D(wallNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewSegment).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '2d', + }) + expect(state.segments).toHaveLength(0) + + handleFloorplanMeasurementGridMove(gridEvent([8, 0, 0])) + + expect(useMeasurementTool.getState().previewSegment).toBeNull() + }) + + test('alt-click on a 2D measurable wall adds its length in distance mode', () => { + const handled = handleFloorplanMeasurementNodeClick2D(wallNode() as never, { + altKey: true, + }) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '2d', + }) + }) + + test('ctrl-click on a 2D measurable wall quick-adds its length without taking over drawing clicks', () => { + const handled = handleFloorplanMeasurementNodeClick2D(wallNode() as never, { + ctrlKey: true, + }) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '2d', + }) + }) + + test('alt-click on a 2D window adds its hosted opening width', () => { + seedScene([wallNode(), windowNode()]) + + const handled = handleFloorplanMeasurementNodeClick2D(windowNode() as never, { altKey: true }) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [1.5, 0, 0], + end: [2.5, 0, 0], + measuredDistanceMeters: 1, + view: '2d', + }) + }) + + test('hovering a 2D modular cabinet footprint previews its direct length', () => { + const handled = previewFloorplanMeasurementNode2D(shelfNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewSegment).toMatchObject({ + start: [1, 0, 2.7], + end: [3, 0, 2.7], + measuredDistanceMeters: 2, + view: '2d', + }) + }) + + test('hovering a 2D footprint previews the edge under the cursor', () => { + useMeasurementTool.getState().setSnapTarget({ + kind: 'grid', + label: 'Grid', + point: [0, 0, 0], + view: '2d', + }) + + const handled = previewFloorplanMeasurementNode2D(shelfNode() as never, [2, 0, 3.3]) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewSegment).toMatchObject({ + start: [3, 0, 3.3], + end: [1, 0, 3.3], + measuredDistanceMeters: 2, + view: '2d', + }) + expect(state.cursor).toEqual({ point: [2, 0, 3.3], view: '2d' }) + expect(state.snapTarget).toMatchObject({ + kind: 'edge', + point: [2, 0, 3.3], + targetLine: { + start: [3, 0, 3.3], + end: [1, 0, 3.3], + }, + view: '2d', + }) + }) + + test('normal click after a 2D hover preview leaves drawing to the grid handler', () => { + previewFloorplanMeasurementNode2D(shelfNode() as never, [2, 0, 3.3]) + + const handled = handleFloorplanMeasurementNodeClick2D(shelfNode() as never, { + cursorPoint: [2, 0, 3.3], + }) + + const state = useMeasurementTool.getState() + expect(handled).toBe(false) + expect(state.draft).toBeNull() + expect(state.previewSegment).toMatchObject({ + start: [3, 0, 3.3], + end: [1, 0, 3.3], + view: '2d', + }) + expect(state.segments).toHaveLength(0) + }) + + test('hovering a 2D skylight footprint uses width by height as direct length', () => { + const handled = previewFloorplanMeasurementNode2D(looseSkylightNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewSegment?.measuredDistanceMeters).toBeCloseTo(1.2) + expect(state.previewSegment?.start).toEqual([5.45, 0, 5.4]) + expect(state.previewSegment?.end).toEqual([5.45, 0, 6.6]) + }) + + test('hovering a 2D solar array footprint uses its full panel grid dimensions', () => { + const handled = previewFloorplanMeasurementNode2D(solarPanelNode() as never) + + const state = useMeasurementTool.getState() + expect(handled).toBe(true) + expect(state.previewSegment?.measuredDistanceMeters).toBeCloseTo(3.32) + expect(state.previewSegment?.start[0]).toBeCloseTo(9.52) + expect(state.previewSegment?.start[2]).toBeCloseTo(2.34) + expect(state.previewSegment?.end[0]).toBeCloseTo(9.52) + expect(state.previewSegment?.end[2]).toBeCloseTo(5.66) + }) + + test('deleteSelected removes the selected 2D measurement', () => { + handleFloorplanMeasurementNodeClick2D(wallNode() as never, { + altKey: true, + }) + + expect(useMeasurementTool.getState().segments).toHaveLength(1) + useMeasurementTool.getState().deleteSelected() + + expect(useMeasurementTool.getState().segments).toHaveLength(0) + expect(useMeasurementTool.getState().selectedId).toBeNull() + }) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx new file mode 100644 index 000000000..39089a66e --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -0,0 +1,1732 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + type GeometryContext, + type GridEvent, + type MeasurementDefinitionArea, + type MeasurementDefinitionDirectLength, + type MeasurementDefinitionPerimeter, + nodeRegistry, + useScene, +} from '@pascal-app/core' +import { type PointerEvent, useEffect, useMemo } from 'react' +import { markToolCancelConsumed } from '../../hooks/use-keyboard' +import { getRotatedRectanglePolygon } from '../../lib/floorplan/geometry' +import { + createPlanMeasurementAttachment, + getResolvedMeasurementSegments, + useResolvedMeasurementSegments, +} from '../../lib/measurement-attachments' +import { + collectCommittedMeasurementSnapGeometry, + collectPlanMeasurementSnapGeometry, + mergeMeasurementSnapGeometry, + resolvePlanMeasurementConstraint, + resolvePlanMeasurementSnap, +} from '../../lib/measurement-snapping' +import { + angleBetweenMeasurements, + formatAngleMeasurement, + formatAreaMeasurement, + formatLinearMeasurement, + type LinearUnit, +} from '../../lib/measurements' +import useEditor from '../../store/use-editor' +import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview' +import useInteractionScope from '../../store/use-interaction-scope' +import { + distanceBetweenMeasurements, + isDraggingMeasurementEndpoint, + type MeasurementAngle, + type MeasurementArea, + type MeasurementDisplayPrecision, + type MeasurementPerimeter, + type MeasurementPoint, + type MeasurementPolygonDraft, + type MeasurementSegment, + type MeasurementSegmentEndpoint, + type MeasurementSnapTarget, + polygonAreaAndLabelPointFromMeasurements, + polygonPerimeterFromMeasurements, + useMeasurementTool, +} from '../../store/use-measurement-tool' +import { useFloorplanRender } from './floorplan-render-context' +import { + FloorplanMeasurementPillLabel, + FloorplanMeasurementsLayer, + getFloorplanMeasurementPillMetrics, + type LinearMeasurementOverlay, +} from './renderers/floorplan-measurements-layer' + +type FloorplanMeasurementToolLayerProps = { + active: boolean + palette: { + measurementStroke: string + } + sceneRotationDeg: number + unit: LinearUnit +} + +const FLOORPLAN_MEASUREMENT_SNAP_RADIUS = 0.25 +const FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX = 5.5 +const FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 7 +const FLOORPLAN_ENDPOINT_HANDLE_HIT_RADIUS_PX = 16 +const FLOORPLAN_SNAP_MARKER_RADIUS_PX = 7 +const FLOORPLAN_LABEL_COLLISION_CELL = 0.35 +const FLOORPLAN_LABEL_STAGGER_STEP = 0.22 +const FLOORPLAN_MEASUREMENT_LABEL_LINE_GAP_PX = 4 +const FLOORPLAN_MEASUREMENT_COLOR = '#8b5cf6' +const FLOORPLAN_SNAP_COLOR = '#ffffff' +const FLOORPLAN_MEASUREMENT_STROKE_WIDTH = 1.2 +const FLOORPLAN_DEGENERATE_MEASUREMENT_MARKER_LENGTH = 0.32 +const FLOORPLAN_ANGLE_ARC_MIN_RADIUS = 0.2 +const FLOORPLAN_ANGLE_ARC_MAX_RADIUS = 0.7 +const FLOORPLAN_ANGLE_ARC_SEGMENTS = 32 +const FLOORPLAN_ANGLE_WEDGE_FILL = 'rgba(139, 92, 246, 0.14)' + +export function getFloorplanEndpointHandleMetrics(unitsPerPixel: number, activeHandle: boolean) { + return { + handleRadius: + unitsPerPixel * + (activeHandle + ? FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX + : FLOORPLAN_ENDPOINT_HANDLE_RADIUS_PX), + hitRadius: unitsPerPixel * FLOORPLAN_ENDPOINT_HANDLE_HIT_RADIUS_PX, + } +} + +function getFloorplanSnapMarkerMetrics(unitsPerPixel: number) { + const marker = unitsPerPixel * FLOORPLAN_SNAP_MARKER_RADIUS_PX * 2 + return { + markerHalf: marker / 2, + markerStroke: 1.3, + } +} + +export function getFloorplanMeasurementColor() { + return FLOORPLAN_MEASUREMENT_COLOR +} + +function isFloorplanEvent(event: GridEvent): boolean { + const target = event.nativeEvent?.target + return !(target instanceof HTMLCanvasElement) +} + +function pointFromGridEvent(event: GridEvent): MeasurementPoint { + return [...event.localPosition] as MeasurementPoint +} + +function setMeasurementCursorPoint(point: MeasurementPoint | null): void { + useFloorplanDraftPreview.getState().setCursorPoint(point ? [point[0], point[2]] : null) +} + +type DirectLengthSegment = { + end: MeasurementPoint + measuredDistanceMeters: number + start: MeasurementPoint +} + +function distancePlanPointToSegmentSq( + point: MeasurementPoint, + start: MeasurementPoint, + end: MeasurementPoint, +): number { + const px = point[0] + const pz = point[2] + const sx = start[0] + const sz = start[2] + const dx = end[0] - sx + const dz = end[2] - sz + const lengthSq = dx * dx + dz * dz + if (lengthSq < 1e-8) { + const ox = px - sx + const oz = pz - sz + return ox * ox + oz * oz + } + const t = Math.max(0, Math.min(1, ((px - sx) * dx + (pz - sz) * dz) / lengthSq)) + const cx = sx + dx * t + const cz = sz + dz * t + const ox = px - cx + const oz = pz - cz + return ox * ox + oz * oz +} + +function projectPlanPointToMeasurementSegment( + point: MeasurementPoint, + start: MeasurementPoint, + end: MeasurementPoint, +): MeasurementPoint | null { + const sx = start[0] + const sz = start[2] + const dx = end[0] - sx + const dz = end[2] - sz + const lengthSq = dx * dx + dz * dz + if (lengthSq < 1e-8) return null + const t = Math.max(0, Math.min(1, ((point[0] - sx) * dx + (point[2] - sz) * dz) / lengthSq)) + return [sx + dx * t, start[1] + (end[1] - start[1]) * t, sz + dz * t] +} + +function snapTargetFromPreviewSegment2D( + point: MeasurementPoint, + segment: DirectLengthSegment, +): MeasurementSnapTarget | null { + const projected = projectPlanPointToMeasurementSegment(point, segment.start, segment.end) + if (!projected) return null + return { + kind: 'edge', + label: 'Preview edge', + point: projected, + targetLine: { + end: segment.end, + start: segment.start, + }, + view: '2d', + } +} + +function closestPlanSegmentToPoint( + segments: DirectLengthSegment[], + point: MeasurementPoint | null, +): DirectLengthSegment | null { + if (segments.length === 0) return null + if (!point) { + return segments.reduce((longest, segment) => + segment.measuredDistanceMeters > longest.measuredDistanceMeters ? segment : longest, + ) + } + + return segments.reduce((closest, segment) => { + const closestDistanceSq = distancePlanPointToSegmentSq(point, closest.start, closest.end) + const segmentDistanceSq = distancePlanPointToSegmentSq(point, segment.start, segment.end) + if (Math.abs(segmentDistanceSq - closestDistanceSq) < 1e-8) { + return segment.measuredDistanceMeters > closest.measuredDistanceMeters ? segment : closest + } + return segmentDistanceSq < closestDistanceSq ? segment : closest + }) +} + +function rectangleLengthSegment( + polygon: ReadonlyArray<{ x: number; y: number }>, + width: number, + depth: number, + cursorPoint: MeasurementPoint | null = null, +): DirectLengthSegment | null { + if (polygon.length < 4) return null + const segments: DirectLengthSegment[] = [] + for (let index = 0; index < polygon.length; index += 1) { + const start = polygon[index] + const end = polygon[(index + 1) % polygon.length] + if (!(start && end)) continue + segments.push({ + start: [start.x, 0, start.y], + end: [end.x, 0, end.y], + measuredDistanceMeters: index % 2 === 0 ? width : depth, + }) + } + return closestPlanSegmentToPoint(segments, cursorPoint) +} + +function nodeRotationY(node: AnyNode): number { + const rotation = 'rotation' in node ? node.rotation : 0 + if (typeof rotation === 'number') return rotation + if (Array.isArray(rotation) && typeof rotation[1] === 'number') return rotation[1] + return 0 +} + +function measurementGeometryContextForNode(node: AnyNode): GeometryContext { + const nodes = useScene.getState().nodes + const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? [] + return { + children: childIds.flatMap((id: AnyNodeId) => { + const child = nodes[id as AnyNodeId] + return child ? [child] : [] + }), + parent: node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null, + resolve: (id: AnyNodeId) => nodes[id] as N | undefined, + siblings: node.parentId + ? Object.values(nodes).filter( + (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id, + ) + : [], + } +} + +function definitionPoint(point: readonly [number, number, number]): MeasurementPoint { + return [...point] as MeasurementPoint +} + +function definitionPlanPoint(point: readonly [number, number, number]): MeasurementPoint { + return [point[0], 0, point[2]] +} + +function registryMeasurementForNode(node: AnyNode) { + return nodeRegistry.get(node.type)?.measurement +} + +function directLengthFromDefinition( + node: AnyNode, + cursorPoint: MeasurementPoint | null, +): DirectLengthSegment | null { + const directLength = registryMeasurementForNode(node)?.directLength?.( + node as never, + measurementGeometryContextForNode(node), + cursorPoint, + ) as MeasurementDefinitionDirectLength | null | undefined + if (!directLength) return null + return { + start: definitionPoint(directLength.start), + end: definitionPoint(directLength.end), + measuredDistanceMeters: directLength.measuredDistanceMeters, + } +} + +function areaFromDefinition(node: AnyNode): MeasurementDefinitionArea | null { + return ( + (registryMeasurementForNode(node)?.area?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionArea | null | undefined) ?? null + ) +} + +function perimeterFromDefinition(node: AnyNode): MeasurementDefinitionPerimeter | null { + return ( + (registryMeasurementForNode(node)?.perimeter?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionPerimeter | null | undefined) ?? null + ) +} + +function perimeterBoundaryPointsFromDefinition2D( + node: AnyNode, + perimeter: MeasurementDefinitionPerimeter, +): MeasurementPoint[] { + const boundaryPoints = + (perimeter.boundaryPoints?.length ?? 0) >= 3 + ? perimeter.boundaryPoints + : areaFromDefinition(node)?.boundaryPoints + return (boundaryPoints ?? []).map(definitionPlanPoint) +} + +function numericNodeProperty(node: AnyNode, key: string): number | null { + const value = (node as Record)[key] + return typeof value === 'number' && Number.isFinite(value) && value > 1e-4 ? value : null +} + +function genericPlanBoxDimensions(node: AnyNode): readonly [number, number] | null { + const rows = numericNodeProperty(node, 'rows') + const columns = numericNodeProperty(node, 'columns') + const panelWidth = numericNodeProperty(node, 'panelWidth') + const panelHeight = numericNodeProperty(node, 'panelHeight') + if (rows && columns && panelWidth && panelHeight) { + const gapX = numericNodeProperty(node, 'gapX') ?? 0 + const gapY = numericNodeProperty(node, 'gapY') ?? 0 + return [ + columns * panelWidth + Math.max(0, columns - 1) * gapX, + rows * panelHeight + Math.max(0, rows - 1) * gapY, + ] + } + + const width = numericNodeProperty(node, 'width') ?? numericNodeProperty(node, 'length') + const depth = + numericNodeProperty(node, 'depth') ?? + numericNodeProperty(node, 'height') ?? + numericNodeProperty(node, 'size') + return width && depth ? [width, depth] : null +} + +function genericPlanBoxLengthSegment( + node: AnyNode, + cursorPoint: MeasurementPoint | null, +): DirectLengthSegment | null { + if (!('position' in node) || !Array.isArray(node.position)) return null + const dimensions = genericPlanBoxDimensions(node) + if (!dimensions) return null + const [width, depth] = dimensions + + const polygon = getRotatedRectanglePolygon( + { x: node.position[0], y: node.position[2] }, + width, + depth, + nodeRotationY(node), + ) + return rectangleLengthSegment(polygon, width, depth, cursorPoint) +} + +function resolveFloorplanMeasurementSnap( + point: MeasurementPoint, + extraNodes: ReadonlyArray = [], +): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + const sceneNodes = Object.values(useScene.getState().nodes) + const sceneNodeIds = new Set(sceneNodes.map((node) => node.id)) + const nodes = [...sceneNodes, ...extraNodes.filter((node) => !sceneNodeIds.has(node.id))] + + return resolvePlanMeasurementSnap( + point, + mergeMeasurementSnapGeometry( + collectPlanMeasurementSnapGeometry(nodes), + collectCommittedMeasurementSnapGeometry(getResolvedMeasurementSegments()), + ), + { + enabledSnapKinds: useMeasurementTool.getState().enabledSnapKinds, + radiusMeters: FLOORPLAN_MEASUREMENT_SNAP_RADIUS, + view: '2d', + }, + ) +} + +function resolveFloorplanMeasurementConstraint( + start: MeasurementPoint, + point: MeasurementPoint, +): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + return resolvePlanMeasurementConstraint( + start, + point, + mergeMeasurementSnapGeometry( + collectPlanMeasurementSnapGeometry(Object.values(useScene.getState().nodes)), + collectCommittedMeasurementSnapGeometry(getResolvedMeasurementSegments()), + ), + { + enabledSnapKinds: useMeasurementTool.getState().enabledSnapKinds, + radiusMeters: FLOORPLAN_MEASUREMENT_SNAP_RADIUS, + view: '2d', + }, + ) +} + +function toOverlay( + segment: Pick, + unit: LinearUnit, + selectedId: string | null, + displayPrecision: MeasurementDisplayPrecision, + unitsPerPixel: number, +): LinearMeasurementOverlay | null { + const start = { x: segment.start[0], y: segment.start[2] } + const end = { x: segment.end[0], y: segment.end[2] } + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + const label = formatLinearMeasurement( + segment.measuredDistanceMeters ?? distanceBetweenMeasurements(segment.start, segment.end), + unit, + { precision: displayPrecision }, + ) + if (length < 1e-4) { + if ( + (segment.measuredDistanceMeters ?? distanceBetweenMeasurements(segment.start, segment.end)) < + 1e-4 + ) + return null + const half = FLOORPLAN_DEGENERATE_MEASUREMENT_MARKER_LENGTH / 2 + const labelX = (start.x + end.x) / 2 + const labelY = (start.y + end.y) / 2 + return { + id: segment.id, + label, + labelX, + labelY: labelY - 0.18, + labelAngleDeg: 0, + dimensionLineStart: { + x1: labelX - half, + y1: labelY, + x2: labelX, + y2: labelY, + }, + dimensionLineEnd: { + x1: labelX, + y1: labelY, + x2: labelX + half, + y2: labelY, + }, + extensionStart: { x1: labelX - half, y1: labelY, x2: labelX - half, y2: labelY }, + extensionEnd: { x1: labelX + half, y1: labelY, x2: labelX + half, y2: labelY }, + dimensionPathStart: null, + dimensionPathEnd: null, + showTicks: false, + isSelected: selectedId ? selectedId === segment.id : true, + dashedExtensions: false, + } + } + + const normal = { x: -dy / length, y: dx / length } + const offset = 0.24 + const dimensionStart = { x: start.x + normal.x * offset, y: start.y + normal.y * offset } + const dimensionEnd = { x: end.x + normal.x * offset, y: end.y + normal.y * offset } + const labelMetrics = getFloorplanMeasurementPillMetrics(label, unitsPerPixel) + const labelClearance = + labelMetrics.height / 2 + unitsPerPixel * FLOORPLAN_MEASUREMENT_LABEL_LINE_GAP_PX + const mid = { + x: (dimensionStart.x + dimensionEnd.x) / 2, + y: (dimensionStart.y + dimensionEnd.y) / 2, + } + + return { + id: segment.id, + label, + labelX: mid.x + normal.x * labelClearance, + labelY: mid.y + normal.y * labelClearance, + labelAngleDeg: (Math.atan2(dy, dx) * 180) / Math.PI, + dimensionLineStart: { + x1: dimensionStart.x, + y1: dimensionStart.y, + x2: mid.x, + y2: mid.y, + }, + dimensionLineEnd: { + x1: mid.x, + y1: mid.y, + x2: dimensionEnd.x, + y2: dimensionEnd.y, + }, + extensionStart: { x1: start.x, y1: start.y, x2: dimensionStart.x, y2: dimensionStart.y }, + extensionEnd: { x1: end.x, y1: end.y, x2: dimensionEnd.x, y2: dimensionEnd.y }, + dimensionPathStart: null, + dimensionPathEnd: null, + showTicks: true, + isSelected: selectedId ? selectedId === segment.id : true, + dashedExtensions: false, + } +} + +function hasFloorplanSegmentProjection( + segment: Pick, +) { + return Math.hypot(segment.end[0] - segment.start[0], segment.end[2] - segment.start[2]) >= 1e-4 +} + +export function staggerFloorplanMeasurementLabels( + overlays: LinearMeasurementOverlay[], +): LinearMeasurementOverlay[] { + const seen = new Map() + return overlays.map((overlay) => { + const key = `${Math.round(overlay.labelX / FLOORPLAN_LABEL_COLLISION_CELL)}:${Math.round( + overlay.labelY / FLOORPLAN_LABEL_COLLISION_CELL, + )}` + const index = seen.get(key) ?? 0 + seen.set(key, index + 1) + if (index === 0) return overlay + + const radians = (overlay.labelAngleDeg * Math.PI) / 180 + const normal = { x: -Math.sin(radians), y: Math.cos(radians) } + const direction = index % 2 === 0 ? -1 : 1 + const magnitude = Math.ceil(index / 2) * FLOORPLAN_LABEL_STAGGER_STEP + return { + ...overlay, + labelX: overlay.labelX + normal.x * direction * magnitude, + labelY: overlay.labelY + normal.y * direction * magnitude, + } + }) +} + +function directLengthSegmentFromNode( + node: AnyNode, + cursorPoint: MeasurementPoint | null = null, +): DirectLengthSegment | null { + const contributed = directLengthFromDefinition(node, cursorPoint) + if (contributed) return contributed + return genericPlanBoxLengthSegment(node, cursorPoint) +} + +type FloorplanMeasurableNode = AnyNode + +export function handleFloorplanMeasurementNodeClick2D( + node: FloorplanMeasurableNode, + options: { + altKey?: boolean + ctrlKey?: boolean + cursorPoint?: MeasurementPoint | null + metaKey?: boolean + } = {}, +): boolean { + const measurementMode = useMeasurementTool.getState().mode + const quickMeasure = Boolean(options.altKey || options.ctrlKey || options.metaKey) + if (measurementMode === 'angle') return false + + const perimeter = perimeterFromDefinition(node) + const area = areaFromDefinition(node) + if (perimeter || area) { + if (perimeter && (quickMeasure || measurementMode === 'perimeter')) { + useMeasurementTool + .getState() + .addPerimeter( + '2d', + definitionPlanPoint(perimeter.labelPoint), + perimeter.lengthMeters, + perimeterBoundaryPointsFromDefinition2D(node, perimeter), + ) + return true + } + if (area && measurementMode === 'area') { + useMeasurementTool + .getState() + .addArea( + '2d', + definitionPlanPoint(area.labelPoint), + area.areaSquareMeters, + (area.boundaryPoints ?? []).map(definitionPlanPoint), + ) + return true + } + } + + if (measurementMode !== 'distance') return false + if (!quickMeasure) return false + const segment = directLengthSegmentFromNode(node, options.cursorPoint ?? null) + if (!segment) return false + const tracksEndpointDistance = + Math.abs( + distanceBetweenMeasurements(segment.start, segment.end) - segment.measuredDistanceMeters, + ) < 1e-4 + useMeasurementTool + .getState() + .addSegment( + '2d', + segment.start, + segment.end, + segment.measuredDistanceMeters, + tracksEndpointDistance ? createPlanMeasurementAttachment(node.id, segment.start) : undefined, + tracksEndpointDistance ? createPlanMeasurementAttachment(node.id, segment.end) : undefined, + ) + return true +} + +export function previewFloorplanMeasurementNode2D( + node: FloorplanMeasurableNode, + cursorPoint: MeasurementPoint | null = null, +): boolean { + const measurement = useMeasurementTool.getState() + if ( + measurement.draft || + measurement.angleDraft || + measurement.draggingSegmentEndpoint || + measurement.polygonDraft + ) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + return false + } + if (measurement.mode === 'area') { + const area = areaFromDefinition(node) + if (!area) { + measurement.setPreviewArea(null) + return false + } + measurement.setPreviewSegment(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewArea({ + id: 'measurement-area-preview', + areaSquareMeters: area.areaSquareMeters, + boundaryPoints: (area.boundaryPoints ?? []).map(definitionPlanPoint), + labelPoint: definitionPlanPoint(area.labelPoint), + view: '2d', + }) + return true + } + + if (measurement.mode === 'perimeter') { + const perimeter = perimeterFromDefinition(node) + if (!perimeter) { + measurement.setPreviewPerimeter(null) + return false + } + measurement.setPreviewSegment(null) + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter({ + id: 'measurement-perimeter-preview', + boundaryPoints: perimeterBoundaryPointsFromDefinition2D(node, perimeter), + labelPoint: definitionPlanPoint(perimeter.labelPoint), + lengthMeters: perimeter.lengthMeters, + view: '2d', + }) + return true + } + + if (measurement.mode !== 'distance') { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + return false + } + + const segment = directLengthSegmentFromNode(node, cursorPoint) + if (!segment) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + return false + } + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + const snap = cursorPoint ? resolveFloorplanMeasurementSnap(cursorPoint, [node]) : null + const previewTarget = cursorPoint ? snapTargetFromPreviewSegment2D(cursorPoint, segment) : null + const target = + snap?.target && snap.target.kind !== 'grid' ? snap.target : (previewTarget ?? snap?.target) + measurement.setSnapTarget(target ?? null) + if (cursorPoint) measurement.setCursor('2d', target?.point ?? cursorPoint) + measurement.setPreviewSegment({ + id: 'measurement-preview', + start: segment.start, + end: segment.end, + measuredDistanceMeters: segment.measuredDistanceMeters, + view: '2d', + }) + return true +} + +function normalizeAngleDelta(delta: number): number { + let normalized = delta + while (normalized <= -Math.PI) normalized += Math.PI * 2 + while (normalized > Math.PI) normalized -= Math.PI * 2 + return normalized +} + +export function getFloorplanAngleMeasurementLayout(angle: MeasurementAngle) { + const vertex = { x: angle.vertex[0], y: angle.vertex[2] } + const first = { x: angle.first[0], y: angle.first[2] } + const second = { x: angle.second[0], y: angle.second[2] } + const firstVector = { x: first.x - vertex.x, y: first.y - vertex.y } + const secondVector = { x: second.x - vertex.x, y: second.y - vertex.y } + const firstLength = Math.hypot(firstVector.x, firstVector.y) + const secondLength = Math.hypot(secondVector.x, secondVector.y) + if (firstLength < 1e-4 || secondLength < 1e-4) return null + + const startAngle = Math.atan2(firstVector.y, firstVector.x) + const delta = normalizeAngleDelta(Math.atan2(secondVector.y, secondVector.x) - startAngle) + + const radius = Math.max( + FLOORPLAN_ANGLE_ARC_MIN_RADIUS, + Math.min(FLOORPLAN_ANGLE_ARC_MAX_RADIUS, firstLength * 0.35, secondLength * 0.35), + ) + if (Math.abs(delta) < 1e-4) { + const labelRadius = radius + 0.22 + return { + arcPath: '', + arcRadials: [], + first, + label: { + x: vertex.x + Math.cos(startAngle) * labelRadius - Math.sin(startAngle) * 0.16, + y: vertex.y + Math.sin(startAngle) * labelRadius + Math.cos(startAngle) * 0.16, + }, + second, + vertex, + wedgePath: '', + } + } + + const sampleCount = Math.max( + 8, + Math.ceil((Math.abs(delta) / Math.PI) * FLOORPLAN_ANGLE_ARC_SEGMENTS), + ) + const points = Array.from({ length: sampleCount + 1 }, (_, index) => { + const t = index / sampleCount + const a = startAngle + delta * t + return { + x: vertex.x + Math.cos(a) * radius, + y: vertex.y + Math.sin(a) * radius, + } + }) + const wedgePath = [ + `M ${vertex.x} ${vertex.y}`, + ...points.map((point) => `L ${point.x} ${point.y}`), + 'Z', + ].join(' ') + const arcPath = [ + `M ${points[0]?.x ?? vertex.x} ${points[0]?.y ?? vertex.y}`, + ...points.slice(1).map((point) => `L ${point.x} ${point.y}`), + ].join(' ') + const labelAngle = startAngle + delta / 2 + const labelRadius = radius + 0.22 + + return { + arcPath, + arcRadials: [ + { x1: vertex.x, x2: points[0]!.x, y1: vertex.y, y2: points[0]!.y }, + { + x1: vertex.x, + x2: points[points.length - 1]!.x, + y1: vertex.y, + y2: points[points.length - 1]!.y, + }, + ], + first, + label: { + x: vertex.x + Math.cos(labelAngle) * labelRadius, + y: vertex.y + Math.sin(labelAngle) * labelRadius, + }, + second, + vertex, + wedgePath, + } +} + +function MeasurementPillLabel2D({ + isSelected, + label, + rotationDeg, + unitsPerPixel, + x, + y, +}: { + isSelected: boolean + label: string + rotationDeg: number + unitsPerPixel: number + x: number + y: number +}) { + return ( + + ) +} + +function AngleMeasurementLabel({ + angle, + displayPrecision, + isSelected, + onPointerDown, + palette, + sceneRotationDeg, + unitsPerPixel, +}: { + angle: MeasurementAngle + displayPrecision: MeasurementDisplayPrecision + isSelected: boolean + onPointerDown?: (id: string, event: PointerEvent) => void + palette: FloorplanMeasurementToolLayerProps['palette'] + sceneRotationDeg: number + unitsPerPixel: number +}) { + const layout = getFloorplanAngleMeasurementLayout(angle) + if (!layout) return null + + const labelText = formatAngleMeasurement( + angleBetweenMeasurements(angle.first, angle.vertex, angle.second), + { + precision: displayPrecision, + }, + ) + const strokeOpacity = isSelected ? 0.95 : 0.38 + + return ( + onPointerDown(angle.id, event) : undefined} + pointerEvents={onPointerDown ? 'auto' : 'none'} + style={{ cursor: onPointerDown ? 'pointer' : undefined, userSelect: 'none' }} + > + + + + {layout.arcRadials.map((radial, index) => ( + + ))} + + + + ) +} + +function AreaMeasurementLabel({ + area, + displayPrecision, + isSelected, + onPointerDown, + palette, + sceneRotationDeg, + unit, + unitsPerPixel, +}: { + area: MeasurementArea + displayPrecision: MeasurementDisplayPrecision + isSelected: boolean + onPointerDown?: (id: string, event: PointerEvent) => void + palette: FloorplanMeasurementToolLayerProps['palette'] + sceneRotationDeg: number + unit: LinearUnit + unitsPerPixel: number +}) { + const label = formatAreaMeasurement(area.areaSquareMeters, unit, { precision: displayPrecision }) + const boundaryPoints = area.boundaryPoints + + return ( + onPointerDown(area.id, event) : undefined} + pointerEvents={onPointerDown ? 'auto' : 'none'} + style={{ + cursor: onPointerDown ? 'pointer' : undefined, + opacity: isSelected ? 1 : 0.4, + userSelect: 'none', + }} + > + {boundaryPoints && boundaryPoints.length >= 3 ? ( + + ) : null} + + + ) +} + +function AreaMeasurementBoundary2D({ + isSelected, + points, + stroke, + strokeOpacity, + strokeWidth = FLOORPLAN_MEASUREMENT_STROKE_WIDTH, +}: { + isSelected: boolean + points: MeasurementPoint[] + stroke: string + strokeOpacity?: number + strokeWidth?: number +}) { + const polygonPoints = points.map((point) => `${point[0]},${point[2]}`).join(' ') + + return ( + + ) +} + +function PerimeterMeasurementLabel({ + displayPrecision, + isSelected, + onPointerDown, + palette, + perimeter, + sceneRotationDeg, + unit, + unitsPerPixel, +}: { + displayPrecision: MeasurementDisplayPrecision + isSelected: boolean + onPointerDown?: (id: string, event: PointerEvent) => void + palette: FloorplanMeasurementToolLayerProps['palette'] + perimeter: MeasurementPerimeter + sceneRotationDeg: number + unit: LinearUnit + unitsPerPixel: number +}) { + const label = `P ${formatLinearMeasurement(perimeter.lengthMeters, unit, { + precision: displayPrecision, + })}` + const boundaryPoints = perimeter.boundaryPoints + const displayBoundaryPoints = boundaryPoints && boundaryPoints.length >= 3 ? boundaryPoints : null + + return ( + onPointerDown(perimeter.id, event) : undefined} + pointerEvents={onPointerDown ? 'auto' : 'none'} + style={{ + cursor: onPointerDown ? 'pointer' : undefined, + opacity: isSelected ? 1 : 0.4, + userSelect: 'none', + }} + > + {displayBoundaryPoints ? ( + + ) : null} + + + ) +} + +function PolygonDraftOutline2D({ + draft, + palette, +}: { + draft: MeasurementPolygonDraft + palette: FloorplanMeasurementToolLayerProps['palette'] +}) { + const points = polygonDraftPointsWithCursor(draft) + if (points.length < 2) return null + + return ( + + {points.flatMap((point, index) => { + const next = points[index + 1] ?? (points.length >= 3 ? points[0] : null) + return next + ? [ + , + ] + : [] + })} + + ) +} + +function FloorplanSnapTargetMarker({ + target, + unitsPerPixel, +}: { + target: MeasurementSnapTarget + unitsPerPixel: number +}) { + const x = target.point[0] + const y = target.point[2] + const { markerHalf, markerStroke } = getFloorplanSnapMarkerMetrics(unitsPerPixel) + const kind = target.kind ?? 'vertex' + const targetLine = target.guideLine ?? target.targetLine + const targetLineHighlight = targetLine + ? floorplanSnapLineHighlight(targetLine, target.point, kind === 'guide' ? 1.6 : 0.9) + : null + const tick = + targetLineHighlight && kind === 'edge' + ? floorplanSnapEdgeTick(targetLineHighlight, markerHalf) + : null + const ringRadius = + kind === 'grid' + ? markerHalf * 0.58 + : kind === 'measurement' + ? markerHalf * 0.78 + : markerHalf * 0.68 + const ringDash = kind === 'grid' ? '2 3' : undefined + const ringStrokeWidth = markerStroke * (kind === 'grid' ? 0.42 : 0.54) + + return ( + + {targetLineHighlight ? ( + + ) : null} + {tick ? ( + + ) : null} + {kind === 'measurement' ? ( + + ) : null} + + + + ) +} + +function floorplanSnapLineHighlight( + line: { end: MeasurementPoint; start: MeasurementPoint }, + point: MeasurementPoint, + maxLength: number, +): { end: MeasurementPoint; start: MeasurementPoint } | null { + const dx = line.end[0] - line.start[0] + const dz = line.end[2] - line.start[2] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + const half = Math.min(length, maxLength) / 2 + const ux = dx / length + const uz = dz / length + return { + start: [point[0] - ux * half, point[1], point[2] - uz * half], + end: [point[0] + ux * half, point[1], point[2] + uz * half], + } +} + +function floorplanSnapEdgeTick( + line: { end: MeasurementPoint; start: MeasurementPoint }, + markerHalf: number, +): { end: MeasurementPoint; start: MeasurementPoint } | null { + const dx = line.end[0] - line.start[0] + const dz = line.end[2] - line.start[2] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + const nx = -dz / length + const nz = dx / length + const half = markerHalf * 0.64 + const point: MeasurementPoint = [ + (line.start[0] + line.end[0]) / 2, + (line.start[1] + line.end[1]) / 2, + (line.start[2] + line.end[2]) / 2, + ] + return { + start: [point[0] - nx * half, point[1], point[2] - nz * half], + end: [point[0] + nx * half, point[1], point[2] + nz * half], + } +} + +function floorplanHexHandlePoints(x: number, y: number, radius: number): string { + return Array.from({ length: 6 }, (_, index) => { + const angle = (Math.PI * 2 * index) / 6 + return `${x + Math.cos(angle) * radius},${y + Math.sin(angle) * radius}` + }).join(' ') +} + +function floorplanPointFromClientPoint(clientX: number, clientY: number): MeasurementPoint | null { + const scene = document.querySelector('[data-floorplan-scene]') as + | (Element & { + getScreenCTM?: () => DOMMatrix | null + ownerSVGElement?: SVGSVGElement | null + }) + | null + const svg = scene?.ownerSVGElement + const ctm = scene?.getScreenCTM?.() + if (!(svg && ctm)) return null + + const point = svg.createSVGPoint() + point.x = clientX + point.y = clientY + const local = point.matrixTransform(ctm.inverse()) + return [local.x, 0, local.y] +} + +function handleFloorplanMeasurementGeometryClick(event: MouseEvent): boolean { + const target = event.target instanceof Element ? event.target : null + const entry = target?.closest('[data-node-id]') + const nodeId = entry?.getAttribute('data-node-id') + if (!nodeId) return false + + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (!node) return false + + return handleFloorplanMeasurementNodeClick2D(node, { + altKey: event.altKey, + ctrlKey: event.ctrlKey, + cursorPoint: floorplanPointFromClientPoint(event.clientX, event.clientY), + metaKey: event.metaKey, + }) +} + +function handleFloorplanMeasurementGeometryMove(event: globalThis.PointerEvent): boolean { + const target = event.target instanceof Element ? event.target : null + const entry = target?.closest('[data-node-id]') + const nodeId = entry?.getAttribute('data-node-id') + if (!nodeId) { + const measurement = useMeasurementTool.getState() + if ( + measurement.previewArea?.view === '2d' || + measurement.previewPerimeter?.view === '2d' || + measurement.previewSegment?.view === '2d' + ) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + } + return false + } + + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (!node) { + const measurement = useMeasurementTool.getState() + if ( + measurement.previewArea?.view === '2d' || + measurement.previewPerimeter?.view === '2d' || + measurement.previewSegment?.view === '2d' + ) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + } + return false + } + + return previewFloorplanMeasurementNode2D( + node, + floorplanPointFromClientPoint(event.clientX, event.clientY), + ) +} + +function resolveFloorplanEditablePoint(event: GridEvent): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + const measurement = useMeasurementTool.getState() + const snap = resolveFloorplanMeasurementSnap(pointFromGridEvent(event)) + const drag = measurement.draggingSegmentEndpoint + if (!drag) return snap + + const segment = getResolvedMeasurementSegments().find((entry) => entry.id === drag.id) + const anchor = + drag.endpoint === 'start' + ? (segment?.end ?? null) + : drag.endpoint === 'end' + ? (segment?.start ?? null) + : null + const constrained = anchor ? resolveFloorplanMeasurementConstraint(anchor, snap.point) : null + const point = constrained?.point ?? snap.point + + return { + point, + target: constrained?.target ?? snap.target, + } +} + +function polygonDraftPointsWithCursor(draft: MeasurementPolygonDraft): MeasurementPoint[] { + return draft.cursor ? [...draft.points, draft.cursor] : draft.points +} + +function updatePolygonMeasurementPreview2D(): void { + const measurement = useMeasurementTool.getState() + const draft = measurement.polygonDraft + if (draft?.view !== '2d') return + const points = polygonDraftPointsWithCursor(draft) + if (points.length < 3) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + return + } + + const { areaSquareMeters, labelPoint } = polygonAreaAndLabelPointFromMeasurements(points) + if (measurement.mode === 'area') { + measurement.setPreviewPerimeter(null) + measurement.setPreviewArea({ + id: 'measurement-area-preview', + areaSquareMeters, + boundaryPoints: points, + labelPoint, + view: '2d', + }) + return + } + + if (measurement.mode === 'perimeter') { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter({ + id: 'measurement-perimeter-preview', + boundaryPoints: points, + labelPoint, + lengthMeters: polygonPerimeterFromMeasurements(points), + view: '2d', + }) + } +} + +function handlePolygonGridClick2D(point: MeasurementPoint): boolean { + const measurement = useMeasurementTool.getState() + if (!(measurement.mode === 'area' || measurement.mode === 'perimeter')) return false + const draft = measurement.polygonDraft + if (draft?.view !== '2d') { + measurement.beginPolygon('2d', point) + return true + } + + const first = draft.points[0] + if (first && draft.points.length >= 3 && distanceBetweenMeasurements(first, point) < 0.25) { + measurement.commitPolygon() + return true + } + + measurement.addPolygonPoint(point) + updatePolygonMeasurementPreview2D() + return true +} + +export function handleFloorplanMeasurementGridMove(event: GridEvent): void { + if (!isFloorplanEvent(event)) return + const measurement = useMeasurementTool.getState() + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + if (measurement.draggingSegmentEndpoint) { + const resolved = resolveFloorplanEditablePoint(event) + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + resolved.point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (resolved.target?.attachment ?? null), + ) + measurement.setSnapTarget(resolved.target) + setMeasurementCursorPoint(resolved.point) + return + } + const snap = resolveFloorplanMeasurementSnap(pointFromGridEvent(event)) + const constrained = + measurement.draft?.view === '2d' + ? resolveFloorplanMeasurementConstraint(measurement.draft.start, snap.point) + : null + const point = constrained?.point ?? snap.point + measurement.setSnapTarget(constrained?.target ?? snap.target) + setMeasurementCursorPoint(point) + if (measurement.angleDraft) { + measurement.updateAngle(point) + return + } + if (measurement.polygonDraft?.view === '2d') { + measurement.updatePolygon(point) + updatePolygonMeasurementPreview2D() + return + } + if (!measurement.draft) return + measurement.update(point) +} + +export function handleFloorplanMeasurementGridClick(event: GridEvent): void { + if (!isFloorplanEvent(event)) return + const measurement = useMeasurementTool.getState() + if (measurement.consumeSuppressedPlacementClick()) return + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + if (measurement.draggingSegmentEndpoint) { + const resolved = resolveFloorplanEditablePoint(event) + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + resolved.point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (resolved.target?.attachment ?? null), + ) + measurement.setSnapTarget(resolved.target) + setMeasurementCursorPoint(resolved.point) + measurement.endSegmentEndpointDrag() + return + } + const snap = resolveFloorplanMeasurementSnap(pointFromGridEvent(event)) + const constrained = + measurement.draft?.view === '2d' + ? resolveFloorplanMeasurementConstraint(measurement.draft.start, snap.point) + : null + const point = constrained?.point ?? snap.point + const target = constrained?.target ?? snap.target + measurement.setSnapTarget(target) + if (measurement.mode === 'angle' || measurement.angleDraft) { + if (measurement.angleDraft) { + measurement.commitAngle(point) + } else { + measurement.beginAngle('2d', point, target?.targetLine ?? null) + } + return + } + if (handlePolygonGridClick2D(point)) return + if (measurement.mode !== 'distance') return + if (measurement.draft) { + measurement.commit(point, undefined, target?.attachment) + } else { + measurement.begin('2d', point, undefined, target?.attachment) + } +} + +export function FloorplanMeasurementToolLayer({ + active, + palette, + sceneRotationDeg, + unit, +}: FloorplanMeasurementToolLayerProps) { + const showMeasurements = useEditor((state) => state.showMeasurements) + const renderContext = useFloorplanRender() + const storedSegments = useMeasurementTool((state) => state.segments) + const segments = useResolvedMeasurementSegments(storedSegments) + const areas = useMeasurementTool((state) => state.areas) + const perimeters = useMeasurementTool((state) => state.perimeters) + const angles = useMeasurementTool((state) => state.angles) + const draft = useMeasurementTool((state) => state.draft) + const polygonDraft = useMeasurementTool((state) => state.polygonDraft) + const previewArea = useMeasurementTool((state) => state.previewArea) + const previewPerimeter = useMeasurementTool((state) => state.previewPerimeter) + const previewSegment = useMeasurementTool((state) => state.previewSegment) + const snapTarget = useMeasurementTool((state) => state.snapTarget) + const angleDraft = useMeasurementTool((state) => state.angleDraft) + const displayPrecision = useMeasurementTool((state) => state.displayPrecision) + const selectedId = useMeasurementTool((state) => state.selectedId) + const draggingSegmentEndpoint = useMeasurementTool((state) => state.draggingSegmentEndpoint) + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 + const measurementPalette = { + ...palette, + measurementStroke: FLOORPLAN_MEASUREMENT_COLOR, + } + const showPersistedMeasurements = active || showMeasurements + const displaySelectedId = active ? selectedId : null + + useEffect(() => { + if (!active) return + + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'measurement' }) + + const handleCancel = () => { + const measurement = useMeasurementTool.getState() + if ( + !measurement.cursor && + !measurement.angleDraft && + !measurement.draft && + !measurement.draggingSegmentEndpoint && + !measurement.polygonDraft && + !measurement.previewArea && + !measurement.previewPerimeter && + !measurement.previewSegment && + !measurement.snapTarget + ) + return + markToolCancelConsumed() + measurement.cancelDraft() + setMeasurementCursorPoint(null) + } + const handleGeometryClick = (event: MouseEvent) => { + if (!handleFloorplanMeasurementGeometryClick(event)) return + event.preventDefault() + event.stopPropagation() + } + const handleGeometryMove = (event: globalThis.PointerEvent) => { + handleFloorplanMeasurementGeometryMove(event) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.key === 'Delete' || event.key === 'Backspace')) return + const target = event.target instanceof HTMLElement ? event.target : null + if ( + target?.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + ) + return + const measurement = useMeasurementTool.getState() + if (!measurement.selectedId) return + event.preventDefault() + event.stopPropagation() + measurement.deleteSelected() + } + const handlePointerUp = (event: globalThis.PointerEvent) => { + const measurement = useMeasurementTool.getState() + if (!measurement.draggingSegmentEndpoint) return + measurement.endSegmentEndpointDrag({ + detachLinkedEndpoints: event.altKey, + suppressNextClick: true, + }) + } + + window.addEventListener('click', handleGeometryClick, true) + window.addEventListener('pointermove', handleGeometryMove, true) + window.addEventListener('keydown', handleKeyDown, true) + window.addEventListener('pointerup', handlePointerUp, true) + emitter.on('grid:move', handleFloorplanMeasurementGridMove) + emitter.on('grid:click', handleFloorplanMeasurementGridClick) + emitter.on('tool:cancel', handleCancel) + return () => { + window.removeEventListener('click', handleGeometryClick, true) + window.removeEventListener('pointermove', handleGeometryMove, true) + window.removeEventListener('keydown', handleKeyDown, true) + window.removeEventListener('pointerup', handlePointerUp, true) + emitter.off('grid:move', handleFloorplanMeasurementGridMove) + emitter.off('grid:click', handleFloorplanMeasurementGridClick) + emitter.off('tool:cancel', handleCancel) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'measurement') + useMeasurementTool.getState().cancelDraft() + setMeasurementCursorPoint(null) + } + }, [active]) + + const measurements = useMemo(() => { + if (!showPersistedMeasurements) return [] + const overlays = [ + ...(showPersistedMeasurements ? segments : []), + ...(active && draft?.view === '2d' && draft.end + ? [{ id: 'measurement-draft', start: draft.start, end: draft.end }] + : []), + ...(active && !draft && previewSegment?.view === '2d' ? [previewSegment] : []), + ] + .map((segment) => + toOverlay(segment, unit, displaySelectedId, displayPrecision, unitsPerPixel), + ) + .filter((measurement): measurement is NonNullable => measurement !== null) + return staggerFloorplanMeasurementLabels(overlays) + }, [ + active, + displayPrecision, + draft, + displaySelectedId, + previewSegment, + segments, + showPersistedMeasurements, + unit, + unitsPerPixel, + ]) + + const areaMeasurements = showPersistedMeasurements + ? [...areas, ...(active && previewArea?.view === '2d' ? [previewArea] : [])] + : [] + const perimeterMeasurements = showPersistedMeasurements + ? [...perimeters, ...(active && previewPerimeter?.view === '2d' ? [previewPerimeter] : [])] + : [] + const angleMeasurements = showPersistedMeasurements ? angles : [] + const draftAngle = + active && angleDraft?.view === '2d' && angleDraft.vertex && angleDraft.second + ? { + first: angleDraft.first, + id: 'measurement-angle-draft', + second: angleDraft.second, + vertex: angleDraft.vertex, + view: angleDraft.view, + } + : null + const handleMeasurementPointerDown = (id: string, event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + useMeasurementTool.getState().selectMeasurement(id) + } + const handleEndpointPointerDown = ( + id: string, + endpoint: MeasurementSegmentEndpoint, + event: PointerEvent, + ) => { + event.preventDefault() + event.stopPropagation() + useMeasurementTool.getState().startSegmentEndpointDrag(id, endpoint) + } + const selectedSegment = selectedId + ? segments.find( + (segment) => segment.id === selectedId && hasFloorplanSegmentProjection(segment), + ) + : null + + if (!(active || showMeasurements)) return null + + return ( + <> + {measurements.length > 0 ? ( + + ) : null} + {active && polygonDraft?.view === '2d' ? ( + + ) : null} + {areaMeasurements.map((area) => ( + + ))} + {perimeterMeasurements.map((perimeter) => ( + + ))} + {angleMeasurements.map((angle) => ( + + ))} + {draftAngle ? ( + + ) : null} + {active && snapTarget?.view === '2d' ? ( + + ) : null} + {active && selectedSegment ? ( + + {(['start', 'end'] as const).map((endpoint) => { + const point = selectedSegment[endpoint] + const activeHandle = isDraggingMeasurementEndpoint( + draggingSegmentEndpoint, + selectedSegment.id, + endpoint, + ) + const { handleRadius, hitRadius } = getFloorplanEndpointHandleMetrics( + unitsPerPixel, + activeHandle, + ) + return ( + + handleEndpointPointerDown(selectedSegment.id, endpoint, event) + } + style={{ cursor: activeHandle ? 'grabbing' : 'grab' }} + > + + + + + ) + })} + + ) : null} + + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx index 811e6b202..7338f4c46 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx @@ -1,13 +1,106 @@ 'use client' -import { memo } from 'react' +import { memo, type PointerEvent } from 'react' +import { + DIMENSION_PILL_PRIMARY_CLASS_NAME, + DimensionPillShell, +} from '../../editor/measurement-pill' +import { useFloorplanRender } from '../floorplan-render-context' -const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35 +const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.2 const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95 -const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15 +const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE_PX = 12 +const FLOORPLAN_MEASUREMENT_LABEL_LINE_HEIGHT_PX = 16 const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98 const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12' const FLOORPLAN_MEASUREMENT_END_TICK = 0.18 +const FLOORPLAN_MEASUREMENT_LABEL_MIN_FONT_SIZE = 0.08 +const FLOORPLAN_MEASUREMENT_LABEL_PAD_X_PX = 16 +const FLOORPLAN_MEASUREMENT_LABEL_PAD_Y_PX = 6 + +export function getFloorplanMeasurementPillMetrics(label: string, unitsPerPixel: number) { + const pixelWidth = + label.length * FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE_PX * 0.56 + + FLOORPLAN_MEASUREMENT_LABEL_PAD_X_PX * 2 + const pixelHeight = + FLOORPLAN_MEASUREMENT_LABEL_LINE_HEIGHT_PX + FLOORPLAN_MEASUREMENT_LABEL_PAD_Y_PX * 2 + const fontSize = Math.max( + unitsPerPixel * FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE_PX, + FLOORPLAN_MEASUREMENT_LABEL_MIN_FONT_SIZE, + ) + const height = pixelHeight * unitsPerPixel + const padX = unitsPerPixel * FLOORPLAN_MEASUREMENT_LABEL_PAD_X_PX + const padY = unitsPerPixel * FLOORPLAN_MEASUREMENT_LABEL_PAD_Y_PX + const width = pixelWidth * unitsPerPixel + + return { + fontSize, + height, + padX, + padY, + pixelHeight, + pixelWidth, + radius: height / 2, + strokeWidth: unitsPerPixel, + width, + } +} + +export function FloorplanMeasurementPillLabel({ + isSelected, + label, + rotationDeg, + unitsPerPixel, + x, + y, +}: { + isSelected: boolean + label: string + rotationDeg: number + unitsPerPixel: number + x: number + y: number +}) { + const metrics = getFloorplanMeasurementPillMetrics(label, unitsPerPixel) + + return ( + + + +
+ + {label} + +
+
+
+
+ ) +} export type LinearMeasurementOverlay = { dashedExtensions?: boolean @@ -30,6 +123,8 @@ export type LinearMeasurementOverlay = { } type FloorplanMeasurementPalette = { + measurementLabelBackground?: string + measurementLabelText?: string measurementStroke: string } @@ -127,6 +222,7 @@ function FloorplanMeasurementTick({ type FloorplanMeasurementsLayerProps = { className: string measurements: LinearMeasurementOverlay[] + onMeasurementPointerDown?: (id: string, event: PointerEvent) => void palette: FloorplanMeasurementPalette sceneRotationDeg?: number } @@ -146,13 +242,17 @@ function normalizeReadableScreenAngle(angleDeg: number) { export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({ className, measurements, + onMeasurementPointerDown, palette, sceneRotationDeg = 0, }: FloorplanMeasurementsLayerProps) { + const renderContext = useFloorplanRender() + if (measurements.length === 0) { return null } + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 return ( <> {measurements.map((measurement) => @@ -161,13 +261,20 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay measurement.labelAngleDeg + sceneRotationDeg, ) const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg - return ( onMeasurementPointerDown(measurement.id, event) + : undefined + } + pointerEvents={onMeasurementPointerDown ? 'auto' : 'none'} + style={{ + cursor: onMeasurementPointerDown ? 'pointer' : undefined, + userSelect: 'none', + }} > ) : null} - - {measurement.label} - + /> ) })(), diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 9a438a86a..b62f3c33c 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -105,6 +105,7 @@ const ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4 const ENDPOINT_HIT_STROKE_WIDTH_PX = 18 const ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX = 16 const ENDPOINT_HOVER_RING_STROKE_WIDTH_PX = 7 +const FLOORPLAN_AFFORDANCE_MEASUREMENT_STROKE_WIDTH = 1.2 const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)' const DIRECT_DRAG_THRESHOLD_PX = 4 const DIRECT_ROTATE_EPSILON = 1e-6 @@ -2454,7 +2455,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ strokeDasharray="0.08 0.12" strokeLinecap="round" strokeOpacity={0.95} - strokeWidth={1.35} + strokeWidth={FLOORPLAN_AFFORDANCE_MEASUREMENT_STROKE_WIDTH} vectorEffect="non-scaling-stroke" x1={g.start[0]} x2={eStartTip[0]} @@ -2466,7 +2467,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ strokeDasharray="0.08 0.12" strokeLinecap="round" strokeOpacity={0.95} - strokeWidth={1.35} + strokeWidth={FLOORPLAN_AFFORDANCE_MEASUREMENT_STROKE_WIDTH} vectorEffect="non-scaling-stroke" x1={g.end[0]} x2={eEndTip[0]} @@ -2477,7 +2478,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ {/* Counter-rotate the scene transform so the chip stays horizontal. */} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9ff3f66fd..376b0e4d8 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -114,6 +114,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 { @@ -6044,6 +6045,7 @@ export function FloorplanPanel({ (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo + const isMeasurementBuildActive = mode === 'build' && tool === 'measurement' // Ceiling-attached items (lights, fans). The 3D viewer drives these via // `ceiling:enter/move/click` raycast events on the ceiling mesh; the 2D // floor plan has no such mesh, so we synthesise the same events when the @@ -6080,6 +6082,7 @@ export function FloorplanPanel({ isFenceEndpointMoveActive || isFloorItemBuildActive || isFloorItemMoveActive || + isMeasurementBuildActive || isRegistryToolBuildActive const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { @@ -11270,6 +11273,13 @@ export function FloorplanPanel({ paint on top of node geometry. */} + + 1 || previewSelectedIds.length > 1} isHighlighted={isSiteBoundaryHighlighted} diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index f9e2ad6e2..1ad52801e 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -60,6 +60,7 @@ export function GroupFloatingActionMenu() { // memo keyed on selection + nodes is enough, no per-frame box traversal. const meshEpoch = useMeshSettleEpoch(nodes) const anchor = useMemo(() => { + void meshEpoch if (participantIds.length === 0) return null const fullIds = expandToComponent(participantIds, nodes, levelId) const box = computeGroupBox(fullIds) @@ -69,7 +70,6 @@ export function GroupFloatingActionMenu() { box.max.y + MENU_Y_OFFSET, (box.min.z + box.max.z) / 2, ) - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [participantIds, nodes, levelId, meshEpoch]) useFrame((state) => { diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index eb77f030c..bab33b952 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -131,6 +131,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin // - `corner` = front-right bbox corner at mid-height → where the gizmo sits const rest = useMemo(() => { + void meshEpoch const box = computeGroupBox(ids) if (!box) return null const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) @@ -140,7 +141,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: box.max.z + CORNER_OFFSET, ) return { pivot, corner } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [ids, meshEpoch]) if (!rest) return null diff --git a/packages/editor/src/components/editor/group-selection-box-3d.tsx b/packages/editor/src/components/editor/group-selection-box-3d.tsx index 6fbc73774..29d78739a 100644 --- a/packages/editor/src/components/editor/group-selection-box-3d.tsx +++ b/packages/editor/src/components/editor/group-selection-box-3d.tsx @@ -53,6 +53,7 @@ export function GroupSelectionBox3D() { // Re-measure once the meshes settle after a scene change (undo included). const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { + void meshEpoch if (participantIds.length === 0) return null const fullIds = expandToComponent(participantIds, nodes, levelId) const world = computeGroupBox(fullIds) @@ -69,7 +70,6 @@ export function GroupSelectionBox3D() { ], center, } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [participantIds, nodes, levelId, meshEpoch]) // Dashed wireframe. Built per box size (rare — selection / commit changes) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index b571b9725..5c330eae4 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -23,6 +23,7 @@ import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint' +import { MeasurementSceneGraphSync } from '../../lib/measurement-scene-nodes' import { applySceneGraphToEditor, loadSceneFromLocalStorage, @@ -38,6 +39,7 @@ import { SelectionAffordanceManager } from '../systems/selection-affordance-mana import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' +import { MeasurementHistoryPanel } from '../tools/measurement/measurement-history-panel' import { BoxSelectTool } from '../tools/select/box-select-tool' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -926,6 +928,24 @@ function PaintCursorLayer({ ) } +function MeasurementHistoryPanelHost({ + isVersionPreviewMode, + showLoader, +}: { + isVersionPreviewMode: boolean + showLoader: boolean +}) { + const mode = useEditor((s) => s.mode) + const tool = useEditor((s) => s.tool) + const unit = useViewer((s) => s.unit) + + if (showLoader || isVersionPreviewMode || mode !== 'build' || tool !== 'measurement') { + return null + } + + return +} + // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ── // This prevents Editor from re-rendering when those values change. @@ -1058,6 +1078,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ /> ) : null} + + {!(showLoader || isVersionPreviewMode) && } ) diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx index 793f9d2b7..62ab019c4 100644 --- a/packages/editor/src/components/editor/measurement-pill.tsx +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -1,6 +1,12 @@ 'use client' -import { type ForwardedRef, Fragment, forwardRef } from 'react' +import { + type ComponentPropsWithoutRef, + type ForwardedRef, + Fragment, + forwardRef, + type ReactNode, +} from 'react' // Canonical in-world dimension formatter — metric metres or imperial // feet/inches. Shared by every measurement readout so they read the same. @@ -31,6 +37,30 @@ export interface DimensionPillPart { signed?: boolean } +export const DIMENSION_PILL_SHELL_CLASS_NAME = + 'flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur' + +export const DIMENSION_PILL_PRIMARY_CLASS_NAME = 'font-medium text-foreground' +export const DIMENSION_PILL_MUTED_CLASS_NAME = 'text-muted-foreground' + +export function DimensionPillShell({ + children, + className, + ...props +}: { + children: ReactNode + className?: string +} & ComponentPropsWithoutRef<'div'>) { + return ( +
+ {children} +
+ ) +} + /** * Generic floating dimension pill: a row of `prefix value` readouts with the * active one emphasised. Styled to match the top-center floating info bar @@ -51,7 +81,7 @@ export function DimensionPill({ primaryRef?: ForwardedRef }) { return ( -
+ {parts.map((part, index) => { const text = part.signed ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}` @@ -59,13 +89,15 @@ export function DimensionPill({ return ( {index > 0 ? ( - + · ) : null} @@ -74,7 +106,7 @@ export function DimensionPill({ ) })} -
+ ) } diff --git a/packages/editor/src/components/editor/use-floorplan-scene-data.ts b/packages/editor/src/components/editor/use-floorplan-scene-data.ts index 912f6c425..6eac9036c 100644 --- a/packages/editor/src/components/editor/use-floorplan-scene-data.ts +++ b/packages/editor/src/components/editor/use-floorplan-scene-data.ts @@ -28,7 +28,7 @@ const DEFAULT_BUILDING_POSITION = [0, 0, 0] as const satisfies [number, number, function useLevelChildren( levelId: LevelNode['id'] | null, typeGuard: (node: AnyNode | undefined) => node is TNode, -) { +): TNode[] { return useScene( useShallow((state) => { if (!levelId) { @@ -45,13 +45,33 @@ function useLevelChildren( ) } +type FloorplanSceneData = { + buildingPosition: readonly [number, number, number] + committedBuildingPosition: readonly [number, number, number] + buildingRotationY: number + currentBuildingId: BuildingNode['id'] | null + ceilings: CeilingNode[] + fences: FenceNode[] + floorplanLevels: LevelNode[] + levelDescendantNodes: AnyNode[] + levelGuides: GuideNode[] + levelNode: LevelNode | undefined + openings: OpeningNode[] + roofs: RoofNode[] + site: SiteNode | null + slabs: SlabNode[] + spawns: SpawnNode[] + walls: WallNode[] + zones: ZoneNodeType[] +} + export function useFloorplanSceneData({ buildingId, levelId, }: { buildingId: BuildingNode['id'] | null levelId: LevelNode['id'] | null -}) { +}): FloorplanSceneData { const levelNode = useScene((state) => levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined, ) diff --git a/packages/editor/src/components/editor/use-mesh-settle-epoch.ts b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts index 14fd822ec..1d14971f4 100644 --- a/packages/editor/src/components/editor/use-mesh-settle-epoch.ts +++ b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts @@ -13,6 +13,7 @@ import { useEffect, useState } from 'react' export function useMeshSettleEpoch(nodes: unknown): number { const [epoch, setEpoch] = useState(0) useEffect(() => { + void nodes let raf2 = 0 const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1)) diff --git a/packages/editor/src/components/tools/measurement/measurement-history-panel.test.tsx b/packages/editor/src/components/tools/measurement/measurement-history-panel.test.tsx new file mode 100644 index 000000000..75256e947 --- /dev/null +++ b/packages/editor/src/components/tools/measurement/measurement-history-panel.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { useMeasurementTool } from '../../../store/use-measurement-tool' +import { + MeasurementHistoryPanel, + parseLinearMeasurementInputToMeters, +} from './measurement-history-panel' + +afterEach(() => { + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + useMeasurementTool.getState().setAllSnapKindsEnabled(true) +}) + +describe('MeasurementHistoryPanel', () => { + test('renders snapping options as a closed accordion', () => { + const markup = renderToStaticMarkup( + createElement(MeasurementHistoryPanel, { portal: false, unit: 'metric' }), + ) + + expect(markup).toContain('Snapping options') + expect(markup).toContain('aria-expanded="false"') + expect(markup).not.toContain('Enable all measurement snap kinds') + expect(markup).not.toContain('Disable all measurement snap kinds') + expect(markup).not.toContain('Reset measurement snap kinds') + expect(markup).not.toContain('Endpoint snaps') + expect(markup).not.toContain('Surface distance snaps') + expect(markup).toContain('Cycle measurement display precision') + expect(markup).toContain('Chained measurements') + }) + + test('parses typed linear measurement units for exact length entry', () => { + expect(parseLinearMeasurementInputToMeters('6 ft', 'metric')).toBeCloseTo(1.8288) + expect(parseLinearMeasurementInputToMeters('180cm', 'metric')).toBeCloseTo(1.8) + expect(parseLinearMeasurementInputToMeters('72in', 'imperial')).toBeCloseTo(1.8288) + }) + + test('treats bare linear measurement numbers as the active display unit', () => { + expect(parseLinearMeasurementInputToMeters('2', 'metric')).toBeCloseTo(2) + expect(parseLinearMeasurementInputToMeters('2', 'imperial')).toBeCloseTo(0.6096) + }) +}) diff --git a/packages/editor/src/components/tools/measurement/measurement-history-panel.tsx b/packages/editor/src/components/tools/measurement/measurement-history-panel.tsx new file mode 100644 index 000000000..cdc6e64af --- /dev/null +++ b/packages/editor/src/components/tools/measurement/measurement-history-panel.tsx @@ -0,0 +1,693 @@ +'use client' + +import { + ChevronDown, + DraftingCompass, + Link2, + Maximize2, + Ruler, + SquareDashed, + Trash2, + X, +} from 'lucide-react' +import { type ComponentType, type ReactNode, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { lingoUnitSpec, parseMeasurement } from '../../../lib/measurement-parser' +import { + angleBetweenMeasurements, + formatAngleMeasurement, + formatAreaMeasurement, + formatLinearMeasurement, + getLinearUnitLabel, + type LinearUnit, + linearControlValueToMeters, + metersToLinearUnit, +} from '../../../lib/measurements' +import { + DEFAULT_MEASUREMENT_SNAP_SETTINGS, + distanceBetweenMeasurements, + type MeasurementDisplayPrecision, + type MeasurementMode, + type MeasurementSnapKind, + useMeasurementTool, +} from '../../../store/use-measurement-tool' + +type MeasurementHistoryPanelProps = { + portal?: boolean + unit: LinearUnit +} + +type MeasurementHistoryRow = { + angleDegrees?: number + id: string + label: string + lengthMeters?: number + kind: 'length' | 'area' | 'perimeter' | 'angle' + value: string + view: '2D' | '3D' +} + +const MEASUREMENT_MODES: Array<{ + icon: ComponentType<{ className?: string }> + id: MeasurementMode + label: string +}> = [ + { icon: Ruler, id: 'distance', label: 'Length' }, + { icon: SquareDashed, id: 'area', label: 'Area' }, + { icon: Maximize2, id: 'perimeter', label: 'Perimeter' }, + { icon: DraftingCompass, id: 'angle', label: 'Angle' }, +] + +const DISPLAY_PRECISIONS: Array<{ + id: MeasurementDisplayPrecision + label: string + title: string +}> = [ + { id: 'coarse', label: '0.1', title: '1 decimal place' }, + { id: 'standard', label: '0.01', title: '2 decimal places' }, + { id: 'fine', label: '0.001', title: '3 decimal places' }, +] + +const nextDisplayPrecision: Record = { + coarse: 'standard', + standard: 'fine', + fine: 'coarse', +} + +export function parseLinearMeasurementInputToMeters(raw: string, unit: LinearUnit): number | null { + const spec = lingoUnitSpec('m') + const parsed = spec + ? parseMeasurement(raw, spec, { + bareUnit: unit === 'imperial' ? 'ft' : 'm', + system: unit === 'imperial' ? 'us' : 'metric', + }) + : null + if (parsed !== null) return Math.max(parsed, 0.001) + + const value = Number.parseFloat(raw) + if (!Number.isFinite(value)) return null + return linearControlValueToMeters(value, unit, { minMeters: 0.001 }) +} + +const SNAP_CONTROLS: Array<{ + group: 'Drawing helpers' | 'Existing' | 'Object geometry' + id: MeasurementSnapKind + label: string + title: string +}> = [ + { + group: 'Object geometry', + id: 'endpoint', + label: 'Endpoints', + title: 'Endpoint snaps', + }, + { + group: 'Object geometry', + id: 'midpoint', + label: 'Midpoints', + title: 'Midpoint snaps', + }, + { + group: 'Object geometry', + id: 'vertex', + label: 'Corners', + title: 'Vertex and corner snaps', + }, + { + group: 'Object geometry', + id: 'edge', + label: 'Edges', + title: 'Edge projection snaps', + }, + { + group: 'Object geometry', + id: 'intersection', + label: 'Intersections', + title: 'Intersection snaps', + }, + { + group: 'Object geometry', + id: 'center', + label: 'Centers', + title: 'Center snaps', + }, + { + group: 'Drawing helpers', + id: 'grid', + label: 'Grid', + title: 'Grid snaps', + }, + { + group: 'Drawing helpers', + id: 'guide', + label: 'Guides', + title: 'Parallel and perpendicular guide snaps', + }, + { + group: 'Existing', + id: 'surface', + label: 'Surfaces', + title: 'Surface distance snaps', + }, + { + group: 'Existing', + id: 'measurement', + label: 'Measurements', + title: 'Saved measurement snaps', + }, +] + +const SNAP_GROUPS: Array<(typeof SNAP_CONTROLS)[number]['group']> = [ + 'Object geometry', + 'Drawing helpers', + 'Existing', +] + +function historyViewLabel(view: '2d' | '3d'): '2D' | '3D' { + return view === '2d' ? '2D' : '3D' +} + +export function MeasurementHistoryPanel({ + portal = true, + unit, +}: MeasurementHistoryPanelProps): ReactNode { + const [mounted, setMounted] = useState(false) + const segments = useMeasurementTool((state) => state.segments) + const areas = useMeasurementTool((state) => state.areas) + const perimeters = useMeasurementTool((state) => state.perimeters) + const angles = useMeasurementTool((state) => state.angles) + const draft = useMeasurementTool((state) => state.draft) + const angleDraft = useMeasurementTool((state) => state.angleDraft) + const mode = useMeasurementTool((state) => state.mode) + const displayPrecision = useMeasurementTool((state) => state.displayPrecision) + const continuousMeasurement = useMeasurementTool((state) => state.continuousMeasurement) + const enabledSnapKinds = useMeasurementTool((state) => state.enabledSnapKinds) + const selectedId = useMeasurementTool((state) => state.selectedId) + const [angleInputValue, setAngleInputValue] = useState('') + const [draftAngleInputValue, setDraftAngleInputValue] = useState('') + const [draftLengthInputValue, setDraftLengthInputValue] = useState('') + const [lengthInputValue, setLengthInputValue] = useState('') + const [snapOptionsOpen, setSnapOptionsOpen] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + const rows = useMemo( + () => [ + ...segments.map((segment, index) => ({ + id: segment.id, + kind: 'length' as const, + label: `Length ${index + 1}`, + lengthMeters: + segment.measuredDistanceMeters ?? distanceBetweenMeasurements(segment.start, segment.end), + value: formatLinearMeasurement( + segment.measuredDistanceMeters ?? distanceBetweenMeasurements(segment.start, segment.end), + unit, + { precision: displayPrecision }, + ), + view: historyViewLabel(segment.view), + })), + ...areas.map((area, index) => ({ + id: area.id, + kind: 'area' as const, + label: `Area ${index + 1}`, + value: formatAreaMeasurement(area.areaSquareMeters, unit, { precision: displayPrecision }), + view: historyViewLabel(area.view), + })), + ...perimeters.map((perimeter, index) => ({ + id: perimeter.id, + kind: 'perimeter' as const, + label: `Perimeter ${index + 1}`, + value: formatLinearMeasurement(perimeter.lengthMeters, unit, { + precision: displayPrecision, + }), + view: historyViewLabel(perimeter.view), + })), + ...angles.map((angle, index) => ({ + angleDegrees: angleBetweenMeasurements(angle.first, angle.vertex, angle.second), + id: angle.id, + kind: 'angle' as const, + label: `Angle ${index + 1}`, + value: formatAngleMeasurement( + angleBetweenMeasurements(angle.first, angle.vertex, angle.second), + { precision: displayPrecision }, + ), + view: historyViewLabel(angle.view), + })), + ], + [angles, areas, displayPrecision, perimeters, segments, unit], + ) + + const selectedLengthRow = rows.find( + (row) => row.id === selectedId && row.kind === 'length' && row.lengthMeters, + ) + const selectedAngleRow = rows.find( + (row) => row.id === selectedId && row.kind === 'angle' && row.angleDegrees, + ) + const draftLengthMeters = draft?.end ? distanceBetweenMeasurements(draft.start, draft.end) : null + const draftViewLabel = draft ? historyViewLabel(draft.view) : null + const draftAngleRadians = + angleDraft?.vertex && angleDraft.second + ? angleBetweenMeasurements(angleDraft.first, angleDraft.vertex, angleDraft.second) + : null + const draftAngleViewLabel = angleDraft ? historyViewLabel(angleDraft.view) : null + const allSnapKindsEnabled = SNAP_CONTROLS.every((entry) => enabledSnapKinds[entry.id]) + const noSnapKindsEnabled = SNAP_CONTROLS.every((entry) => !enabledSnapKinds[entry.id]) + const defaultSnapKindsEnabled = SNAP_CONTROLS.every( + (entry) => enabledSnapKinds[entry.id] === DEFAULT_MEASUREMENT_SNAP_SETTINGS[entry.id], + ) + + useEffect(() => { + if (!selectedLengthRow?.lengthMeters) { + setLengthInputValue('') + return + } + + setLengthInputValue(metersToLinearUnit(selectedLengthRow.lengthMeters, unit).toFixed(3)) + }, [selectedLengthRow?.lengthMeters, unit]) + + useEffect(() => { + if (!selectedAngleRow?.angleDegrees) { + setAngleInputValue('') + return + } + + setAngleInputValue(((selectedAngleRow.angleDegrees * 180) / Math.PI).toFixed(2)) + }, [selectedAngleRow?.angleDegrees]) + + useEffect(() => { + if (!draftLengthMeters) { + setDraftLengthInputValue('') + return + } + + setDraftLengthInputValue(metersToLinearUnit(draftLengthMeters, unit).toFixed(3)) + }, [draftLengthMeters, unit]) + + useEffect(() => { + if (!draftAngleRadians) { + setDraftAngleInputValue('') + return + } + + setDraftAngleInputValue(((draftAngleRadians * 180) / Math.PI).toFixed(2)) + }, [draftAngleRadians]) + + const commitDraftLengthInput = () => { + if (!draftLengthMeters) return + const value = parseLinearMeasurementInputToMeters(draftLengthInputValue, unit) + if (value === null) { + setDraftLengthInputValue(metersToLinearUnit(draftLengthMeters, unit).toFixed(3)) + return + } + + useMeasurementTool.getState().updateDraftLength(value) + } + + const commitDraftAngleInput = () => { + if (!draftAngleRadians) return + const value = Number.parseFloat(draftAngleInputValue) + if (!Number.isFinite(value)) { + setDraftAngleInputValue(((draftAngleRadians * 180) / Math.PI).toFixed(2)) + return + } + + useMeasurementTool.getState().updateAngleDegrees(value) + } + + const commitLengthInput = () => { + if (!selectedLengthRow) return + const value = parseLinearMeasurementInputToMeters(lengthInputValue, unit) + if (value === null) { + setLengthInputValue(metersToLinearUnit(selectedLengthRow.lengthMeters ?? 0, unit).toFixed(3)) + return + } + + useMeasurementTool.getState().updateSegmentLength(selectedLengthRow.id, value) + } + + const commitAngleInput = () => { + if (!selectedAngleRow?.angleDegrees) return + const value = Number.parseFloat(angleInputValue) + if (!Number.isFinite(value)) { + setAngleInputValue(((selectedAngleRow.angleDegrees * 180) / Math.PI).toFixed(2)) + return + } + + useMeasurementTool.getState().updateAngleMeasurementDegrees(selectedAngleRow.id, value) + } + + if (portal && !mounted) return null + + const panel = ( +
+
+
Measurements
+ +
+
+ {MEASUREMENT_MODES.map((entry) => { + const Icon = entry.icon + const active = mode === entry.id + return ( + + ) + })} +
+ {draftLengthMeters ? ( +
+
+
Active length
+
{draftViewLabel}
+
+
+ setDraftLengthInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setDraftLengthInputValue(metersToLinearUnit(draftLengthMeters, unit).toFixed(3)) + event.currentTarget.blur() + } + }} + type="text" + value={draftLengthInputValue} + /> + + {getLinearUnitLabel(unit)} + +
+
+ ) : null} +
+
+
Precision
+ +
+
+
Chained measurements
+ +
+
+ {draftAngleRadians ? ( +
+
+
Active angle
+
{draftAngleViewLabel}
+
+
+ setDraftAngleInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setDraftAngleInputValue(((draftAngleRadians * 180) / Math.PI).toFixed(2)) + event.currentTarget.blur() + } + }} + type="number" + value={draftAngleInputValue} + /> + deg +
+
+ ) : null} +
+
+ History +
+ {rows.length === 0 ? ( +
No measurements yet
+ ) : null} + {rows.map((row) => { + const selected = selectedId === row.id + return ( +
+ + {selected && row.kind === 'length' ? ( +
+ setLengthInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setLengthInputValue( + metersToLinearUnit(row.lengthMeters ?? 0, unit).toFixed(3), + ) + event.currentTarget.blur() + } + }} + type="text" + value={lengthInputValue} + /> + + {getLinearUnitLabel(unit)} + +
+ ) : selected && row.kind === 'angle' ? ( +
+ setAngleInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur() + } + if (event.key === 'Escape') { + setAngleInputValue( + row.angleDegrees ? ((row.angleDegrees * 180) / Math.PI).toFixed(2) : '', + ) + event.currentTarget.blur() + } + }} + type="number" + value={angleInputValue} + /> + deg +
+ ) : ( + {row.value} + )} + +
+ ) + })} +
+
+
+ + +
+ {snapOptionsOpen ? ( +
+
+ + + +
+ {SNAP_GROUPS.map((group) => ( +
+
+ {group} +
+
+ {SNAP_CONTROLS.filter((entry) => entry.group === group).map((entry) => { + const active = enabledSnapKinds[entry.id] + return ( + + ) + })} +
+
+ ))} +
+ ) : null} +
+
+ ) + + return portal ? createPortal(panel, document.body) : panel +} diff --git a/packages/editor/src/components/tools/measurement/measurement-tool.test.ts b/packages/editor/src/components/tools/measurement/measurement-tool.test.ts new file mode 100644 index 000000000..09fa0a510 --- /dev/null +++ b/packages/editor/src/components/tools/measurement/measurement-tool.test.ts @@ -0,0 +1,2253 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + type GridEvent, + ItemNode, + type NodeEvent, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { BoxGeometry, BufferGeometry, Float32BufferAttribute, Group, Mesh, Vector3 } from 'three' +import { syncLinearMeasurementSceneNodes } from '../../../lib/measurement-scene-nodes' +import { registerMeasurementTestNodes } from '../../../lib/register-measurement-test-nodes' +import { + DEFAULT_MEASUREMENT_SNAP_SETTINGS, + type MeasurementPoint, + type MeasurementSnapKind, + useMeasurementTool, +} from '../../../store/use-measurement-tool' +import { + getMeasurementAngleLayout3D, + getMeasurementAnnotationColors, + handleMeasurementGridClick3D, + handleMeasurementGridMove3D, + handleMeasurementNodeClick3D, + handleMeasurementNodeMove3D, + staggerMeasurementLabelLayouts3D, +} from './measurement-tool' + +beforeAll(() => { + class TestCanvas {} + globalThis.HTMLCanvasElement = TestCanvas as never + registerMeasurementTestNodes() +}) + +beforeEach(() => { + useScene.getState().clearScene() + sceneRegistry.nodes.clear() + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + resetSnapSettings() +}) + +afterEach(() => { + useScene.getState().clearScene() + sceneRegistry.nodes.clear() + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + resetSnapSettings() +}) + +function resetSnapSettings() { + for (const [kind, enabled] of Object.entries(DEFAULT_MEASUREMENT_SNAP_SETTINGS)) { + useMeasurementTool.getState().setSnapKindEnabled(kind as MeasurementSnapKind, enabled) + } +} + +test('builds 3D angle measurement arc layout in the measured plane', () => { + const layout = getMeasurementAngleLayout3D({ + first: [1, 0, 0], + id: 'angle-layout-3d', + second: [0, 0, 1], + vertex: [0, 0, 0], + view: '3d', + }) + + expect(layout).not.toBeNull() + expect(layout?.arcSegments.length).toBeGreaterThanOrEqual(8) + expect(layout?.arcRadials).toHaveLength(2) + expect(layout?.arcRadials[0]?.start.toArray()).toEqual([0, 0, 0]) + expect(layout?.arcRadials[0]?.end.x).toBeCloseTo(0.35) + expect(layout?.arcRadials[0]?.end.y).toBeCloseTo(0) + expect(layout?.arcRadials[0]?.end.z).toBeCloseTo(0) + expect(layout?.arcRadials[1]?.start.toArray()).toEqual([0, 0, 0]) + expect(layout?.arcRadials[1]?.end.x).toBeCloseTo(0) + expect(layout?.arcRadials[1]?.end.y).toBeCloseTo(0) + expect(layout?.arcRadials[1]?.end.z).toBeCloseTo(0.35) + expect(layout?.arcSegments[0]?.start.x).toBeCloseTo(0.35) + expect(layout?.arcSegments[0]?.start.y).toBeCloseTo(0) + expect(layout?.arcSegments[0]?.start.z).toBeCloseTo(0) + expect(layout?.arcSegments.at(-1)?.end.x).toBeCloseTo(0) + expect(layout?.arcSegments.at(-1)?.end.y).toBeCloseTo(0) + expect(layout?.arcSegments.at(-1)?.end.z).toBeCloseTo(0.35) + expect(layout?.labelPosition.x).toBeCloseTo(0.417) + expect(layout?.labelPosition.y).toBeCloseTo(0) + expect(layout?.labelPosition.z).toBeCloseTo(0.417) +}) + +function gridEvent( + localPosition: [number, number, number], + canvas: HTMLCanvasElement, + options: { altKey?: boolean; shiftKey?: boolean; target?: unknown } = {}, +): GridEvent { + return { + position: localPosition, + localPosition, + nativeEvent: { + altKey: options.altKey ?? false, + shiftKey: options.shiftKey ?? false, + target: options.target ?? canvas, + } as never, + } +} + +function nodeEvent( + node: AnyNode, + position: [number, number, number], + options: { + altKey?: boolean + ctrlKey?: boolean + faceIndex?: number + localPosition?: [number, number, number] + metaKey?: boolean + normal?: [number, number, number] + object?: NodeEvent['object'] + onStopPropagation?: () => void + shiftKey?: boolean + } = {}, +): NodeEvent { + return { + node, + position, + localPosition: options.localPosition ?? position, + normal: options.normal, + faceIndex: options.faceIndex, + object: options.object ?? ({} as never), + stopPropagation: options.onStopPropagation ?? (() => {}), + nativeEvent: { + altKey: options.altKey ?? false, + ctrlKey: options.ctrlKey ?? false, + metaKey: options.metaKey ?? false, + shiftKey: options.shiftKey ?? false, + } as never, + } +} + +function zoneNode(): AnyNode { + return { + id: 'zone_measurement', + type: 'zone', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + } as never +} + +function areaOutlinedPerimeterNode(): AnyNode { + return { + ...zoneNode(), + id: 'area_outlined_perimeter_measurement_3d', + type: 'surface-perimeter-without-boundary', + } as never +} + +function slabWithHoleNode(): AnyNode { + return { + id: 'slab_measurement_hole', + type: 'slab', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + elevation: 0, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + } as never +} + +function siteNode(): AnyNode { + return { + id: 'site_measurement', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: { + type: 'polygon', + points: [ + [-2, -1], + [2, -1], + [2, 1], + [-2, 1], + ], + }, + } as never +} + +function wallNode(): AnyNode { + return { + id: 'wall_measurement', + type: 'wall', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + } as never +} + +function curvedWallNode(): AnyNode { + return { + ...wallNode(), + id: 'wall_measurement_curved', + curveOffset: 1, + } as never +} + +function splineFenceNode(): AnyNode { + return { + id: 'fence_measurement_spline', + type: 'fence', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + path: [ + [0, 0], + [2, 1], + [4, 0], + ], + } as never +} + +function windowNode(): AnyNode { + return { + id: 'window_measurement', + type: 'window', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + wallId: 'wall_measurement', + position: [2, 1, 0], + width: 1, + height: 1, + } as never +} + +function doorNode(): AnyNode { + return { + id: 'door_measurement', + type: 'door', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + wallId: 'wall_measurement', + position: [2, 1.05, 0], + width: 0.9, + height: 2.1, + } as never +} + +function spawnNode(): AnyNode { + return { + id: 'spawn_measurement', + type: 'spawn', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + } as never +} + +function stairNode(): AnyNode { + return { + id: 'stair_measurement', + type: 'stair', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['stair_segment_measurement'], + position: [0, 0, 0], + rotation: 0, + stairType: 'straight', + width: 1, + } as never +} + +function stairSegmentNode(): AnyNode { + return { + id: 'stair_segment_measurement', + type: 'stair-segment', + object: 'node', + parentId: 'stair_measurement', + visible: true, + metadata: {}, + children: [], + width: 1, + length: 3, + height: 2.5, + attachmentSide: 'front', + } as never +} + +function ductSegmentNode(): AnyNode { + return { + id: 'duct_segment_measurement', + type: 'duct-segment', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + path: [ + [0, 2.4, 0], + [2, 2.4, 0], + [2, 2.4, 3], + ], + } as never +} + +function roofNode(overrides: Partial = {}): AnyNode { + return { + id: 'roof_measurement', + type: 'roof', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['roof_segment_measurement'], + position: [0, 0, 0], + rotation: 0, + ...overrides, + } as never +} + +function roofSegmentNode(overrides: Partial = {}): AnyNode { + return { + id: 'roof_segment_measurement', + type: 'roof-segment', + object: 'node', + parentId: 'roof_measurement', + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: 4, + depth: 2, + pitch: 40, + wallHeight: 0.5, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + gambrelLowerWidthRatio: 0.5, + mansardSteepWidthRatio: 0.15, + dutchHipWidthRatio: 0.25, + dutchWaistLengthRatio: 0.98, + ...overrides, + } as never +} + +function skylightNode(): AnyNode { + return { + id: 'skylight_measurement', + type: 'skylight', + object: 'node', + parentId: 'roof_segment_measurement', + visible: true, + metadata: {}, + children: [], + roofSegmentId: 'roof_segment_measurement', + position: [0, 0, 0], + rotation: 0, + width: 2, + height: 1, + } as never +} + +function seedScene(nodes: AnyNode[]) { + useScene.setState({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes.map((node) => node.id as AnyNodeId), + dirtyNodes: new Set(), + collections: {}, + } as never) +} + +function syncMeasurementSceneNodesForTest() { + syncLinearMeasurementSceneNodes(useMeasurementTool.getState().segments) +} + +function triangleMesh() { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)) + return new Mesh(geometry) +} + +function twoTriangleMesh() { + const geometry = new BufferGeometry() + geometry.setAttribute( + 'position', + new Float32BufferAttribute( + [0, 0, 0, 1, 0, 0, 0, 1, 0, 0.32, 0.34, 0, 1.2, 0.34, 0, 0.32, 1.2, 0], + 3, + ), + ) + return new Mesh(geometry) +} + +function indexedTriangleMesh() { + const geometry = new BufferGeometry() + geometry.setAttribute( + 'position', + new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 4, 4, 4], 3), + ) + geometry.setIndex([2, 0, 1]) + return new Mesh(geometry) +} + +describe('measurement 3D grid handlers', () => { + test('uses the wall-top measurement annotation colors in 3D', () => { + expect(getMeasurementAnnotationColors('light')).toEqual({ + backgroundColor: 'rgba(255, 255, 255, 0.96)', + borderColor: 'rgba(139, 92, 246, 0.72)', + color: '#7c3aed', + shadowColor: '#ffffff', + }) + expect(getMeasurementAnnotationColors('dark')).toEqual({ + backgroundColor: 'rgba(24, 24, 27, 0.94)', + borderColor: 'rgba(139, 92, 246, 0.72)', + color: '#c4b5fd', + shadowColor: '#111111', + }) + }) + + test('staggers overlapping 3D measurement value labels', () => { + const layouts = staggerMeasurementLabelLayouts3D([ + { + id: 'a', + labelPosition: new Vector3(1, 1, 1), + tickDirection: new Vector3(0, 1, 0), + }, + { + id: 'b', + labelPosition: new Vector3(1.02, 1.01, 1.03), + tickDirection: new Vector3(0, 1, 0), + }, + { + id: 'c', + labelPosition: new Vector3(4, 4, 4), + tickDirection: new Vector3(0, 1, 0), + }, + ]) + + expect(layouts[0]?.labelPosition.y).toBe(1) + expect(layouts[1]?.labelPosition.y).toBeGreaterThan(1.01) + expect(layouts[2]?.labelPosition.y).toBe(4) + }) + + test('commits a 3D point-to-point distance from two grid clicks', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([0, 3, 4], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([0, 3, 4], canvas), canvas) + + const state = useMeasurementTool.getState() + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [0, 3, 4], + view: '3d', + }) + expect(state.selectedId).toBe(state.segments[0]?.id ?? null) + }) + + test('updates the 3D draft endpoint while moving the pointer', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([2, 1, 0], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + end: [2, 1, 0], + view: '3d', + }) + }) + + test('drags a saved 3D grid measurement endpoint through snapping', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.setSnapKindEnabled('guide', false) + measurement.startSegmentEndpointDrag(segmentId, 'end') + handleMeasurementGridMove3D(gridEvent([3.9, 0, 0.08], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([3.9, 0, 0.08], canvas), canvas) + + const segment = useMeasurementTool.getState().segments[0] + expect(segment).toMatchObject({ + start: [1, 0, 1], + measuredDistanceMeters: undefined, + view: '3d', + }) + expect(segment?.end[0]).toBeCloseTo(4) + expect(segment?.end[1]).toBeCloseTo(0) + expect(segment?.end[2]).toBeCloseTo(0) + expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull() + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + point: [4, 0, 0], + view: '3d', + }) + }) + + test('Alt detaches a linked 3D measurement endpoint while dragging', () => { + const canvas = new globalThis.HTMLCanvasElement() + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [1, 0, 1], [2, 0, 1], 1) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('3d', [2, 0, 1], [2, 0, 3], 2) + const secondId = useMeasurementTool.getState().segments[1]!.id + + measurement.startSegmentEndpointDrag(firstId, 'end') + handleMeasurementGridMove3D(gridEvent([3, 0, 1], canvas, { altKey: true }), canvas) + handleMeasurementGridClick3D(gridEvent([3, 0, 1], canvas, { altKey: true }), canvas) + + const segments = useMeasurementTool.getState().segments + expect(segments.find((segment) => segment.id === firstId)).toMatchObject({ + end: [3, 0, 1], + measuredDistanceMeters: undefined, + }) + expect(segments.find((segment) => segment.id === secondId)).toMatchObject({ + measuredDistanceMeters: 2, + start: [2, 0, 1], + }) + }) + + test('does not start a new 3D measurement from the click after endpoint drag release', () => { + const canvas = new globalThis.HTMLCanvasElement() + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'end') + handleMeasurementGridMove3D(gridEvent([3, 0, 1], canvas), canvas) + measurement.endSegmentEndpointDrag({ suppressNextClick: true }) + handleMeasurementGridClick3D(gridEvent([3, 0, 1], canvas), canvas) + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.draft).toBeNull() + expect(state.segments[0]).toMatchObject({ + end: [3, 0, 1], + start: [1, 0, 1], + view: '3d', + }) + }) + + test('does not start a new 3D measurement from a node click after endpoint drag release', () => { + const canvas = new globalThis.HTMLCanvasElement() + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'end') + handleMeasurementGridMove3D(gridEvent([3, 0, 1], canvas), canvas) + measurement.endSegmentEndpointDrag({ suppressNextClick: true }) + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.draft).toBeNull() + expect(state.segments[0]).toMatchObject({ + end: [3, 0, 1], + start: [1, 0, 1], + view: '3d', + }) + }) + + test('drags a saved 3D surface measurement endpoint through surface snapping', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [1, 0, 1], [2, 0, 1], 1) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'start') + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 1], + measuredDistanceMeters: undefined, + view: '3d', + }) + expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull() + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + point: [0, 0, 0], + view: '3d', + }) + }) + + test('shift does not axis-lock the 3D draft endpoint while drawing', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([1, 2, 5], canvas, { shiftKey: true }), canvas) + handleMeasurementGridClick3D(gridEvent([1, 2, 5], canvas, { shiftKey: true }), canvas) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [1, 2, 5], + view: '3d', + }) + }) + + test('snaps the first 3D surface placement point to nearby surface anchors', () => { + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Vertex', + point: [0, 0, 0], + view: '3d', + }) + }) + + test('snaps the committed 3D surface endpoint to nearby surface anchors', () => { + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [3.92, 0, 2.92])) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 3], + view: '3d', + }) + }) + + test('shift does not axis-lock the committed 3D surface endpoint after snapping', () => { + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [3.92, 0, 2.92], { shiftKey: true })) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 3], + view: '3d', + }) + }) + + test('snaps 3D angle points to nearby surface anchors', () => { + useMeasurementTool.getState().setMode('angle') + + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [2.02, 0, 1.52])) + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [3.92, 0, 2.92])) + + expect(useMeasurementTool.getState().angles[0]).toMatchObject({ + vertex: [2, 0, 1.5], + second: [4, 0, 3], + view: '3d', + }) + expect(useMeasurementTool.getState().angles[0]?.first[0]).toBeCloseTo(4.5) + expect(useMeasurementTool.getState().angles[0]?.first[1]).toBeCloseTo(0) + expect(useMeasurementTool.getState().angles[0]?.first[2]).toBeCloseTo(1.5) + }) + + test('uses a 3D grid edge projection as the angle reference', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + useMeasurementTool.getState().setMode('angle') + + handleMeasurementGridClick3D(gridEvent([2, 0, 0.05], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([2, 0, 2], canvas), canvas) + + expect(useMeasurementTool.getState().angles[0]).toMatchObject({ + first: [4, 0, 0], + vertex: [2, 0, 0], + second: [2, 0, 2], + view: '3d', + }) + }) + + test('uses the adjacent 3D mesh edge as the angle reference at a vertex', () => { + const mesh = triangleMesh() + useMeasurementTool.getState().setMode('angle') + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.04, 0.02, 0], { + faceIndex: 0, + localPosition: [0.04, 0.02, 0], + object: mesh, + }), + ) + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0, 1, 0], { + faceIndex: 0, + localPosition: [0, 1, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().angles[0]).toMatchObject({ + first: [1, 0, 0], + vertex: [0, 0, 0], + second: [0, 1, 0], + view: '3d', + }) + }) + + test('snaps 3D node hits to mesh vertices', () => { + const mesh = triangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.04, 0.02, 0], { + faceIndex: 0, + localPosition: [0.04, 0.02, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Mesh vertex', + point: [0, 0, 0], + view: '3d', + }) + }) + + test('does not snap 3D node hits to mesh vertices when vertex snaps are disabled', () => { + const mesh = triangleMesh() + useMeasurementTool.getState().setSnapKindEnabled('vertex', false) + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.04, 0.02, 0], { + faceIndex: 0, + localPosition: [0.04, 0.02, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.04, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Mesh edge', + point: [0.04, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D node hits to mesh edge projections', () => { + const mesh = triangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.5, 0.04, 0], { + faceIndex: 0, + localPosition: [0.5, 0.04, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.5, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Mesh edge', + point: [0.5, 0, 0], + targetLine: { + start: [0, 0, 0], + end: [1, 0, 0], + }, + view: '3d', + }) + }) + + test('snaps 3D node hits to mesh face centers', () => { + const mesh = triangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [1 / 3, 1 / 3, 0], { + faceIndex: 0, + localPosition: [1 / 3, 1 / 3, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1 / 3, 1 / 3, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'center', + label: 'Face center', + point: [1 / 3, 1 / 3, 0], + view: '3d', + }) + }) + + test('snaps 3D node hits to indexed mesh triangle vertices', () => { + const mesh = indexedTriangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.02, 0.96, 0], { + faceIndex: 0, + localPosition: [0.02, 0.96, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 1, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Mesh vertex', + point: [0, 1, 0], + view: '3d', + }) + }) + + test('snaps 3D node hits to indexed mesh edge projections', () => { + const mesh = indexedTriangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.5, 0.04, 0], { + faceIndex: 0, + localPosition: [0.5, 0.04, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.5, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Mesh edge', + point: [0.5, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D node hits to indexed mesh face centers', () => { + const mesh = indexedTriangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [1 / 3, 1 / 3, 0], { + faceIndex: 0, + localPosition: [1 / 3, 1 / 3, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1 / 3, 1 / 3, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'center', + label: 'Face center', + point: [1 / 3, 1 / 3, 0], + view: '3d', + }) + }) + + test('ignores closer vertices from non-hit mesh triangles', () => { + const mesh = twoTriangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [1 / 3, 1 / 3, 0], { + faceIndex: 0, + localPosition: [1 / 3, 1 / 3, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1 / 3, 1 / 3, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'center', + label: 'Face center', + point: [1 / 3, 1 / 3, 0], + view: '3d', + }) + }) + + test('measures surface-to-surface distance between parallel 3D faces', () => { + const mesh = triangleMesh() + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.04, 0.02, 0], { + localPosition: [0.04, 0.02, 0], + normal: [1, 0, 0], + object: mesh, + }), + ) + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [2.02, 0.02, 0], { + localPosition: [2.02, 0.02, 0], + normal: [-1, 0, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 0], + measuredDistanceMeters: 2, + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'surface', + label: 'Surface distance', + point: [2, 0, 0], + view: '3d', + }) + }) + + test('does not use surface-to-surface distance when surface snaps are disabled', () => { + const mesh = triangleMesh() + useMeasurementTool.getState().setSnapKindEnabled('surface', false) + + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [0.04, 0.02, 0], { + localPosition: [0.04, 0.02, 0], + normal: [1, 0, 0], + object: mesh, + }), + ) + handleMeasurementNodeClick3D( + nodeEvent(zoneNode(), [2.02, 0.02, 0], { + localPosition: [2.02, 0.02, 0], + normal: [-1, 0, 0], + object: mesh, + }), + ) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 0], + measuredDistanceMeters: undefined, + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.kind).not.toBe('surface') + }) + + test('commits a 3D angle in angle mode', () => { + const canvas = new globalThis.HTMLCanvasElement() + const measurement = useMeasurementTool.getState() + measurement.setMode('angle') + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([0, 1, 0], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([0, 1, 0], canvas), canvas) + + const state = useMeasurementTool.getState() + expect(state.angleDraft).toBeNull() + expect(state.angles).toHaveLength(1) + expect(state.angles[0]).toMatchObject({ + first: [1, 0, 0], + vertex: [0, 0, 0], + second: [0, 1, 0], + view: '3d', + }) + }) + + test('ignores events from a non-canvas target', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas, { target: {} }), canvas) + + expect(useMeasurementTool.getState().draft).toBeNull() + expect(useMeasurementTool.getState().segments).toHaveLength(0) + }) + + test('ignores grid events suppressed after a surface event', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas, () => true) + + expect(useMeasurementTool.getState().draft).toBeNull() + }) + + test('tracks 3D grid snap target on grid movement', () => { + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [0.08, 0, 0.06])) + handleMeasurementGridMove3D(gridEvent([1, 0, 1], canvas), canvas) + + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'grid', + label: 'Grid', + point: [1, 0, 1], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to nearby scene wall endpoints', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([0.08, 0, 0.06], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + startAttachment: { + feature: { kind: 'plan-anchor' }, + nodeId: 'wall_measurement', + }, + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Endpoint', + point: [0, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to nearby scene wall edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([1.25, 0, 0.08], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.25, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Wall edge', + point: [1.25, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to sampled curved wall edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([curvedWallNode()]) + + handleMeasurementGridClick3D( + gridEvent([0.8819660112501051, 0, -0.7360679774997898], canvas), + canvas, + ) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.8819660112501051, 0, -0.7360679774997898], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Wall edge', + point: [0.8819660112501051, 0, -0.7360679774997898], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to sampled spline fence edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([splineFenceNode()]) + + handleMeasurementGridClick3D(gridEvent([0.9704915028125263, 0, 0.625], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0.9704915028125263, 0, 0.625], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Fence edge', + point: [0.9704915028125263, 0, 0.625], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to hosted opening endpoints', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode(), windowNode()]) + + handleMeasurementGridClick3D(gridEvent([1.52, 0, 0.03], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.5, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + label: 'Opening endpoint', + point: [1.5, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to hosted opening centers', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode(), windowNode()]) + + handleMeasurementGridClick3D(gridEvent([2.03, 0, 0.04], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [2, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'center', + label: 'Opening center', + point: [2, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to hosted opening edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode(), windowNode()]) + + handleMeasurementGridClick3D(gridEvent([1.75, 0, 0.08], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.75, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Opening edge', + point: [1.75, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to composite stair footprint corners', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([stairNode(), stairSegmentNode()]) + + handleMeasurementGridClick3D(gridEvent([-0.48, 0, 2.96], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [-0.5, 0, 3], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Stair corner', + point: [-0.5, 0, 3], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to MEP run path edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([ductSegmentNode()]) + + handleMeasurementGridClick3D(gridEvent([2.08, 0, 1.25], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [2, 0, 1.25], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Run edge', + point: [2, 0, 1.25], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to surface opening hole edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([slabWithHoleNode()]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleMeasurementGridClick3D(gridEvent([1.4, 0, 1.08], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.4, 0, 1], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Surface opening edge', + point: [1.4, 0, 1], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to site property lines', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([siteNode()]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleMeasurementGridClick3D(gridEvent([0.4, 0, -1.08], canvas), canvas) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0.4) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(-1) + expect(draft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Property line edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(-1) + }) + + test('snaps 3D grid clicks to roof ridge edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode()]) + + handleMeasurementGridClick3D(gridEvent([1.25, 0, 0.08], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.25, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + point: [1.25, 0, 0], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to roof eave corners', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode()]) + + handleMeasurementGridClick3D(gridEvent([-2.03, 0, -1.04], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [-2, 0, -1], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'vertex', + label: 'Roof eave corner', + point: [-2, 0, -1], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to rotated roof ridge edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode({ rotation: Math.PI / 2 } as never)]) + + handleMeasurementGridClick3D(gridEvent([0.08, 0, -1.25], canvas), canvas) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(-1.25) + expect(draft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(-1.25) + }) + + test('snaps 3D grid clicks to roof accessory edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([ + roofNode({ position: [10, 0, 20], rotation: Math.PI / 2 } as never), + roofSegmentNode({ position: [2, 0, 0] } as never), + skylightNode(), + ]) + useMeasurementTool.getState().setSnapKindEnabled('midpoint', false) + + handleMeasurementGridClick3D(gridEvent([10.58, 0, 18.2], canvas), canvas) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(10.5) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(18.2) + expect(draft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Skylight edge', + view: '3d', + }) + }) + + test('snaps 3D grid clicks to hip roof edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode({ roofType: 'hip' } as never)]) + + handleMeasurementGridClick3D(gridEvent([-1.2, 0, 0.2], canvas), canvas) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(-1.2) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(0.2) + expect(draft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof hip edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(-1.2) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.2) + }) + + test('snaps 3D grid clicks to mansard roof break edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode({ roofType: 'mansard' } as never)]) + + handleMeasurementGridClick3D(gridEvent([0.6, 0, 0.7], canvas), canvas) + + const draft = useMeasurementTool.getState().draft + expect(draft?.start[0]).toBeCloseTo(0.6) + expect(draft?.start[1]).toBeCloseTo(0) + expect(draft?.start[2]).toBeCloseTo(0.7) + expect(draft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof break edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.6) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.7) + }) + + test('snaps 3D grid clicks to dutch roof ridge and break edges', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([roofNode(), roofSegmentNode({ roofType: 'dutch' } as never)]) + + handleMeasurementGridClick3D(gridEvent([0.4, 0, 0.08], canvas), canvas) + + const ridgeDraft = useMeasurementTool.getState().draft + expect(ridgeDraft?.start[0]).toBeCloseTo(0.4) + expect(ridgeDraft?.start[1]).toBeCloseTo(0) + expect(ridgeDraft?.start[2]).toBeCloseTo(0) + expect(ridgeDraft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof ridge edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0) + + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setMode('distance') + + handleMeasurementGridClick3D(gridEvent([0.4, 0, 0.5], canvas), canvas) + + const breakDraft = useMeasurementTool.getState().draft + expect(breakDraft?.start[0]).toBeCloseTo(0.4) + expect(breakDraft?.start[1]).toBeCloseTo(0) + expect(breakDraft?.start[2]).toBeCloseTo(0.5) + expect(breakDraft?.view).toBe('3d') + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'edge', + label: 'Roof break edge', + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget?.point[0]).toBeCloseTo(0.4) + expect(useMeasurementTool.getState().snapTarget?.point[2]).toBeCloseTo(0.5) + }) + + test('prefers 3D scene endpoints over closer edge projections in crowded snaps', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([0.12, 0, 0.04], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'endpoint', + label: 'Endpoint', + point: [0, 0, 0], + view: '3d', + }) + }) + + test('does not snap 3D grid clicks to scene anchors outside the snap radius', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([1.04, 0, 1.96], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1, 0, 2], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'grid', + label: 'Grid', + point: [1, 0, 2], + view: '3d', + }) + }) + + test('snaps 3D grid clicks to saved measurement midpoints', () => { + const canvas = new globalThis.HTMLCanvasElement() + useMeasurementTool.getState().addSegment('3d', [0, 0, 0], [3, 0, 0]) + + handleMeasurementGridClick3D(gridEvent([1.52, 0, 0.04], canvas), canvas) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [1.5, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + kind: 'measurement', + label: 'Measurement midpoint', + point: [1.5, 0, 0], + view: '3d', + }) + }) + + test('constrains 3D grid endpoints parallel to a nearby host edge', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([0.08, 0, 0.06], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([2, 0, 0.08], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([2, 0, 0.08], canvas), canvas) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [2, 0, 0], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + guideLine: { + end: [2, 0, 0], + start: [-2, 0, 0], + }, + kind: 'guide', + label: 'Parallel', + point: [2, 0, 0], + view: '3d', + }) + }) + + test('constrains 3D grid endpoints perpendicular to a nearby host edge', () => { + const canvas = new globalThis.HTMLCanvasElement() + seedScene([wallNode()]) + + handleMeasurementGridClick3D(gridEvent([0.08, 0, 0.06], canvas), canvas) + handleMeasurementGridMove3D(gridEvent([0.08, 0, 2], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([0.08, 0, 2], canvas), canvas) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [0, 0, 2], + view: '3d', + }) + expect(useMeasurementTool.getState().snapTarget).toMatchObject({ + label: 'Perpendicular', + point: [0, 0, 2], + view: '3d', + }) + }) + + test('adds 3D surface area in area mode', () => { + useMeasurementTool.getState().setMode('area') + + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [2, 0, 1.5])) + + const state = useMeasurementTool.getState() + expect(state.areas).toHaveLength(1) + expect(state.areas[0]).toMatchObject({ + areaSquareMeters: 12, + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + labelPoint: [2, 0.05, 1.5], + view: '3d', + }) + expect(state.perimeters).toHaveLength(0) + }) + + test('hovering a 3D surface previews area in area mode without saving it', () => { + useMeasurementTool.getState().setMode('area') + + handleMeasurementNodeMove3D(nodeEvent(zoneNode(), [2, 0, 1.5])) + + const state = useMeasurementTool.getState() + expect(state.previewArea).toMatchObject({ + areaSquareMeters: 12, + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + view: '3d', + }) + expect(state.areas).toHaveLength(0) + }) + + test('commits a freeform 3D area polygon by clicking the first point again', () => { + useMeasurementTool.getState().setMode('area') + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([4, 0, 0], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([4, 0, 3], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + + const state = useMeasurementTool.getState() + expect(state.polygonDraft).toBeNull() + expect(state.areas).toHaveLength(1) + expect(state.areas[0]).toMatchObject({ + areaSquareMeters: 6, + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + ], + view: '3d', + }) + }) + + test('adds 3D surface perimeter in perimeter mode', () => { + useMeasurementTool.getState().setMode('perimeter') + + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [2, 0, 1.5])) + + const state = useMeasurementTool.getState() + expect(state.perimeters).toHaveLength(1) + expect(state.perimeters[0]).toMatchObject({ + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + labelPoint: [2, 0.05, 1.5], + lengthMeters: 14, + view: '3d', + }) + expect(state.areas).toHaveLength(0) + }) + + test('draws 3D slab perimeter on the rendered slab edge', () => { + useMeasurementTool.getState().setMode('perimeter') + + handleMeasurementNodeClick3D(nodeEvent(slabWithHoleNode(), [2, 0, 2])) + + const perimeter = useMeasurementTool.getState().perimeters[0] + expect(perimeter).toMatchObject({ + view: '3d', + }) + expect(perimeter?.boundaryPoints).toHaveLength(4) + const boundaryPoints = perimeter?.boundaryPoints + expect(boundaryPoints).toBeDefined() + const expectedBoundaryPoints: MeasurementPoint[] = [ + [4.05, 0.02, -0.05], + [4.05, 0.02, 4.05], + [-0.05, 0.02, 4.05], + [-0.05, 0.02, -0.05], + ] + expectedBoundaryPoints.forEach((point, index) => { + expect(boundaryPoints![index]?.[0]).toBeCloseTo(point[0]) + expect(boundaryPoints![index]?.[1]).toBeCloseTo(point[1]) + expect(boundaryPoints![index]?.[2]).toBeCloseTo(point[2]) + }) + expect(perimeter?.labelPoint[0]).toBeCloseTo(2) + expect(perimeter?.labelPoint[1]).toBeCloseTo(0.05) + expect(perimeter?.labelPoint[2]).toBeCloseTo(2) + expect(perimeter?.lengthMeters).toBeCloseTo(20.4) + }) + + test('hovering a 3D surface previews perimeter in perimeter mode without saving it', () => { + useMeasurementTool.getState().setMode('perimeter') + + handleMeasurementNodeMove3D(nodeEvent(zoneNode(), [2, 0, 1.5])) + + const state = useMeasurementTool.getState() + expect(state.previewPerimeter).toMatchObject({ + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + lengthMeters: 14, + view: '3d', + }) + expect(state.perimeters).toHaveLength(0) + }) + + test('3D perimeter reuses the area outline when its perimeter has no boundary points', () => { + useMeasurementTool.getState().setMode('perimeter') + + handleMeasurementNodeMove3D(nodeEvent(areaOutlinedPerimeterNode(), [2, 0, 1.5])) + + const state = useMeasurementTool.getState() + expect(state.previewPerimeter).toMatchObject({ + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + lengthMeters: 14, + view: '3d', + }) + }) + + test('commits a freeform 3D perimeter polygon by clicking the first point again', () => { + useMeasurementTool.getState().setMode('perimeter') + const canvas = new globalThis.HTMLCanvasElement() + + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([4, 0, 0], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([4, 0, 3], canvas), canvas) + handleMeasurementGridClick3D(gridEvent([0, 0, 0], canvas), canvas) + + const state = useMeasurementTool.getState() + expect(state.polygonDraft).toBeNull() + expect(state.perimeters).toHaveLength(1) + expect(state.perimeters[0]).toMatchObject({ + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + ], + lengthMeters: 12, + view: '3d', + }) + }) + + test('alt-click on a 3D surface adds perimeter only', () => { + handleMeasurementNodeClick3D(nodeEvent(zoneNode(), [2, 0, 1.5], { altKey: true })) + + const state = useMeasurementTool.getState() + expect(state.perimeters).toHaveLength(1) + expect(state.areas).toHaveLength(0) + }) + + test('normal click on a 3D measurable wall starts point-to-point drawing', () => { + handleMeasurementNodeClick3D(nodeEvent(wallNode(), [2, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.draft).toMatchObject({ + start: [2, 0, 0], + view: '3d', + }) + expect(state.segments).toHaveLength(0) + }) + + test('hovering a 3D measurable wall previews its direct length without saving it', () => { + handleMeasurementNodeMove3D(nodeEvent(wallNode(), [2, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '3d', + }) + expect(state.segments).toHaveLength(0) + + const canvas = {} as HTMLCanvasElement + handleMeasurementGridMove3D(gridEvent([8, 0, 0], canvas), canvas) + + expect(useMeasurementTool.getState().previewSegment).toBeNull() + }) + + test('ignores the 3D site ground plane for measurement hover', () => { + handleMeasurementNodeMove3D(nodeEvent(siteNode(), [0, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.cursor).toBeNull() + expect(state.previewSegment).toBeNull() + expect(state.previewArea).toBeNull() + expect(state.previewPerimeter).toBeNull() + expect(state.snapTarget).toBeNull() + }) + + test('hovering a 3D surface edge previews the edge under the cursor', () => { + handleMeasurementNodeMove3D(nodeEvent(zoneNode(), [4, 0, 1.5])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 3, + start: [4, 0, 0], + end: [4, 0, 3], + view: '3d', + }) + }) + + test('hovering a 3D roof ridge previews the actual roof line under the cursor', () => { + const roof = roofNode() + const segment = roofSegmentNode() + seedScene([roof, segment]) + + handleMeasurementNodeMove3D(nodeEvent(segment, [1, 0, 0])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 4, + start: [-2, 0, 0], + end: [2, 0, 0], + view: '3d', + }) + }) + + test('hovering a 3D stair footprint edge previews the edge under the cursor', () => { + const stair = stairNode() + const segment = stairSegmentNode() + seedScene([stair, segment]) + + handleMeasurementNodeMove3D(nodeEvent(stair, [0.5, 0, 1.5])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 3, + start: [0.5, 0, 0], + end: [0.5, 0, 3], + view: '3d', + }) + }) + + test('hovering a 3D fence side previews the fence height', () => { + const fence = { ...splineFenceNode(), height: 1.8, thickness: 0.08 } as AnyNode + seedScene([fence]) + + handleMeasurementNodeMove3D(nodeEvent(fence, [2, 0.9, 1])) + + const preview = useMeasurementTool.getState().previewSegment + expect(preview?.measuredDistanceMeters).toBeCloseTo(1.8) + expect(preview?.start[1]).toBeCloseTo(0) + expect(preview?.end[1]).toBeCloseTo(1.8) + expect(preview?.view).toBe('3d') + }) + + test('hovering a 3D window side edge previews the opening height', () => { + const window = { + ...windowNode(), + position: [2, 1, 0], + width: 3, + height: 1, + } as AnyNode + seedScene([wallNode(), window]) + + handleMeasurementNodeMove3D(nodeEvent(window, [0.5, 1, 0.08])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 1, + start: [0.5, 0.5, 0.08], + end: [0.5, 1.5, 0.08], + view: '3d', + }) + }) + + test('hovering a 3D window bottom edge still previews the opening width', () => { + const window = { + ...windowNode(), + position: [2, 1, 0], + width: 3, + height: 1, + } as AnyNode + seedScene([wallNode(), window]) + + handleMeasurementNodeMove3D(nodeEvent(window, [2, 0.5, 0.08])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 3, + start: [0.5, 0.5, 0.08], + end: [3.5, 0.5, 0.08], + view: '3d', + }) + }) + + test('hovering a 3D door side edge previews the door height', () => { + const door = doorNode() + seedScene([wallNode(), door]) + + handleMeasurementNodeMove3D(nodeEvent(door, [2.45, 1.05, 0.08])) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 2.1, + start: [2.45, 0, 0.08], + end: [2.45, 2.1, 0.08], + view: '3d', + }) + }) + + test('hovering a 3D wall side previews semantic wall height instead of rendered bounds', () => { + const wall = { ...wallNode(), height: 2.5, thickness: 0.2 } as AnyNode + const mesh = new Mesh(new BoxGeometry(4, 3.54, 0.2)) + mesh.position.y = 1.77 + sceneRegistry.nodes.set(wall.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D( + nodeEvent(wall, [1.2, 1.4, 0.1], { normal: [0, 0, 1], object: mesh }), + ) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 2.5, + start: [1.2, 0, 0.1], + end: [1.2, 2.5, 0.1], + view: '3d', + }) + expect(state.segments).toHaveLength(0) + }) + + test('hovering a 3D wall side anchors the preview to the wall face', () => { + const wall = { ...wallNode(), height: 2.5, thickness: 0.2 } as AnyNode + const mesh = new Mesh(new BoxGeometry(4, 3.54, 0.2)) + mesh.position.y = 1.77 + sceneRegistry.nodes.set(wall.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D( + nodeEvent(wall, [1.2, 1.4, 0.34], { normal: [0, 0, 1], object: mesh }), + ) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 2.5, + start: [1.2, 0, 0.1], + end: [1.2, 2.5, 0.1], + view: '3d', + }) + }) + + test('hovering a 3D wall side without a face normal still previews semantic wall height', () => { + const wall = { ...wallNode(), height: 2.5, thickness: 0.2 } as AnyNode + const mesh = new Mesh(new BoxGeometry(4, 3.54, 0.2)) + mesh.position.y = 1.77 + sceneRegistry.nodes.set(wall.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D(nodeEvent(wall, [1.2, 1.4, 0.1], { object: mesh })) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 2.5, + start: [1.2, 0, 0.1], + end: [1.2, 2.5, 0.1], + view: '3d', + }) + }) + + test('hovering an elevated 3D wall anchors semantic height to its rendered level', () => { + const wall = { ...wallNode(), height: 2.5, thickness: 0.2 } as AnyNode + const building = new Group() + const level = new Group() + const mesh = new Mesh(new BoxGeometry(4, 2.5, 0.2)) + level.position.y = 5 + mesh.position.y = 1.25 + level.add(mesh) + building.add(level) + building.updateMatrixWorld(true) + sceneRegistry.nodes.set('building_measurement' as AnyNodeId, building) + sceneRegistry.nodes.set(wall.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D( + nodeEvent(wall, [1.2, 6.4, 0.1], { normal: [0, 0, 1], object: mesh }), + 'building_measurement' as AnyNodeId, + ) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 2.5, + start: [1.2, 5, 0.1], + end: [1.2, 7.5, 0.1], + view: '3d', + }) + }) + + test('hovering a rendered external asset owns the hover preview over the underlay', () => { + const node = spawnNode() + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + const canvas = {} as HTMLCanvasElement + let stopped = false + sceneRegistry.nodes.set(node.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D( + nodeEvent(node, [0, 0, 0], { + object: mesh, + onStopPropagation: () => { + stopped = true + }, + }), + ) + + const state = useMeasurementTool.getState() + expect(stopped).toBe(true) + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 3, + start: [-1, 0, -1.5], + end: [-1, 0, 1.5], + view: '3d', + }) + + handleMeasurementGridMove3D(gridEvent([2, 0, 1.5], canvas), canvas, () => true) + + expect(useMeasurementTool.getState().previewSegment).toMatchObject({ + measuredDistanceMeters: 3, + start: [-1, 0, -1.5], + end: [-1, 0, 1.5], + view: '3d', + }) + }) + + test('hovering a rendered external asset previews the edge under the cursor', () => { + const node = spawnNode() + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + sceneRegistry.nodes.set(node.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D(nodeEvent(node, [0, 1, 1.5], { object: mesh })) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 2, + start: [-1, 1, 1.5], + end: [1, 1, 1.5], + view: '3d', + }) + expect(state.cursor).toEqual({ point: [0, 1, 1.5], view: '3d' }) + expect(state.snapTarget).toMatchObject({ + kind: 'edge', + label: 'Box edge', + point: [0, 1, 1.5], + view: '3d', + }) + }) + + test('hovering a rendered vertical side previews a surface-aligned height', () => { + const node = spawnNode() + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + sceneRegistry.nodes.set(node.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D(nodeEvent(node, [1, 0.6, 0.2], { normal: [1, 0, 0], object: mesh })) + + const state = useMeasurementTool.getState() + expect(state.previewSegment).toMatchObject({ + measuredDistanceMeters: 1, + start: [1, 0, 0.2], + end: [1, 1, 0.2], + view: '3d', + }) + }) + + test('normal click after a 3D hover preview starts point-to-point drawing', () => { + const node = spawnNode() + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + sceneRegistry.nodes.set(node.id as AnyNodeId, mesh) + + handleMeasurementNodeMove3D(nodeEvent(node, [0, 1, 1.5], { object: mesh })) + handleMeasurementNodeClick3D(nodeEvent(node, [0, 1, 1.5], { object: mesh })) + + const state = useMeasurementTool.getState() + expect(state.draft).toMatchObject({ + start: [0, 1, 1.5], + view: '3d', + }) + expect(state.previewSegment).toBeNull() + expect(state.segments).toHaveLength(0) + }) + + test('normal clicks on 3D measurable walls commit point-to-point drawing', () => { + handleMeasurementNodeClick3D(nodeEvent(wallNode(), [1, 0, 0])) + handleMeasurementNodeClick3D(nodeEvent(wallNode(), [3, 0, 0])) + + const state = useMeasurementTool.getState() + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [1, 0, 0], + end: [3, 0, 0], + view: '3d', + }) + }) + + test('alt-click on a 3D measurable wall adds its length in distance mode', () => { + handleMeasurementNodeClick3D(nodeEvent(wallNode(), [2, 0, 0], { altKey: true })) + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '3d', + }) + }) + + test('ctrl-click on a 3D measurable wall quick-adds its length without starting a draft', () => { + handleMeasurementNodeClick3D(nodeEvent(wallNode(), [2, 0, 0], { ctrlKey: true })) + + const state = useMeasurementTool.getState() + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [4, 0, 0], + measuredDistanceMeters: 4, + view: '3d', + }) + }) + + test('alt-click on a rendered external asset uses its bounding box length', () => { + const node = spawnNode() + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + sceneRegistry.nodes.set(node.id as AnyNodeId, mesh) + + handleMeasurementNodeClick3D(nodeEvent(node, [0, 0, 0], { altKey: true, object: mesh })) + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]).toMatchObject({ + measuredDistanceMeters: 3, + start: [-1, 0, -1.5], + end: [-1, 0, 1.5], + view: '3d', + }) + }) + + test('alt-click on an item parents the quick measurement scene node under that item', () => { + const item = ItemNode.parse({ + id: 'item_measurement_quick', + asset: { + id: 'asset_measurement_quick', + category: 'furniture', + name: 'Measured item', + thumbnail: '/item.webp', + src: '/item.glb', + }, + }) + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + seedScene([item]) + sceneRegistry.nodes.set(item.id as AnyNodeId, mesh) + + handleMeasurementNodeClick3D(nodeEvent(item, [0, 0, 0], { altKey: true, object: mesh })) + syncMeasurementSceneNodesForTest() + + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + parentId: item.id, + }) + expect((useScene.getState().nodes[item.id] as { children: string[] }).children).toContain( + sceneNode!.id, + ) + }) + + test('two normal item surface clicks parent the measurement scene node under that item', () => { + const item = ItemNode.parse({ + id: 'item_measurement_drawn', + asset: { + id: 'asset_measurement_drawn', + category: 'furniture', + name: 'Measured item', + thumbnail: '/item.webp', + src: '/item.glb', + }, + }) + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + seedScene([item]) + sceneRegistry.nodes.set(item.id as AnyNodeId, mesh) + + handleMeasurementNodeClick3D(nodeEvent(item, [-1, 0, -1.5], { object: mesh })) + handleMeasurementNodeClick3D(nodeEvent(item, [1, 0, -1.5], { object: mesh })) + syncMeasurementSceneNodesForTest() + + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + parentId: item.id, + }) + expect((useScene.getState().nodes[item.id] as { children: string[] }).children).toContain( + sceneNode!.id, + ) + }) + + test('two normal merged roof surface clicks parent the measurement scene node under the hit segment', () => { + const roof = roofNode() + const segment = roofSegmentNode() + const roofMesh = new Mesh(new BoxGeometry(4, 2, 2)) + const segmentMesh = new Mesh(new BoxGeometry(4, 2, 2)) + seedScene([roof, segment]) + sceneRegistry.nodes.set(roof.id as AnyNodeId, roofMesh) + sceneRegistry.nodes.set(segment.id as AnyNodeId, segmentMesh) + + handleMeasurementNodeClick3D(nodeEvent(roof, [0, 0, 0])) + handleMeasurementNodeClick3D(nodeEvent(roof, [1, 0, 0])) + syncMeasurementSceneNodesForTest() + + const segmentState = useMeasurementTool.getState().segments[0] + expect(segmentState?.startAttachment).toMatchObject({ + nodeId: roof.id, + ownerNodeId: segment.id, + }) + expect(segmentState?.endAttachment).toMatchObject({ + nodeId: roof.id, + ownerNodeId: segment.id, + }) + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + parentId: segment.id, + }) + expect((useScene.getState().nodes[segment.id] as { children: string[] }).children).toContain( + sceneNode!.id, + ) + expect((useScene.getState().nodes[roof.id] as { children: string[] }).children).not.toContain( + sceneNode!.id, + ) + roofMesh.geometry.dispose() + segmentMesh.geometry.dispose() + }) + + test('two normal cabinet run surface clicks parent the measurement scene node under the hit module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_measurement_drawn', + children: ['cabinet-module_measurement_left', 'cabinet-module_measurement_right'], + }) + const leftModule = CabinetModuleNode.parse({ + id: 'cabinet-module_measurement_left', + parentId: run.id, + position: [-1, 0, 0], + width: 2, + depth: 1, + }) + const rightModule = CabinetModuleNode.parse({ + id: 'cabinet-module_measurement_right', + parentId: run.id, + position: [1, 0, 0], + width: 2, + depth: 1, + }) + const mesh = new Mesh(new BoxGeometry(4, 1, 1)) + mesh.position.y = 0.5 + seedScene([run, leftModule, rightModule]) + sceneRegistry.nodes.set(run.id as AnyNodeId, mesh) + + handleMeasurementNodeClick3D(nodeEvent(run, [1.2, 0, 0], { object: mesh })) + handleMeasurementNodeClick3D(nodeEvent(run, [1.8, 0, 0], { object: mesh })) + syncMeasurementSceneNodesForTest() + + const segmentState = useMeasurementTool.getState().segments[0] + expect(segmentState?.startAttachment).toMatchObject({ + nodeId: run.id, + ownerNodeId: rightModule.id, + }) + expect(segmentState?.endAttachment).toMatchObject({ + nodeId: run.id, + ownerNodeId: rightModule.id, + }) + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + parentId: rightModule.id, + }) + expect( + (useScene.getState().nodes[rightModule.id] as { children: string[] }).children, + ).toContain(sceneNode!.id) + expect((useScene.getState().nodes[run.id] as { children: string[] }).children).not.toContain( + sceneNode!.id, + ) + mesh.geometry.dispose() + }) + + test('two unsnapped item surface clicks still attach the measurement scene node to that item', () => { + const item = ItemNode.parse({ + id: 'item_measurement_unsnapped', + asset: { + id: 'asset_measurement_unsnapped', + category: 'furniture', + name: 'Measured item', + thumbnail: '/item.webp', + src: '/item.glb', + }, + }) + const mesh = new Mesh(new BoxGeometry(2, 1, 3)) + mesh.position.y = 0.5 + seedScene([item]) + sceneRegistry.nodes.set(item.id as AnyNodeId, mesh) + + handleMeasurementNodeClick3D(nodeEvent(item, [0.23, 0.42, 0.31], { object: mesh })) + handleMeasurementNodeClick3D(nodeEvent(item, [0.67, 0.48, 0.94], { object: mesh })) + syncMeasurementSceneNodesForTest() + + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + parentId: item.id, + }) + expect((useScene.getState().nodes[item.id] as { children: string[] }).children).toContain( + sceneNode!.id, + ) + }) +}) diff --git a/packages/editor/src/components/tools/measurement/measurement-tool.tsx b/packages/editor/src/components/tools/measurement/measurement-tool.tsx new file mode 100644 index 000000000..c56ccd887 --- /dev/null +++ b/packages/editor/src/components/tools/measurement/measurement-tool.tsx @@ -0,0 +1,2502 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + type GeometryContext, + type GridEvent, + type MeasurementDefinitionArea, + type MeasurementDefinitionDirectLength, + type MeasurementDefinitionPerimeter, + type MeasurementDefinitionSnapGeometry, + type NodeEvent, + nodeRegistry, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { type PointerEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { + Box3, + BoxGeometry, + BufferGeometry, + CircleGeometry, + type Group, + Quaternion, + RingGeometry, + SphereGeometry, + TorusGeometry, + Vector3, +} from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { EDITOR_LAYER } from '../../../lib/constants' +import { + createNodeBoundsAttachment, + getResolvedMeasurementSegments, + refreshRenderedBoundsMeasurementSegments, + sameResolvedMeasurementSegments, + useResolvedMeasurementSegments, +} from '../../../lib/measurement-attachments' +import { + collectCommittedMeasurementSnapGeometry, + collectPlanMeasurementSnapGeometry, + type MeasurementSnapAnchor, + measurementSnapKindFromLabel, + mergeMeasurementSnapGeometry, + resolvePlanMeasurementConstraint, + resolvePlanMeasurementSnap, +} from '../../../lib/measurement-snapping' +import { + angleBetweenMeasurements, + formatAngleMeasurement, + formatAreaMeasurement, + formatLinearMeasurement, + type LinearUnit, +} from '../../../lib/measurements' +import useInteractionScope from '../../../store/use-interaction-scope' +import { + distanceBetweenMeasurements, + isDraggingMeasurementEndpoint, + type MeasurementAngle, + type MeasurementArea, + type MeasurementPerimeter, + type MeasurementPoint, + type MeasurementPolygonDraft, + type MeasurementSegment, + type MeasurementSegmentEndpoint, + type MeasurementSnapTarget, + polygonAreaAndLabelPointFromMeasurements, + polygonPerimeterFromMeasurements, + useMeasurementTool, +} from '../../../store/use-measurement-tool' +import { + DIMENSION_PILL_PRIMARY_CLASS_NAME, + DimensionPillShell, +} from '../../editor/measurement-pill' + +const MEASUREMENT_COLOR = 0x8b_5c_f6 +const MEASUREMENT_LINE_WIDTH = 0.0135 +const MEASUREMENT_PERIMETER_BOUNDARY_WIDTH = MEASUREMENT_LINE_WIDTH +const MEASUREMENT_END_TICK = 0.28 +const MEASUREMENT_LABEL_LIFT = 0.08 +const MEASUREMENT_CURSOR_SIZE = 0.18 +const MEASUREMENT_CURSOR_WIDTH = 0.018 +const MEASUREMENT_SNAP_COLOR = 0xff_ff_ff +const MEASUREMENT_ENDPOINT_HANDLE_SIZE = 0.16 +const MEASUREMENT_ENDPOINT_HANDLE_HIT_SIZE = 0.34 +const MEASUREMENT_LABEL_COLLISION_CELL = 0.45 +const MEASUREMENT_LABEL_STAGGER_STEP = 0.22 +const MEASUREMENT_ANGLE_ARC_MIN_RADIUS = 0.22 +const MEASUREMENT_ANGLE_ARC_MAX_RADIUS = 0.72 +const MEASUREMENT_ANGLE_ARC_SEGMENTS = 32 +const MEASUREMENT_SURFACE_SNAP_RADIUS = 0.25 +const MEASUREMENT_GRID_SNAP_RADIUS = 0.25 +const MEASUREMENT_NODE_SNAP_PRIORITY_BUCKET = 1_000 +const MEASUREMENT_PLAN_SNAP_Y_TOLERANCE = 1e-5 +const SURFACE_EVENT_SUPPRESSION_MS = 80 +type MeasurementAppearance = 'dark' | 'light' + +const dashGeometry = new BoxGeometry(1, 1, 1) +const endpointHandleGeometry = new SphereGeometry(0.5, 24, 16) +const endpointHandleHexGeometry = new CircleGeometry(0.5, 6) +const endpointHandleHexRingGeometry = new RingGeometry(0.5, 0.62, 6) +const snapTargetRingGeometry = new TorusGeometry(0.5, 0.02, 8, 36) +const MEASUREMENT_BAR_AXIS = new Vector3(1, 0, 0) +const measurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.95, + toneMapped: false, + transparent: true, +}) +const darkMeasurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.95, + toneMapped: false, + transparent: true, +}) +const mutedMeasurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.38, + toneMapped: false, + transparent: true, +}) +const darkMutedMeasurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.38, + toneMapped: false, + transparent: true, +}) +const draftMeasurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.98, + toneMapped: false, + transparent: true, +}) +const darkDraftMeasurementMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.98, + toneMapped: false, + transparent: true, +}) +const endpointHandleHitMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0, + toneMapped: false, + transparent: true, +}) +const snapTargetRingMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_SNAP_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.68, + toneMapped: false, + transparent: true, +}) +const snapTargetLineMaterial = new MeshBasicNodeMaterial({ + color: MEASUREMENT_SNAP_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.5, + toneMapped: false, + transparent: true, +}) + +function measurableNodeKinds(): string[] { + return Array.from(nodeRegistry.entries()).flatMap(([kind, def]) => + is3DMeasurementPickEnabled(def) && + (def.measurement || + def.floorplan || + def.renderer || + def.geometry || + def.capabilities.selectable) + ? [kind] + : [], + ) +} + +function is3DMeasurementPickEnabled(def: ReturnType): boolean { + return def?.measurement?.pick3D !== false +} + +function isIgnoredMeasurementNode(node: AnyNode): boolean { + return !is3DMeasurementPickEnabled(nodeRegistry.get(node.type)) +} + +function clearTransientMeasurementHover() { + const measurement = useMeasurementTool.getState() + measurement.setCursor('3d', null) + measurement.setSnapTarget(null) + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) +} + +export function getMeasurementAnnotationColors(appearance: 'dark' | 'light') { + return appearance === 'dark' + ? { + backgroundColor: 'rgba(24, 24, 27, 0.94)', + borderColor: 'rgba(139, 92, 246, 0.72)', + color: '#c4b5fd', + shadowColor: '#111111', + } + : { + backgroundColor: 'rgba(255, 255, 255, 0.96)', + borderColor: 'rgba(139, 92, 246, 0.72)', + color: '#7c3aed', + shadowColor: '#ffffff', + } +} + +function isCanvasEvent(event: GridEvent, canvas: HTMLCanvasElement): boolean { + return event.nativeEvent?.target === canvas +} + +function measurementPointFromGridEvent(event: GridEvent): MeasurementPoint { + return [...event.localPosition] as MeasurementPoint +} + +function resolveGridMeasurementSnap3D(point: MeasurementPoint): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + if (Math.abs(point[1]) > MEASUREMENT_PLAN_SNAP_Y_TOLERANCE) { + return { point, target: null } + } + + return resolvePlanMeasurementSnap( + point, + mergeMeasurementSnapGeometry( + collectPlanMeasurementSnapGeometry(Object.values(useScene.getState().nodes)), + collectCommittedMeasurementSnapGeometry(getResolvedMeasurementSegments()), + ), + { + enabledSnapKinds: useMeasurementTool.getState().enabledSnapKinds, + radiusMeters: MEASUREMENT_GRID_SNAP_RADIUS, + view: '3d', + }, + ) +} + +function resolveGridMeasurementConstraint3D( + start: MeasurementPoint, + point: MeasurementPoint, +): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + return resolvePlanMeasurementConstraint( + start, + point, + mergeMeasurementSnapGeometry( + collectPlanMeasurementSnapGeometry(Object.values(useScene.getState().nodes)), + collectCommittedMeasurementSnapGeometry(getResolvedMeasurementSegments()), + ), + { + enabledSnapKinds: useMeasurementTool.getState().enabledSnapKinds, + radiusMeters: MEASUREMENT_GRID_SNAP_RADIUS, + view: '3d', + }, + ) +} + +function worldToBuildingLocal( + point: [number, number, number], + buildingId: AnyNodeId | null, +): MeasurementPoint { + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + const local = buildingMesh + ? buildingMesh.worldToLocal(new Vector3(...point)) + : new Vector3(...point) + return [local.x, local.y, local.z] +} + +function measurementPointFromNodeEvent( + event: NodeEvent, + buildingId: AnyNodeId | null, +): MeasurementPoint { + return worldToBuildingLocal(event.position, buildingId) +} + +function measurementOwnerNodeIdFromEvent(event: NodeEvent): AnyNodeId { + const ownerId = registryMeasurementForNode(event.node)?.resolveOwnerId?.({ + ctx: measurementGeometryContextForNode(event.node), + node: event.node as never, + worldPoint: event.position, + }) as AnyNodeId | null | undefined + return ownerId ?? event.node.id +} + +function createNodeEventMeasurementAttachment( + event: NodeEvent, + point: MeasurementPoint, + buildingId: AnyNodeId | null, +) { + const attachment = createNodeBoundsAttachment(event.node.id, point, buildingId ?? undefined) + return attachEventMeasurementOwner(event, attachment) +} + +function attachEventMeasurementOwner< + T extends { attachment?: MeasurementSnapAnchor['attachment'] }, +>(event: NodeEvent, value: T): T +function attachEventMeasurementOwner( + event: NodeEvent, + attachment: MeasurementSnapAnchor['attachment'], +): MeasurementSnapAnchor['attachment'] +function attachEventMeasurementOwner< + T extends { attachment?: MeasurementSnapAnchor['attachment'] }, +>( + event: NodeEvent, + value: T | MeasurementSnapAnchor['attachment'], +): T | MeasurementSnapAnchor['attachment'] { + const attachment = + value && 'attachment' in value + ? value.attachment + : (value as MeasurementSnapAnchor['attachment']) + if (!attachment) return value && 'attachment' in value ? value : undefined + const ownerNodeId = measurementOwnerNodeIdFromEvent(event) + const ownedAttachment = + ownerNodeId === event.node.id ? attachment : { ...attachment, ownerNodeId } + if (value && 'attachment' in value) return { ...value, attachment: ownedAttachment } as T + return ownedAttachment +} + +function measurementNormalFromNodeEvent( + event: NodeEvent, + buildingId: AnyNodeId | null, +): MeasurementPoint | null { + if (!event.normal) return null + const localNormal = new Vector3(...event.normal).normalize() + if (localNormal.lengthSq() < 1e-8) return null + + const worldOrigin = event.object.localToWorld(new Vector3(0, 0, 0)) + const worldNormalPoint = event.object.localToWorld(localNormal.clone()) + const localOrigin = worldToBuildingLocal( + [worldOrigin.x, worldOrigin.y, worldOrigin.z], + buildingId, + ) + const localNormalPoint = worldToBuildingLocal( + [worldNormalPoint.x, worldNormalPoint.y, worldNormalPoint.z], + buildingId, + ) + const normal = new Vector3( + localNormalPoint[0] - localOrigin[0], + localNormalPoint[1] - localOrigin[1], + localNormalPoint[2] - localOrigin[2], + ).normalize() + if (normal.lengthSq() < 1e-8) return null + return [normal.x, normal.y, normal.z] +} + +function measurementGeometryContextForNode(node: AnyNode): GeometryContext { + const nodes = useScene.getState().nodes + const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? [] + return { + children: childIds.flatMap((id: AnyNodeId) => { + const child = nodes[id as AnyNodeId] + return child ? [child] : [] + }), + parent: node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null, + resolve: (id: AnyNodeId) => nodes[id] as N | undefined, + siblings: node.parentId + ? Object.values(nodes).filter( + (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id, + ) + : [], + } +} + +function registryMeasurementForNode(node: AnyNode) { + return nodeRegistry.get(node.type)?.measurement +} + +function definitionPoint(point: readonly [number, number, number]): MeasurementPoint { + return [...point] as MeasurementPoint +} + +function directLengthFromDefinition( + node: AnyNode, + buildingId: AnyNodeId | null, + hitPoint: MeasurementPoint | null, + hitNormal: MeasurementPoint | null, +): DirectLengthSegment | null { + const directLength = registryMeasurementForNode(node)?.directLength?.( + node as never, + measurementGeometryContextForNode(node), + hitPoint, + hitNormal, + ) as MeasurementDefinitionDirectLength | null | undefined + if (!directLength) return null + return alignVerticalDefinitionLengthToRenderedBase(node, buildingId, { + start: definitionPoint(directLength.start), + end: definitionPoint(directLength.end), + measuredDistanceMeters: directLength.measuredDistanceMeters, + }) +} + +function areaFromDefinition(node: AnyNode): MeasurementDefinitionArea | null { + return ( + (registryMeasurementForNode(node)?.area?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionArea | null | undefined) ?? null + ) +} + +function perimeterFromDefinition(node: AnyNode): MeasurementDefinitionPerimeter | null { + return ( + (registryMeasurementForNode(node)?.perimeter?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionPerimeter | null | undefined) ?? null + ) +} + +function perimeterBoundaryPointsFromDefinition3D( + node: AnyNode, + perimeter: MeasurementDefinitionPerimeter, +): MeasurementPoint[] { + const boundaryPoints = + (perimeter.boundaryPoints?.length ?? 0) >= 3 + ? perimeter.boundaryPoints + : areaFromDefinition(node)?.boundaryPoints + return (boundaryPoints ?? []).map(definitionPoint) +} + +function snapAnchorsFromDefinition(node: AnyNode): MeasurementSnapAnchor[] { + const geometry = registryMeasurementForNode(node)?.snapGeometry?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionSnapGeometry | null | undefined + return (geometry?.anchors ?? []).map((anchor, index) => ({ + ...anchor, + attachment: { + feature: { index, kind: 'plan-anchor' }, + nodeId: node.id, + }, + point: definitionPoint(anchor.point), + targetLine: anchor.targetLine + ? { + end: definitionPoint(anchor.targetLine.end), + start: definitionPoint(anchor.targetLine.start), + } + : undefined, + })) +} + +function resolveSurfaceDistanceEndpoint( + start: MeasurementPoint, + startNormal: MeasurementPoint | undefined, + endPoint: MeasurementPoint, + endNormal: MeasurementPoint | null, +): { end: MeasurementPoint; measuredDistanceMeters: number } | null { + if (!(startNormal && endNormal)) return null + const startN = new Vector3(...startNormal).normalize() + const endN = new Vector3(...endNormal).normalize() + if (Math.abs(startN.dot(endN)) < 0.94) return null + + const delta = new Vector3(endPoint[0] - start[0], endPoint[1] - start[1], endPoint[2] - start[2]) + const signedDistance = delta.dot(startN) + if (Math.abs(signedDistance) < 1e-4) return null + const end = startN.multiplyScalar(signedDistance).add(new Vector3(...start)) + return { + end: [end.x, end.y, end.z], + measuredDistanceMeters: Math.abs(signedDistance), + } +} + +type DirectLengthSegment = { + end: MeasurementPoint + measuredDistanceMeters: number + start: MeasurementPoint +} + +function alignVerticalDefinitionLengthToRenderedBase( + node: AnyNode, + buildingId: AnyNodeId | null, + segment: DirectLengthSegment, +): DirectLengthSegment { + const dx = segment.end[0] - segment.start[0] + const dz = segment.end[2] - segment.start[2] + if (Math.hypot(dx, dz) > 1e-6) return segment + + const object = sceneRegistry.nodes.get(node.id as AnyNodeId) + if (!object) return segment + + const geometry = (object as { geometry?: unknown }).geometry + let localBaseY = 0 + if (geometry instanceof BufferGeometry) { + if (!geometry.boundingBox) geometry.computeBoundingBox() + localBaseY = geometry.boundingBox?.min.y ?? 0 + } + + const baseWorld = object.localToWorld(new Vector3(0, localBaseY, 0)) + const renderedBaseY = worldToBuildingLocal([baseWorld.x, baseWorld.y, baseWorld.z], buildingId)[1] + const segmentBaseY = Math.min(segment.start[1], segment.end[1]) + const offsetY = renderedBaseY - segmentBaseY + if (Math.abs(offsetY) < 1e-6) return segment + + return { + ...segment, + start: [segment.start[0], segment.start[1] + offsetY, segment.start[2]], + end: [segment.end[0], segment.end[1] + offsetY, segment.end[2]], + } +} + +function distancePointToMeasurementSegmentSq( + point: MeasurementPoint, + start: MeasurementPoint, + end: MeasurementPoint, +): number { + const px = point[0] + const py = point[1] + const pz = point[2] + const sx = start[0] + const sy = start[1] + const sz = start[2] + const dx = end[0] - sx + const dy = end[1] - sy + const dz = end[2] - sz + const lengthSq = dx * dx + dy * dy + dz * dz + if (lengthSq < 1e-8) { + const ox = px - sx + const oy = py - sy + const oz = pz - sz + return ox * ox + oy * oy + oz * oz + } + const t = Math.max(0, Math.min(1, ((px - sx) * dx + (py - sy) * dy + (pz - sz) * dz) / lengthSq)) + const cx = sx + dx * t + const cy = sy + dy * t + const cz = sz + dz * t + const ox = px - cx + const oy = py - cy + const oz = pz - cz + return ox * ox + oy * oy + oz * oz +} + +function projectPointToMeasurementSegment( + point: MeasurementPoint, + start: MeasurementPoint, + end: MeasurementPoint, +): MeasurementPoint | null { + const sx = start[0] + const sy = start[1] + const sz = start[2] + const dx = end[0] - sx + const dy = end[1] - sy + const dz = end[2] - sz + const lengthSq = dx * dx + dy * dy + dz * dz + if (lengthSq < 1e-8) return null + const t = Math.max( + 0, + Math.min(1, ((point[0] - sx) * dx + (point[1] - sy) * dy + (point[2] - sz) * dz) / lengthSq), + ) + return [sx + dx * t, sy + dy * t, sz + dz * t] +} + +function snapTargetFromPreviewSegment3D( + point: MeasurementPoint, + segment: DirectLengthSegment, +): MeasurementSnapTarget | null { + const projected = projectPointToMeasurementSegment(point, segment.start, segment.end) + if (!projected) return null + return { + kind: 'edge', + label: 'Preview edge', + point: projected, + targetLine: { + end: segment.end, + start: segment.start, + }, + view: '3d', + } +} + +function closestSegmentToPoint( + segments: DirectLengthSegment[], + point: MeasurementPoint | null, +): DirectLengthSegment | null { + if (segments.length === 0) return null + if (!point) { + return segments.reduce((longest, segment) => + segment.measuredDistanceMeters > longest.measuredDistanceMeters ? segment : longest, + ) + } + + return segments.reduce((closest, segment) => { + const closestDistanceSq = distancePointToMeasurementSegmentSq(point, closest.start, closest.end) + const segmentDistanceSq = distancePointToMeasurementSegmentSq(point, segment.start, segment.end) + if (Math.abs(segmentDistanceSq - closestDistanceSq) < 1e-8) { + return segment.measuredDistanceMeters > closest.measuredDistanceMeters ? segment : closest + } + return segmentDistanceSq < closestDistanceSq ? segment : closest + }) +} + +function snapGeometryLengthSegmentFromNode( + node: AnyNode, + hitPoint: MeasurementPoint | null, +): DirectLengthSegment | null { + if (!hitPoint) return null + const geometry = registryMeasurementForNode(node)?.snapGeometry?.( + node as never, + measurementGeometryContextForNode(node), + ) as MeasurementDefinitionSnapGeometry | null | undefined + const segments = (geometry?.segments ?? []).flatMap((segment) => { + const start = definitionPoint(segment.start) + const end = definitionPoint(segment.end) + const measuredDistanceMeters = distanceBetweenMeasurements(start, end) + return measuredDistanceMeters >= 1e-4 ? [{ start, end, measuredDistanceMeters }] : [] + }) + return closestSegmentToPoint(segments, hitPoint) +} + +function renderedBoundingBoxLengthSegment( + node: AnyNode, + buildingId: AnyNodeId | null, + hitPoint: MeasurementPoint | null = null, + hitNormal: MeasurementPoint | null = null, +): DirectLengthSegment | null { + const object = sceneRegistry.nodes.get(node.id as AnyNodeId) + if (!object) return null + + object.updateWorldMatrix(true, true) + const box = new Box3().setFromObject(object) + if (box.isEmpty()) return null + + const size = box.getSize(new Vector3()) + const center = box.getCenter(new Vector3()) + const measureX = size.x >= size.z + const length = measureX ? size.x : size.z + if (length < 1e-4) return null + + if (hitPoint && hitNormal && Math.abs(hitNormal[1]) < 0.7 && size.y >= 1e-4) { + const corners = [ + [box.min.x, box.min.y, box.min.z], + [box.min.x, box.min.y, box.max.z], + [box.min.x, box.max.y, box.min.z], + [box.min.x, box.max.y, box.max.z], + [box.max.x, box.min.y, box.min.z], + [box.max.x, box.min.y, box.max.z], + [box.max.x, box.max.y, box.min.z], + [box.max.x, box.max.y, box.max.z], + ] satisfies MeasurementPoint[] + const localCorners = corners.map((corner) => worldToBuildingLocal(corner, buildingId)) + const minY = Math.min(...localCorners.map((corner) => corner[1])) + const maxY = Math.max(...localCorners.map((corner) => corner[1])) + const start: MeasurementPoint = [hitPoint[0], minY, hitPoint[2]] + const end: MeasurementPoint = [hitPoint[0], maxY, hitPoint[2]] + return { + start, + end, + measuredDistanceMeters: distanceBetweenMeasurements(start, end), + } + } + + if (hitPoint) { + const xs = [box.min.x, box.max.x] + const ys = [box.min.y, box.max.y] + const zs = [box.min.z, box.max.z] + const worldEdges: Array<[Vector3, Vector3]> = [] + for (const y of ys) { + for (const z of zs) + worldEdges.push([new Vector3(box.min.x, y, z), new Vector3(box.max.x, y, z)]) + } + for (const x of xs) { + for (const z of zs) + worldEdges.push([new Vector3(x, box.min.y, z), new Vector3(x, box.max.y, z)]) + } + for (const x of xs) { + for (const y of ys) + worldEdges.push([new Vector3(x, y, box.min.z), new Vector3(x, y, box.max.z)]) + } + + const segments = worldEdges.flatMap(([startWorld, endWorld]) => { + const start = worldToBuildingLocal([startWorld.x, startWorld.y, startWorld.z], buildingId) + const end = worldToBuildingLocal([endWorld.x, endWorld.y, endWorld.z], buildingId) + const measuredDistanceMeters = distanceBetweenMeasurements(start, end) + return measuredDistanceMeters >= 1e-4 ? [{ start, end, measuredDistanceMeters }] : [] + }) + const closest = closestSegmentToPoint(segments, hitPoint) + if (closest) return closest + } + + const startWorld = measureX + ? new Vector3(box.min.x, box.min.y, center.z) + : new Vector3(center.x, box.min.y, box.min.z) + const endWorld = measureX + ? new Vector3(box.max.x, box.min.y, center.z) + : new Vector3(center.x, box.min.y, box.max.z) + const start = worldToBuildingLocal([startWorld.x, startWorld.y, startWorld.z], buildingId) + const end = worldToBuildingLocal([endWorld.x, endWorld.y, endWorld.z], buildingId) + + return { start, end, measuredDistanceMeters: length } +} + +function directLengthSegmentFromNode( + node: AnyNode, + buildingId: AnyNodeId | null, + hitPoint: MeasurementPoint | null = null, + hitNormal: MeasurementPoint | null = null, +): DirectLengthSegment | null { + const contributed = directLengthFromDefinition(node, buildingId, hitPoint, hitNormal) + if (contributed) return contributed + const snapSegment = snapGeometryLengthSegmentFromNode(node, hitPoint) + if (snapSegment) return snapSegment + return renderedBoundingBoxLengthSegment(node, buildingId, hitPoint, hitNormal) +} + +function surfaceAreaMeasurementFromNode(node: AnyNode): { + areaSquareMeters: number + boundaryPoints: MeasurementPoint[] + labelPoint: MeasurementPoint +} | null { + const area = areaFromDefinition(node) + if (!area) return null + return { + areaSquareMeters: area.areaSquareMeters, + boundaryPoints: (area.boundaryPoints ?? []).map(definitionPoint), + labelPoint: definitionPoint(area.labelPoint), + } +} + +function surfacePerimeterMeasurementFromNode(node: AnyNode): { + boundaryPoints: MeasurementPoint[] + labelPoint: MeasurementPoint + lengthMeters: number +} | null { + const perimeter = perimeterFromDefinition(node) + if (!perimeter) return null + return { + boundaryPoints: perimeterBoundaryPointsFromDefinition3D(node, perimeter), + labelPoint: definitionPoint(perimeter.labelPoint), + lengthMeters: perimeter.lengthMeters, + } +} + +function squaredMeasurementDistance(a: MeasurementPoint, b: MeasurementPoint): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +function projectVectorToSegment(point: Vector3, start: Vector3, end: Vector3): Vector3 | null { + const direction = end.clone().sub(start) + const lengthSq = direction.lengthSq() + if (lengthSq < 1e-8) return null + const t = Math.min(1, Math.max(0, point.clone().sub(start).dot(direction) / lengthSq)) + return start.clone().add(direction.multiplyScalar(t)) +} + +function measurementPointFromObjectLocalPoint( + object: NodeEvent['object'], + point: Vector3, + buildingId: AnyNodeId | null, +): MeasurementPoint { + const worldPoint = object.localToWorld(point.clone()) + return worldToBuildingLocal([worldPoint.x, worldPoint.y, worldPoint.z], buildingId) +} + +function isBufferGeometry(value: unknown): value is BufferGeometry { + return value instanceof BufferGeometry +} + +type MeshSnapCandidate = { + kind: 'center' | 'edge' | 'vertex' + label: string + localPoint: Vector3 + localTargetLine?: { + end: Vector3 + start: Vector3 + } + priority: number +} + +function meshSnapCandidatesFromEvent(event: NodeEvent): MeshSnapCandidate[] { + const geometry = (event.object as { geometry?: unknown }).geometry + if (!isBufferGeometry(geometry)) return [] + + const position = geometry.getAttribute('position') + const faceIndex = event.faceIndex + if (!position || typeof faceIndex !== 'number') return [] + + const readVertexIndex = (triangleCorner: 0 | 1 | 2) => + geometry.index + ? geometry.index.getX(faceIndex * 3 + triangleCorner) + : faceIndex * 3 + triangleCorner + const readVertex = (triangleCorner: 0 | 1 | 2) => + new Vector3().fromBufferAttribute(position, readVertexIndex(triangleCorner)) + const localHit = new Vector3(...event.localPosition) + const vertices = [readVertex(0), readVertex(1), readVertex(2)] as const + const triangleEdges: Array = [ + [vertices[0], vertices[1]], + [vertices[1], vertices[2]], + [vertices[2], vertices[0]], + ] + const edges = triangleEdges.flatMap(([start, end]) => { + const projection = projectVectorToSegment(localHit, start, end) + return projection + ? [ + { + kind: 'edge' as const, + label: 'Mesh edge', + localPoint: projection, + localTargetLine: { end, start }, + priority: 2, + }, + ] + : [] + }) + const faceCenter = vertices[0] + .clone() + .add(vertices[1]) + .add(vertices[2]) + .multiplyScalar(1 / 3) + + return [ + ...vertices.map((localPoint, index) => ({ + kind: 'vertex' as const, + label: 'Mesh vertex', + localPoint, + localTargetLine: { start: localPoint, end: vertices[(index + 1) % vertices.length]! }, + priority: 0, + })), + { kind: 'center' as const, label: 'Face center', localPoint: faceCenter, priority: 1 }, + ...edges, + ] +} + +function boundingBoxMeasurementAnchors( + node: AnyNode, + buildingId: AnyNodeId | null, +): MeasurementSnapAnchor[] { + const object = sceneRegistry.nodes.get(node.id as AnyNodeId) + if (!object) return [] + + object.updateWorldMatrix(true, true) + const box = new Box3().setFromObject(object) + if (box.isEmpty()) return [] + + const min = box.min + const max = box.max + const center = box.getCenter(new Vector3()) + const corners = [ + new Vector3(min.x, min.y, min.z), + new Vector3(max.x, min.y, min.z), + new Vector3(max.x, min.y, max.z), + new Vector3(min.x, min.y, max.z), + new Vector3(min.x, max.y, min.z), + new Vector3(max.x, max.y, min.z), + new Vector3(max.x, max.y, max.z), + new Vector3(min.x, max.y, max.z), + ] + const edgeMidpoints = [ + [corners[0], corners[1]], + [corners[1], corners[2]], + [corners[2], corners[3]], + [corners[3], corners[0]], + [corners[4], corners[5]], + [corners[5], corners[6]], + [corners[6], corners[7]], + [corners[7], corners[4]], + [corners[0], corners[4]], + [corners[1], corners[5]], + [corners[2], corners[6]], + [corners[3], corners[7]], + ].map(([a, b]) => a!.clone().add(b!).multiplyScalar(0.5)) + const faceCenters = [ + new Vector3(center.x, min.y, center.z), + new Vector3(center.x, max.y, center.z), + new Vector3(min.x, center.y, center.z), + new Vector3(max.x, center.y, center.z), + new Vector3(center.x, center.y, min.z), + new Vector3(center.x, center.y, max.z), + ] + + return [...corners, ...edgeMidpoints, ...faceCenters, center].map((point, index) => { + const measurementPoint = worldToBuildingLocal([point.x, point.y, point.z], buildingId) + return { + attachment: createNodeBoundsAttachment(node.id, measurementPoint, buildingId ?? undefined), + label: + index < 8 ? 'Box corner' : index < 20 ? 'Box edge' : index < 26 ? 'Face center' : 'Center', + point: measurementPoint, + } + }) +} + +function nodeMeasurementAnchors( + node: AnyNode, + buildingId: AnyNodeId | null, +): MeasurementSnapAnchor[] { + return [...snapAnchorsFromDefinition(node), ...boundingBoxMeasurementAnchors(node, buildingId)] +} + +function resolveNodeMeasurementSnap( + event: NodeEvent, + point: MeasurementPoint, + buildingId: AnyNodeId | null, +): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + const anchors = [ + ...meshSnapCandidatesFromEvent(event).map((candidate) => { + const candidatePoint = measurementPointFromObjectLocalPoint( + event.object, + candidate.localPoint, + buildingId, + ) + return { + attachment: createNodeEventMeasurementAttachment(event, candidatePoint, buildingId), + kind: candidate.kind, + label: candidate.label, + point: candidatePoint, + priority: candidate.priority, + targetLine: candidate.localTargetLine + ? { + end: measurementPointFromObjectLocalPoint( + event.object, + candidate.localTargetLine.end, + buildingId, + ), + start: measurementPointFromObjectLocalPoint( + event.object, + candidate.localTargetLine.start, + buildingId, + ), + } + : undefined, + } + }), + ...nodeMeasurementAnchors(event.node, buildingId).map((anchor) => ({ + ...anchor, + priority: anchor.priority ?? 3, + })), + ].map((anchor) => attachEventMeasurementOwner(event, anchor)) + const enabledSnapKinds = useMeasurementTool.getState().enabledSnapKinds + const enabledAnchors = anchors.filter((anchor) => { + const kind = anchor.kind ?? measurementSnapKindFromLabel(anchor.label) + return enabledSnapKinds[kind] + }) + + if (enabledAnchors.length === 0) return { point, target: null } + + const maxDistanceSq = MEASUREMENT_SURFACE_SNAP_RADIUS * MEASUREMENT_SURFACE_SNAP_RADIUS + let closest: MeasurementSnapAnchor | null = null + let closestDistanceSq = Number.POSITIVE_INFINITY + let closestScore = Number.POSITIVE_INFINITY + + for (const anchor of enabledAnchors) { + const distanceSq = squaredMeasurementDistance(point, anchor.point) + if (distanceSq > maxDistanceSq) continue + const score = (anchor.priority ?? 10) * MEASUREMENT_NODE_SNAP_PRIORITY_BUCKET + distanceSq + if ( + score < closestScore || + (Math.abs(score - closestScore) < 1e-8 && (anchor.priority ?? 10) < (closest?.priority ?? 10)) + ) { + closest = anchor + closestDistanceSq = distanceSq + closestScore = score + } + } + + return { + point: closest?.point ?? point, + target: closest + ? { + kind: closest.kind ?? measurementSnapKindFromLabel(closest.label), + attachment: closest.attachment, + label: closest.label, + point: closest.point, + targetLine: closest.targetLine, + view: '3d', + } + : null, + } +} + +function resolveGridEditablePoint3D(event: GridEvent): { + point: MeasurementPoint + target: MeasurementSnapTarget | null +} { + const measurement = useMeasurementTool.getState() + const snap = resolveGridMeasurementSnap3D(measurementPointFromGridEvent(event)) + const drag = measurement.draggingSegmentEndpoint + if (!drag) return snap + + const segment = getResolvedMeasurementSegments().find((entry) => entry.id === drag.id) + const anchor = + drag.endpoint === 'start' + ? (segment?.end ?? null) + : drag.endpoint === 'end' + ? (segment?.start ?? null) + : null + const constrained = anchor ? resolveGridMeasurementConstraint3D(anchor, snap.point) : null + const point = constrained?.point ?? snap.point + + return { + point, + target: constrained?.target ?? snap.target, + } +} + +function polygonDraftPointsWithCursor(draft: MeasurementPolygonDraft): MeasurementPoint[] { + return draft.cursor ? [...draft.points, draft.cursor] : draft.points +} + +function updatePolygonMeasurementPreview3D(): void { + const measurement = useMeasurementTool.getState() + const draft = measurement.polygonDraft + if (draft?.view !== '3d') return + const points = polygonDraftPointsWithCursor(draft) + if (points.length < 3) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + return + } + + const { areaSquareMeters, labelPoint } = polygonAreaAndLabelPointFromMeasurements(points) + if (measurement.mode === 'area') { + measurement.setPreviewPerimeter(null) + measurement.setPreviewArea({ + id: 'measurement-area-preview', + areaSquareMeters, + boundaryPoints: points, + labelPoint, + view: '3d', + }) + return + } + + if (measurement.mode === 'perimeter') { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter({ + id: 'measurement-perimeter-preview', + boundaryPoints: points, + labelPoint, + lengthMeters: polygonPerimeterFromMeasurements(points), + view: '3d', + }) + } +} + +function handlePolygonGridClick3D(point: MeasurementPoint): boolean { + const measurement = useMeasurementTool.getState() + if (!(measurement.mode === 'area' || measurement.mode === 'perimeter')) return false + const draft = measurement.polygonDraft + if (draft?.view !== '3d') { + measurement.beginPolygon('3d', point) + return true + } + + const first = draft.points[0] + if (first && draft.points.length >= 3 && distanceBetweenMeasurements(first, point) < 0.25) { + measurement.commitPolygon() + return true + } + + measurement.addPolygonPoint(point) + updatePolygonMeasurementPreview3D() + return true +} + +export function handleMeasurementGridMove3D( + event: GridEvent, + canvas: HTMLCanvasElement, + shouldIgnoreGridEvent: () => boolean = () => false, +): void { + if (!isCanvasEvent(event, canvas)) return + if (shouldIgnoreGridEvent()) return + const measurement = useMeasurementTool.getState() + if (measurement.draggingSegmentEndpoint) { + const resolved = resolveGridEditablePoint3D(event) + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + resolved.point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (resolved.target?.attachment ?? null), + ) + measurement.setSnapTarget(resolved.target) + measurement.setCursor('3d', resolved.point) + return + } + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + const rawPoint = measurementPointFromGridEvent(event) + const snap = resolveGridMeasurementSnap3D(rawPoint) + const constrained = + measurement.draft?.view === '3d' + ? resolveGridMeasurementConstraint3D(measurement.draft.start, snap.point) + : null + const point = constrained?.point ?? snap.point + measurement.setSnapTarget(constrained?.target ?? snap.target) + measurement.setCursor('3d', point) + if (measurement.angleDraft) { + measurement.updateAngle(point) + return + } + if (measurement.polygonDraft?.view === '3d') { + measurement.updatePolygon(point) + updatePolygonMeasurementPreview3D() + return + } + if (!measurement.draft) return + measurement.update(point) +} + +export function handleMeasurementGridClick3D( + event: GridEvent, + canvas: HTMLCanvasElement, + shouldIgnoreGridEvent: () => boolean = () => false, +): void { + if (!isCanvasEvent(event, canvas)) return + if (shouldIgnoreGridEvent()) return + const measurement = useMeasurementTool.getState() + if (measurement.consumeSuppressedPlacementClick()) return + if (measurement.draggingSegmentEndpoint) { + const resolved = resolveGridEditablePoint3D(event) + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + resolved.point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (resolved.target?.attachment ?? null), + ) + measurement.setSnapTarget(resolved.target) + measurement.setCursor('3d', resolved.point) + measurement.endSegmentEndpointDrag() + return + } + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + const rawPoint = measurementPointFromGridEvent(event) + const snap = resolveGridMeasurementSnap3D(rawPoint) + const constrained = + measurement.draft?.view === '3d' + ? resolveGridMeasurementConstraint3D(measurement.draft.start, snap.point) + : null + const point = constrained?.point ?? snap.point + const target = constrained?.target ?? snap.target + measurement.setSnapTarget(target) + measurement.setCursor('3d', point) + if (measurement.mode === 'angle' || measurement.angleDraft) { + if (measurement.angleDraft) { + measurement.commitAngle(point) + } else { + measurement.beginAngle('3d', point, target?.targetLine ?? null) + } + return + } + if (handlePolygonGridClick3D(point)) return + if (measurement.mode !== 'distance') return + if (measurement.draft) { + measurement.commit(point, undefined, target?.attachment) + } else { + measurement.begin('3d', point, undefined, target?.attachment) + } +} + +export function handleMeasurementNodeClick3D( + event: NodeEvent, + buildingId: AnyNodeId | null = null, +): void { + if (useMeasurementTool.getState().consumeSuppressedPlacementClick()) { + event.stopPropagation() + return + } + if (isIgnoredMeasurementNode(event.node)) { + clearTransientMeasurementHover() + return + } + + event.stopPropagation() + + const measurement = useMeasurementTool.getState() + const rawPoint = measurementPointFromNodeEvent(event, buildingId) + const snap = resolveNodeMeasurementSnap(event, rawPoint, buildingId) + const surfaceNormal = measurementNormalFromNodeEvent(event, buildingId) + const point = snap.point + const pointAttachment = + snap.target?.attachment ?? createNodeEventMeasurementAttachment(event, point, buildingId) + const quickMeasure = Boolean( + event.nativeEvent.altKey || event.nativeEvent.ctrlKey || event.nativeEvent.metaKey, + ) + measurement.setCursor('3d', point) + measurement.setSnapTarget(snap.target) + if (measurement.draggingSegmentEndpoint) { + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (pointAttachment ?? null), + ) + measurement.endSegmentEndpointDrag() + return + } + if (measurement.mode === 'angle' || measurement.angleDraft) { + if (measurement.angleDraft) { + measurement.commitAngle(point) + } else { + measurement.beginAngle('3d', point, snap.target?.targetLine ?? null) + } + return + } + + if (!measurement.draft) { + if (quickMeasure || measurement.mode === 'perimeter') { + const perimeter = surfacePerimeterMeasurementFromNode(event.node) + if (perimeter) { + measurement.addPerimeter( + '3d', + perimeter.labelPoint, + perimeter.lengthMeters, + perimeter.boundaryPoints, + ) + return + } + } + + if (measurement.mode === 'area') { + const area = surfaceAreaMeasurementFromNode(event.node) + if (area) { + measurement.addArea('3d', area.labelPoint, area.areaSquareMeters, area.boundaryPoints) + return + } + } + + if (measurement.mode === 'distance' && quickMeasure) { + const segment = directLengthSegmentFromNode(event.node, buildingId, rawPoint, surfaceNormal) + if (segment) { + const tracksEndpointDistance = + Math.abs( + distanceBetweenMeasurements(segment.start, segment.end) - + segment.measuredDistanceMeters, + ) < 1e-4 + measurement.addSegment( + '3d', + segment.start, + segment.end, + segment.measuredDistanceMeters, + tracksEndpointDistance + ? createNodeEventMeasurementAttachment(event, segment.start, buildingId) + : undefined, + tracksEndpointDistance + ? createNodeEventMeasurementAttachment(event, segment.end, buildingId) + : undefined, + ) + return + } + } + } + + if (measurement.mode !== 'distance') return + + if (measurement.draft) { + const surfaceDistance = measurement.enabledSnapKinds.surface + ? resolveSurfaceDistanceEndpoint( + measurement.draft.start, + measurement.draft.surfaceNormal, + point, + surfaceNormal, + ) + : null + if (surfaceDistance) { + measurement.setSnapTarget({ + kind: 'surface', + label: 'Surface distance', + point: surfaceDistance.end, + view: '3d', + }) + measurement.commit(surfaceDistance.end, surfaceDistance.measuredDistanceMeters) + } else { + measurement.commit(point, undefined, pointAttachment) + } + } else { + measurement.begin('3d', point, surfaceNormal ?? undefined, pointAttachment) + } +} + +export function handleMeasurementNodeMove3D( + event: NodeEvent, + buildingId: AnyNodeId | null = null, +): void { + if (isIgnoredMeasurementNode(event.node)) { + clearTransientMeasurementHover() + return + } + + event.stopPropagation() + + const rawPoint = measurementPointFromNodeEvent(event, buildingId) + const snap = resolveNodeMeasurementSnap(event, rawPoint, buildingId) + const measurement = useMeasurementTool.getState() + const surfaceNormal = measurementNormalFromNodeEvent(event, buildingId) + const snappedPoint = snap.point + const surfaceDistance = + measurement.enabledSnapKinds.surface && measurement.draft?.view === '3d' + ? resolveSurfaceDistanceEndpoint( + measurement.draft.start, + measurement.draft.surfaceNormal, + snappedPoint, + surfaceNormal, + ) + : null + const point = surfaceDistance?.end ?? snappedPoint + if (measurement.draggingSegmentEndpoint) { + measurement.updateSegmentEndpoint( + measurement.draggingSegmentEndpoint.id, + measurement.draggingSegmentEndpoint.endpoint, + point, + event.nativeEvent.altKey, + event.nativeEvent.altKey ? null : (snap.target?.attachment ?? null), + ) + measurement.setCursor('3d', point) + measurement.setSnapTarget(snap.target) + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + return + } + measurement.setCursor('3d', point) + measurement.setSnapTarget( + surfaceDistance + ? { kind: 'surface', label: 'Surface distance', point: surfaceDistance.end, view: '3d' } + : snap.target, + ) + if (measurement.angleDraft) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + measurement.updateAngle(point) + return + } + if (measurement.draft) { + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewSegment(null) + measurement.update(point) + return + } + if (measurement.polygonDraft?.view === '3d') { + measurement.setPreviewSegment(null) + measurement.updatePolygon(point) + updatePolygonMeasurementPreview3D() + return + } + + if (measurement.mode === 'area') { + const area = surfaceAreaMeasurementFromNode(event.node) + measurement.setPreviewSegment(null) + measurement.setPreviewPerimeter(null) + measurement.setPreviewArea( + area + ? { + id: 'measurement-area-preview', + areaSquareMeters: area.areaSquareMeters, + boundaryPoints: area.boundaryPoints, + labelPoint: area.labelPoint, + view: '3d', + } + : null, + ) + return + } + + if (measurement.mode === 'perimeter') { + const perimeter = surfacePerimeterMeasurementFromNode(event.node) + measurement.setPreviewSegment(null) + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter( + perimeter + ? { + id: 'measurement-perimeter-preview', + boundaryPoints: perimeter.boundaryPoints, + labelPoint: perimeter.labelPoint, + lengthMeters: perimeter.lengthMeters, + view: '3d', + } + : null, + ) + return + } + + measurement.setPreviewArea(null) + measurement.setPreviewPerimeter(null) + const preview = + measurement.mode === 'distance' + ? directLengthSegmentFromNode(event.node, buildingId, rawPoint, surfaceNormal) + : null + const previewTarget = preview ? snapTargetFromPreviewSegment3D(rawPoint, preview) : null + if (previewTarget && !snap.target) { + measurement.setCursor('3d', previewTarget.point) + measurement.setSnapTarget(previewTarget) + } + measurement.setPreviewSegment( + preview + ? { + id: 'measurement-preview', + start: preview.start, + end: preview.end, + measuredDistanceMeters: preview.measuredDistanceMeters, + view: '3d', + } + : null, + ) +} + +function MeasurementCursor3D({ + appearance, + point, +}: { + appearance: MeasurementAppearance + point: MeasurementPoint +}) { + const material = appearance === 'dark' ? darkDraftMeasurementMaterial : draftMeasurementMaterial + + return ( + + + + ) +} + +function MeasurementSnapTarget3D({ + appearance, + target, +}: { + appearance: MeasurementAppearance + target: MeasurementSnapTarget +}) { + const dotMaterial = + appearance === 'dark' ? darkDraftMeasurementMaterial : draftMeasurementMaterial + const kind = target.kind ?? 'vertex' + const targetLine = target.guideLine ?? target.targetLine + const targetLineHighlight = targetLine + ? measurementSnapLineHighlight3D(targetLine, target.point, kind === 'guide' ? 1.6 : 0.9) + : null + const tick = + targetLineHighlight && kind === 'edge' + ? measurementSnapEdgeTick3D(targetLineHighlight, MEASUREMENT_CURSOR_SIZE * 0.82) + : null + const ringScale = + kind === 'grid' + ? MEASUREMENT_CURSOR_SIZE * 1.14 + : kind === 'measurement' + ? MEASUREMENT_CURSOR_SIZE * 1.62 + : MEASUREMENT_CURSOR_SIZE * 1.48 + + return ( + + {targetLineHighlight ? ( + + ) : null} + {tick ? ( + + ) : null} + + {kind === 'measurement' ? ( + + ) : null} + + + + + ) +} + +function measurementSnapLineHighlight3D( + line: { end: MeasurementPoint; start: MeasurementPoint }, + point: MeasurementPoint, + maxLength: number, +): { end: MeasurementPoint; start: MeasurementPoint } | null { + const direction = new Vector3( + line.end[0] - line.start[0], + line.end[1] - line.start[1], + line.end[2] - line.start[2], + ) + const length = direction.length() + if (length < 1e-6) return null + const half = Math.min(length, maxLength) / 2 + direction.normalize() + const center = new Vector3(...point) + const start = center.clone().add(direction.clone().multiplyScalar(-half)) + const end = center.clone().add(direction.clone().multiplyScalar(half)) + return { + start: [start.x, start.y, start.z], + end: [end.x, end.y, end.z], + } +} + +function measurementSnapEdgeTick3D( + line: { end: MeasurementPoint; start: MeasurementPoint }, + halfLength: number, +): { end: MeasurementPoint; start: MeasurementPoint } | null { + const direction = new Vector3( + line.end[0] - line.start[0], + line.end[1] - line.start[1], + line.end[2] - line.start[2], + ) + if (direction.lengthSq() < 1e-8) return null + direction.normalize() + const normal = new Vector3(0, 1, 0).cross(direction) + if (normal.lengthSq() < 1e-8) normal.set(1, 0, 0) + normal.normalize() + const center = new Vector3( + (line.start[0] + line.end[0]) / 2, + (line.start[1] + line.end[1]) / 2, + (line.start[2] + line.end[2]) / 2, + ) + const start = center.clone().add(normal.clone().multiplyScalar(-halfLength)) + const end = center.clone().add(normal.clone().multiplyScalar(halfLength)) + return { + start: [start.x, start.y, start.z], + end: [end.x, end.y, end.z], + } +} + +function selectMeasurementMaterial({ + draft, + isDark, + isSelected, +}: { + draft?: boolean + isDark: boolean + isSelected?: boolean +}) { + if (draft) return isDark ? darkDraftMeasurementMaterial : draftMeasurementMaterial + if (isSelected) return isDark ? darkMeasurementMaterial : measurementMaterial + return isDark ? darkMutedMeasurementMaterial : mutedMeasurementMaterial +} + +function MeasurementArea3D({ + appearance, + area, + displayPrecision, + isSelected, + onSelect, + unit, +}: { + appearance: MeasurementAppearance + area: MeasurementArea + displayPrecision: ReturnType['displayPrecision'] + isSelected: boolean + onSelect?: (id: string, event: PointerEvent) => void + unit: LinearUnit +}) { + const isDark = appearance === 'dark' + const material = selectMeasurementMaterial({ + draft: area.id.includes('preview'), + isDark, + isSelected, + }) + + return ( + + + + onSelect(area.id, event) : undefined} + > + {formatAreaMeasurement(area.areaSquareMeters, unit, { precision: displayPrecision })} + + + + ) +} + +function MeasurementBoundary3D({ + extendBy = 0, + id, + material, + points, + width, +}: { + extendBy?: number + id: string + material: MeshBasicNodeMaterial + points?: MeasurementPoint[] + width: number +}) { + if (!points || points.length < 3) return null + + return ( + + {points.map((point, index) => { + const next = points[(index + 1) % points.length] + if (!next) return null + const segment = extendedMeasurementBoundarySegment(point, next, extendBy) + return ( + + ) + })} + + ) +} + +function extendedMeasurementBoundarySegment( + start: MeasurementPoint, + end: MeasurementPoint, + extendBy: number, +): { end: MeasurementPoint; start: MeasurementPoint } { + if (extendBy <= 1e-6) return { end, start } + const direction = new Vector3(end[0] - start[0], end[1] - start[1], end[2] - start[2]) + if (direction.lengthSq() < 1e-8) return { end, start } + direction.normalize().multiplyScalar(extendBy) + return { + start: [start[0] - direction.x, start[1] - direction.y, start[2] - direction.z], + end: [end[0] + direction.x, end[1] + direction.y, end[2] + direction.z], + } +} + +function MeasurementPerimeter3D({ + appearance, + displayPrecision, + isSelected, + onSelect, + perimeter, + unit, +}: { + appearance: MeasurementAppearance + displayPrecision: ReturnType['displayPrecision'] + isSelected: boolean + onSelect?: (id: string, event: PointerEvent) => void + perimeter: MeasurementPerimeter + unit: LinearUnit +}) { + const isDark = appearance === 'dark' + const material = selectMeasurementMaterial({ + draft: perimeter.id.includes('preview'), + isDark, + isSelected, + }) + const boundaryPoints = perimeter.boundaryPoints + + return ( + + + + onSelect(perimeter.id, event) : undefined} + > + {`P ${formatLinearMeasurement(perimeter.lengthMeters, unit, { precision: displayPrecision })}`} + + + + ) +} + +function MeasurementValueLabel({ + children, + isSelected = true, + onPointerDown, +}: { + children: ReactNode + isSelected?: boolean + onPointerDown?: (event: PointerEvent) => void +}) { + return ( + + {children} + + ) +} + +function MeasurementBar3D({ + end, + material, + start, + width = MEASUREMENT_LINE_WIDTH, +}: { + end: Vector3 + material: MeshBasicNodeMaterial + start: Vector3 + width?: number +}) { + const segment = useMemo(() => { + const direction = end.clone().sub(start) + const length = direction.length() + if (!Number.isFinite(length) || length < 1e-4) return null + + return { + length, + position: start.clone().add(end).multiplyScalar(0.5), + quaternion: new Quaternion().setFromUnitVectors(MEASUREMENT_BAR_AXIS, direction.normalize()), + } + }, [end, start]) + + if (!segment) return null + + return ( + + ) +} + +type MeasurementLabelLayout3D = { + id: string + labelPosition: Vector3 + tickDirection: Vector3 +} + +export function staggerMeasurementLabelLayouts3D( + layouts: T[], +): T[] { + const seen = new Map() + return layouts.map((layout) => { + const key = `${Math.round(layout.labelPosition.x / MEASUREMENT_LABEL_COLLISION_CELL)}:${Math.round( + layout.labelPosition.y / MEASUREMENT_LABEL_COLLISION_CELL, + )}:${Math.round(layout.labelPosition.z / MEASUREMENT_LABEL_COLLISION_CELL)}` + const index = seen.get(key) ?? 0 + seen.set(key, index + 1) + if (index === 0) return layout + + const direction = index % 2 === 0 ? -1 : 1 + const magnitude = Math.ceil(index / 2) * MEASUREMENT_LABEL_STAGGER_STEP + return { + ...layout, + labelPosition: layout.labelPosition.clone().add( + layout.tickDirection + .clone() + .normalize() + .multiplyScalar(direction * magnitude), + ), + } + }) +} + +function measurementLineLayout3D(segment: Pick) { + const start = new Vector3(...segment.start) + const end = new Vector3(...segment.end) + const direction = end.clone().sub(start) + const length = direction.length() + const midpoint = start.clone().add(end).multiplyScalar(0.5) + const unitDirection = length > 1e-4 ? direction.clone().normalize() : new Vector3(1, 0, 0) + const tickDirection = + Math.abs(unitDirection.dot(new Vector3(0, 1, 0))) > 0.94 + ? new Vector3(1, 0, 0) + : new Vector3(0, 1, 0) + const tickOffset = tickDirection.clone().multiplyScalar(MEASUREMENT_END_TICK / 2) + + return { + end, + id: segment.id, + labelPosition: midpoint + .clone() + .add(tickDirection.clone().multiplyScalar(MEASUREMENT_END_TICK / 2 + MEASUREMENT_LABEL_LIFT)), + start, + tickDirection, + tickEndA: end.clone().add(tickOffset), + tickEndB: end.clone().sub(tickOffset), + tickStartA: start.clone().add(tickOffset), + tickStartB: start.clone().sub(tickOffset), + } +} + +export function getMeasurementAngleLayout3D(angle: MeasurementAngle) { + const first = new Vector3(...angle.first) + const vertex = new Vector3(...angle.vertex) + const second = new Vector3(...angle.second) + const firstDirection = first.clone().sub(vertex) + const secondDirection = second.clone().sub(vertex) + const firstLength = firstDirection.length() + const secondLength = secondDirection.length() + if (firstLength < 1e-4 || secondLength < 1e-4) return null + + firstDirection.normalize() + secondDirection.normalize() + const radians = firstDirection.angleTo(secondDirection) + const radius = Math.max( + MEASUREMENT_ANGLE_ARC_MIN_RADIUS, + Math.min(MEASUREMENT_ANGLE_ARC_MAX_RADIUS, firstLength * 0.35, secondLength * 0.35), + ) + if (radians < 1e-4) { + return { + arcRadials: [], + arcSegments: [], + labelPosition: vertex + .clone() + .add(firstDirection.clone().multiplyScalar(radius + 0.24)) + .add(new Vector3(0, 0.18, 0)), + } + } + + const normal = firstDirection.clone().cross(secondDirection) + if (normal.lengthSq() < 1e-8) return null + normal.normalize() + + const sampleCount = Math.max(8, Math.ceil((radians / Math.PI) * MEASUREMENT_ANGLE_ARC_SEGMENTS)) + const points = Array.from({ length: sampleCount + 1 }, (_, index) => { + const t = index / sampleCount + return firstDirection + .clone() + .applyAxisAngle(normal, radians * t) + .multiplyScalar(radius) + .add(vertex) + }) + const arcSegments = points.slice(1).map((point, index) => ({ + end: point, + start: points[index]!, + })) + const arcRadials = [ + { end: points[0]!, start: vertex }, + { end: points[points.length - 1]!, start: vertex }, + ] + const labelDirection = firstDirection.clone().applyAxisAngle(normal, radians / 2) + + return { + arcRadials, + arcSegments, + labelPosition: vertex.clone().add(labelDirection.multiplyScalar(radius + 0.24)), + } +} + +function MeasurementLine3D({ + appearance, + displayPrecision, + draft = false, + isSelected = true, + labelPosition, + onSelect, + segment, + showLabel = true, + unit, +}: { + appearance: MeasurementAppearance + displayPrecision: ReturnType['displayPrecision'] + draft?: boolean + isSelected?: boolean + labelPosition?: Vector3 + onSelect?: (id: string, event: PointerEvent) => void + segment: Pick + showLabel?: boolean + unit: LinearUnit +}) { + const isDark = appearance === 'dark' + const distance = + segment.measuredDistanceMeters ?? distanceBetweenMeasurements(segment.start, segment.end) + const lineLayout = useMemo(() => measurementLineLayout3D(segment), [segment]) + + if (distance < 1e-4) return null + + const material = selectMeasurementMaterial({ draft, isDark, isSelected }) + + return ( + + + + + {showLabel ? ( + + onSelect(segment.id, event) : undefined} + > + {formatLinearMeasurement(distance, unit, { precision: displayPrecision })} + + + ) : null} + + ) +} + +function MeasurementEndpointHandles3D({ + appearance, + draggingEndpoint, + segment, +}: { + appearance: MeasurementAppearance + draggingEndpoint: ReturnType['draggingSegmentEndpoint'] + segment: Pick +}) { + const isDark = appearance === 'dark' + const handles: Array<{ endpoint: MeasurementSegmentEndpoint; point: MeasurementPoint }> = [ + { endpoint: 'start', point: segment.start }, + { endpoint: 'end', point: segment.end }, + ] + const material = selectMeasurementMaterial({ draft: false, isDark, isSelected: true }) + const activeMaterial = selectMeasurementMaterial({ draft: true, isDark, isSelected: true }) + + return ( + + {handles.map(({ endpoint, point }) => { + const activeHandle = isDraggingMeasurementEndpoint(draggingEndpoint, segment.id, endpoint) + return ( + + ) + })} + + ) +} + +function MeasurementEndpointHandle3D({ + active, + endpoint, + material, + point, + segmentId, +}: { + active: boolean + endpoint: MeasurementSegmentEndpoint + material: MeshBasicNodeMaterial + point: MeasurementPoint + segmentId: string +}) { + const { camera } = useThree() + const billboardRef = useRef(null) + const parentWorldQuaternionRef = useRef(new Quaternion()) + const handleSize = active + ? MEASUREMENT_ENDPOINT_HANDLE_SIZE * 1.35 + : MEASUREMENT_ENDPOINT_HANDLE_SIZE + const ringSize = handleSize * 1.16 + + useFrame(() => { + const billboard = billboardRef.current + if (!billboard) return + const parent = billboard.parent + if (parent) { + parent.getWorldQuaternion(parentWorldQuaternionRef.current) + billboard.quaternion + .copy(parentWorldQuaternionRef.current.invert()) + .multiply(camera.quaternion) + return + } + billboard.quaternion.copy(camera.quaternion) + }) + + return ( + + { + event.stopPropagation() + useMeasurementTool.getState().startSegmentEndpointDrag(segmentId, endpoint) + }} + renderOrder={1003} + scale={[ + MEASUREMENT_ENDPOINT_HANDLE_HIT_SIZE, + MEASUREMENT_ENDPOINT_HANDLE_HIT_SIZE, + MEASUREMENT_ENDPOINT_HANDLE_HIT_SIZE, + ]} + /> + + + + + + ) +} + +function MeasurementAngle3D({ + appearance, + angle, + displayPrecision, + draft = false, + isSelected, + onSelect, + unit, +}: { + appearance: MeasurementAppearance + angle: MeasurementAngle + displayPrecision: ReturnType['displayPrecision'] + draft?: boolean + isSelected: boolean + onSelect?: (id: string, event: PointerEvent) => void + unit: LinearUnit +}) { + const angleLayout = useMemo(() => getMeasurementAngleLayout3D(angle), [angle]) + const isDark = appearance === 'dark' + const material = selectMeasurementMaterial({ draft, isDark, isSelected }) + + return ( + + + + {angleLayout?.arcSegments.map((segment, index) => ( + + ))} + {angleLayout?.arcRadials.map((segment, index) => ( + + ))} + {angleLayout ? ( + + onSelect(angle.id, event) : undefined} + > + {formatAngleMeasurement( + angleBetweenMeasurements(angle.first, angle.vertex, angle.second), + { precision: displayPrecision }, + )} + + + ) : null} + + ) +} + +export function MeasurementTool({ + appearance, + buildingId, + interactive = true, + unit, +}: { + appearance: MeasurementAppearance + buildingId: AnyNodeId | null + interactive?: boolean + unit: LinearUnit +}) { + const canvas = useThree((state) => state.gl.domElement) + const storedSegments = useMeasurementTool((state) => state.segments) + const reactivelyResolvedSegments = useResolvedMeasurementSegments(storedSegments) + const hasRenderedBoundsAttachment = useMemo( + () => + storedSegments.some( + (segment) => + segment.startAttachment?.feature.kind === 'node-bounds' || + segment.endAttachment?.feature.kind === 'node-bounds', + ), + [storedSegments], + ) + const [segments, setSegments] = useState(reactivelyResolvedSegments) + const segmentsRef = useRef(reactivelyResolvedSegments) + const areas = useMeasurementTool((state) => state.areas) + const perimeters = useMeasurementTool((state) => state.perimeters) + const angles = useMeasurementTool((state) => state.angles) + const draft = useMeasurementTool((state) => state.draft) + const polygonDraft = useMeasurementTool((state) => state.polygonDraft) + const previewArea = useMeasurementTool((state) => state.previewArea) + const previewPerimeter = useMeasurementTool((state) => state.previewPerimeter) + const previewSegment = useMeasurementTool((state) => state.previewSegment) + const angleDraft = useMeasurementTool((state) => state.angleDraft) + const cursor = useMeasurementTool((state) => state.cursor) + const snapTarget = useMeasurementTool((state) => state.snapTarget) + const displayPrecision = useMeasurementTool((state) => state.displayPrecision) + const selectedId = useMeasurementTool((state) => state.selectedId) + const draggingSegmentEndpoint = useMeasurementTool((state) => state.draggingSegmentEndpoint) + const lastSurfaceEventAtRef = useRef(0) + + useEffect(() => { + if (sameResolvedMeasurementSegments(segmentsRef.current, reactivelyResolvedSegments)) return + segmentsRef.current = reactivelyResolvedSegments + setSegments(reactivelyResolvedSegments) + }, [reactivelyResolvedSegments]) + + useFrame(() => { + if (!hasRenderedBoundsAttachment) return + const refreshed = refreshRenderedBoundsMeasurementSegments( + segmentsRef.current, + storedSegments, + useScene.getState().nodes, + useLiveTransforms.getState().transforms, + useLiveNodeOverrides.getState().overrides, + ) + if (refreshed === segmentsRef.current) return + segmentsRef.current = refreshed + setSegments(refreshed) + }) + + useEffect(() => { + if (!interactive) return + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'measurement' }) + return () => { + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'measurement') + useMeasurementTool.getState().cancelDraft() + useMeasurementTool.getState().setCursor('3d', null) + } + }, [interactive]) + + useEffect(() => { + if (!interactive) return + const kinds = measurableNodeKinds() + const noteSurfaceEvent = () => { + lastSurfaceEventAtRef.current = performance.now() + } + const shouldIgnoreGridEvent = () => + performance.now() - lastSurfaceEventAtRef.current < SURFACE_EVENT_SUPPRESSION_MS + + const handleMove = (event: GridEvent) => { + handleMeasurementGridMove3D(event, canvas, shouldIgnoreGridEvent) + } + + const handleClick = (event: GridEvent) => { + handleMeasurementGridClick3D(event, canvas, shouldIgnoreGridEvent) + } + const handlePointerUp = (event: globalThis.PointerEvent) => { + const measurement = useMeasurementTool.getState() + if (!measurement.draggingSegmentEndpoint) return + measurement.endSegmentEndpointDrag({ + detachLinkedEndpoints: event.altKey, + suppressNextClick: true, + }) + } + + const handleNodeMove = (event: NodeEvent) => { + if (isIgnoredMeasurementNode(event.node)) { + handleMeasurementNodeMove3D(event, buildingId) + return + } + noteSurfaceEvent() + handleMeasurementNodeMove3D(event, buildingId) + } + + const handleNodeClick = (event: NodeEvent) => { + if (isIgnoredMeasurementNode(event.node)) { + handleMeasurementNodeClick3D(event, buildingId) + return + } + noteSurfaceEvent() + handleMeasurementNodeClick3D(event, buildingId) + } + + const handleCancel = () => { + const measurement = useMeasurementTool.getState() + if ( + !measurement.cursor && + !measurement.angleDraft && + !measurement.draft && + !measurement.draggingSegmentEndpoint && + !measurement.polygonDraft && + !measurement.previewArea && + !measurement.previewPerimeter && + !measurement.previewSegment && + !measurement.snapTarget + ) + return + markToolCancelConsumed() + measurement.cancelDraft() + } + + emitter.on('grid:move', handleMove) + emitter.on('grid:click', handleClick) + emitter.on('tool:cancel', handleCancel) + window.addEventListener('pointerup', handlePointerUp, true) + for (const kind of kinds) { + emitter.on(`${kind}:move` as never, handleNodeMove as never) + emitter.on(`${kind}:click` as never, handleNodeClick as never) + } + return () => { + emitter.off('grid:move', handleMove) + emitter.off('grid:click', handleClick) + emitter.off('tool:cancel', handleCancel) + window.removeEventListener('pointerup', handlePointerUp, true) + for (const kind of kinds) { + emitter.off(`${kind}:move` as never, handleNodeMove as never) + emitter.off(`${kind}:click` as never, handleNodeClick as never) + } + } + }, [buildingId, canvas, interactive]) + + useEffect(() => { + if (!interactive) return + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.key === 'Delete' || event.key === 'Backspace')) return + const target = event.target instanceof HTMLElement ? event.target : null + if ( + target?.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement + ) + return + const measurement = useMeasurementTool.getState() + if (!measurement.selectedId) return + event.preventDefault() + event.stopPropagation() + measurement.deleteSelected() + } + + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) + }, [interactive]) + + const handleSelectMeasurement = (id: string, event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + useMeasurementTool.getState().selectMeasurement(id) + } + const draftAngle = + interactive && angleDraft?.view === '3d' && angleDraft.vertex && angleDraft.second + ? { + first: angleDraft.first, + id: 'measurement-angle-draft', + second: angleDraft.second, + vertex: angleDraft.vertex, + view: angleDraft.view, + } + : null + const displaySelectedId = interactive ? selectedId : null + const selectedSegment = displaySelectedId + ? segments.find((segment) => segment.id === displaySelectedId) + : null + const segmentLabelPositions = useMemo(() => { + const layouts = staggerMeasurementLabelLayouts3D( + segments.map((segment) => measurementLineLayout3D(segment)), + ) + return new Map(layouts.map((layout) => [layout.id, layout.labelPosition])) + }, [segments]) + const polygonDraftSegments = + interactive && polygonDraft?.view === '3d' + ? polygonDraftPointsWithCursor(polygonDraft).flatMap((point, index, points) => { + const next = points[index + 1] ?? (points.length >= 3 ? points[0] : null) + return next + ? [ + { + id: `measurement-polygon-draft-${index}`, + start: point, + end: next, + }, + ] + : [] + }) + : [] + + return ( + <> + {segments.map((segment) => ( + + ))} + {interactive && draft?.view === '3d' && draft.end ? ( + + ) : null} + {interactive && !draft && previewSegment?.view === '3d' ? ( + + ) : null} + {polygonDraftSegments.map((segment) => ( + + ))} + {areas.map((area) => ( + + ))} + {interactive && previewArea?.view === '3d' ? ( + + ) : null} + {perimeters.map((perimeter) => ( + + ))} + {interactive && previewPerimeter?.view === '3d' ? ( + + ) : null} + {angles.map((angle) => ( + + ))} + {draftAngle ? ( + + ) : null} + {interactive && snapTarget?.view === '3d' ? ( + + ) : null} + {interactive && selectedSegment ? ( + + ) : null} + {interactive && cursor?.view === '3d' ? ( + + ) : null} + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 12f3fec24..54fce18eb 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -8,7 +8,7 @@ import { useScene, type WallNode, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useMemo } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { @@ -25,6 +25,7 @@ import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' +import { MeasurementTool } from './measurement/measurement-tool' import { RoofTool } from './roof/roof-tool' import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { FacingPoseIndicator } from './shared/facing-pose-indicator' @@ -67,6 +68,7 @@ export const ToolManager: React.FC = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) + const showMeasurements = useEditor((state) => state.showMeasurements) const movingNode = useMovingNode() const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) const endpointReshape = useEndpointReshape() @@ -102,6 +104,8 @@ export const ToolManager: React.FC = () => { const selectedIds = useViewer((state) => state.selection.selectedIds) const buildingId = useViewer((state) => state.selection.buildingId) const activeLevelId = useViewer((state) => state.selection.levelId) + const unit = useViewer((state) => state.unit) + const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) const setSelection = useViewer((state) => state.setSelection) const nodes = useScene((state) => state.nodes) @@ -332,6 +336,14 @@ export const ToolManager: React.FC = () => { onPlaced={handlePlacedElevatorSelected} /> )} + {showMeasurements || (!movingNode && showBuildTool && tool === 'measurement') ? ( + + ) : null} {!movingNode && BuildToolComponent && tool !== 'elevator' ? : null} {/* Figma-style alignment guides published by the move / placement tools above. Lives inside the building-local group so the diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index c399afbac..845956fde 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -16,6 +16,7 @@ export const tools: ToolConfig[] = [ { id: 'door', iconSrc: '/icons/door.webp', label: 'Door' }, { id: 'window', iconSrc: '/icons/window.webp', label: 'Window' }, { id: 'stair', iconSrc: '/icons/stairs.webp', label: 'Stairs' }, + { id: 'measurement', iconSrc: '/icons/blueprint.webp', label: 'Measure' }, { id: 'roof', iconSrc: '/icons/roof.webp', label: 'Gable Roof' }, { id: 'fence', iconSrc: '/icons/fence.webp', label: 'Fence' }, { id: 'column', iconSrc: '/icons/column.webp', label: 'Column' }, diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index e3023323c..b005de7c9 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -9,7 +9,16 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2, Waypoints } from 'lucide-react' +import { + Check, + ChevronDown, + Eye, + EyeOff, + Layers2, + Plus, + Trash2, + Waypoints, +} from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { getLevelDisplayName } from '@pascal-app/core' diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 967602bf1..9d3f67343 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -27,6 +27,7 @@ import { PencilLine, Plus, Redo2, + Ruler, Sparkles, Square, SquareStack, @@ -41,6 +42,7 @@ import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' import { useCommandRegistry } from '../../../store/use-command-registry' import type { StructureTool } from '../../../store/use-editor' import useEditor from '../../../store/use-editor' +import { useMeasurementTool } from '../../../store/use-measurement-tool' import { useCommandPalette } from './index' export function EditorCommands() { @@ -131,6 +133,40 @@ export function EditorCommands() { keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'], execute: () => activateTool('stair'), }, + { + id: 'editor.tool.measurement', + label: 'Measure Tool', + group: 'Scene', + icon: , + keywords: ['measure', 'distance', 'ruler', 'length'], + execute: () => activateTool('measurement'), + }, + { + id: 'editor.measurement.clear', + label: 'Clear Measurements', + group: 'Scene', + icon: , + keywords: ['measure', 'distance', 'ruler', 'length', 'area', 'delete', 'remove'], + when: () => { + const measurement = useMeasurementTool.getState() + return ( + measurement.segments.length > 0 || + measurement.areas.length > 0 || + measurement.perimeters.length > 0 || + measurement.angles.length > 0 || + measurement.draft !== null || + measurement.angleDraft !== null || + measurement.polygonDraft !== null || + measurement.previewArea !== null || + measurement.previewPerimeter !== null || + measurement.previewSegment !== null + ) + }, + execute: () => + run(() => { + useMeasurementTool.getState().clear() + }), + }, { id: 'editor.tool.zone', label: 'Zone Tool', diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 402b6d8c4..52d99382a 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -15,6 +15,7 @@ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL, + resolveMeasurementHelpHints, resolveRotateHandleHelpHints, resolveSelectModeHelpHints, } from '../../../lib/contextual-help' @@ -28,6 +29,7 @@ import useInteractionScope, { useActiveHandleDrag, useMovingNode, } from '../../../store/use-interaction-scope' +import { useMeasurementTool } from '../../../store/use-measurement-tool' import { BuildingHelper } from './building-helper' import { ContextualHelperPanel } from './contextual-helper-panel' import { ItemHelper } from './item-helper' @@ -54,12 +56,14 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] { } type ActiveModifierKeys = { + alt: boolean command: boolean shift: boolean } function useActiveModifierKeys(): ActiveModifierKeys { const [modifiers, setModifiers] = useState({ + alt: false, command: false, shift: false, }) @@ -68,6 +72,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { const updateModifiers = (event: KeyboardEvent) => { const isKeyDown = event.type === 'keydown' setModifiers({ + alt: event.altKey || (isKeyDown && event.key === 'Alt'), command: event.metaKey || event.ctrlKey || @@ -76,7 +81,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { }) } const clearModifiers = () => { - setModifiers({ command: false, shift: false }) + setModifiers({ alt: false, command: false, shift: false }) } window.addEventListener('keydown', updateModifiers) @@ -100,6 +105,10 @@ export function HelperManager() { const movingNode = useMovingNode() const activeHandleDrag = useActiveHandleDrag() const selectedIds = useViewer((s) => s.selection.selectedIds) + const measurementMode = useMeasurementTool((s) => s.mode) + const measurementDraft = useMeasurementTool((s) => s.draft) + const measurementAngleDraft = useMeasurementTool((s) => s.angleDraft) + const measurementPolygonDraft = useMeasurementTool((s) => s.polygonDraft) const isMobile = useIsMobile() const modifiers = useActiveModifierKeys() const selectedNodes = useScene( @@ -208,6 +217,21 @@ export function HelperManager() { return } + if (tool === 'measurement') { + return ( + + ) + } + // Idle select only — an active scope (handle-drag, box-select, …) must not show // the idle selection hints. if (mode === 'select' && scope.kind === 'idle') { diff --git a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx index 8239ea48a..f88e43870 100644 --- a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx +++ b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx @@ -18,7 +18,13 @@ interface MobilePanelSheetProps { const HEIGHT_VH = 50 const DRAG_CLOSE_THRESHOLD_PX = 120 -export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) { +export function MobilePanelSheet({ + open, + onClose, + icon, + title, + children, +}: MobilePanelSheetProps): ReactNode { const [mounted, setMounted] = useState(false) const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 9c8dac818..f2fac681c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -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: 'Switch to Measure tool' }, { keys: ['X'], action: 'Switch to Delete mode' }, { keys: ['Esc'], diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-tree-node.tsx new file mode 100644 index 000000000..45af948cf --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-tree-node.tsx @@ -0,0 +1,62 @@ +import { type AnyNodeId, type MeasurementNode, useScene } from '@pascal-app/core' +import { Ruler, Trash2 } from 'lucide-react' +import { memo, useCallback } from 'react' +import { useMeasurementTool } from '../../../../../store/use-measurement-tool' +import { TreeNodeWrapper } from './tree-node' + +interface MeasurementTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export const MeasurementTreeNode = memo(function MeasurementTreeNode({ + nodeId, + depth, + isLast, +}: MeasurementTreeNodeProps) { + const node = useScene((state) => state.nodes[nodeId] as MeasurementNode | undefined) + const isSelected = useMeasurementTool((state) => state.selectedId === node?.measurementId) + const selectMeasurement = useMeasurementTool((state) => state.selectMeasurement) + const removeMeasurement = useMeasurementTool((state) => state.removeMeasurement) + const handleClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + if (node) selectMeasurement(node.measurementId) + }, + [node, selectMeasurement], + ) + const handleDelete = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation() + if (node) removeMeasurement(node.measurementId) + }, + [node, removeMeasurement], + ) + + if (!node) return null + + return ( + + + + } + depth={depth} + expanded={false} + hasChildren={false} + icon={} + isLast={isLast} + isSelected={isSelected} + label={node.name || 'Measurement'} + nodeId={nodeId} + onClick={handleClick} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index 02795c583..3d05e7fcc 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -1,4 +1,10 @@ -import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { AnimatePresence } from 'motion/react' import Image from 'next/image' @@ -31,19 +37,20 @@ export const RoofTreeNode = memo(function RoofTreeNode({ const setRoofHostDragArmedId = useEditor((state) => state.setRoofHostDragArmedId) const { drag, dropTarget } = useTreeNodeDrag() - const segments = useScene( + const childNodes = useScene( useShallow((s) => { const n = s.nodes[nodeId] as RoofNode | undefined - if (!n) return [] as RoofSegmentNode[] + if (!n) return [] as AnyNode[] return (n.children ?? []) - .map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined) - .filter((n): n is RoofSegmentNode => n?.type === 'roof-segment') + .map((childId) => s.nodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => Boolean(child)) }), ) + const segments = childNodes.filter((n): n is RoofSegmentNode => n.type === 'roof-segment') - // Targeted selector — only re-renders when a segment of THIS roof is selected/deselected + // Targeted selector — only re-renders when a child of THIS roof is selected/deselected const hasSelectedChild = useViewer((state) => - segments.some((seg) => state.selection.selectedIds.includes(seg.id)), + childNodes.some((child) => state.selection.selectedIds.includes(child.id)), ) const handleClick = useCallback( @@ -90,7 +97,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({ const defaultName = `Roof (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})` // Hide the dragged segment from every roof while dragging - const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments + const visibleChildren = drag ? childNodes.filter((child) => child.id !== drag.nodeId) : childNodes const isValidDropTarget = drag !== null && drag.nodeId !== nodeId @@ -100,7 +107,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({ actions={} depth={depth} expanded={expanded} - hasChildren={segments.length > 0} + hasChildren={childNodes.length > 0} icon={ } @@ -125,24 +132,32 @@ export const RoofTreeNode = memo(function RoofTreeNode({ onMouseLeave={handleMouseLeave} onToggle={handleToggle} > - {visibleSegments.map((seg, i) => { + {visibleChildren.map((child, i) => { const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i const showIndicatorAfter = isDropTarget && - i === visibleSegments.length - 1 && + i === visibleChildren.length - 1 && dropTarget?.insertIndex !== undefined && dropTarget.insertIndex > i return ( -
+
{showIndicatorBefore && } - + {child.type === 'roof-segment' ? ( + + ) : ( + + )} {showIndicatorAfter && } @@ -150,7 +165,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({ ) })} - {isDropTarget && visibleSegments.length === 0 && } + {isDropTarget && visibleChildren.length === 0 && }
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 6f2e832a0..253249d1e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -2,9 +2,11 @@ import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@ import { useViewer } from '@pascal-app/viewer' import { ChevronRight } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' -import { forwardRef, memo, useEffect, useRef } from 'react' +import { forwardRef, memo, useEffect, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing' import useEditor from '../../../../../store/use-editor' +import { resolveTreeChildIds } from './tree-structure' export function handleTreeSelection( e: React.MouseEvent, @@ -93,6 +95,7 @@ import { FenceTreeNode } from './fence-tree-node' import { GutterTreeNode } from './gutter-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' +import { MeasurementTreeNode } from './measurement-tree-node' import { RegistryTreeNode } from './registry-tree-node' import { RoofTreeNode } from './roof-tree-node' import { ShelfTreeNode } from './shelf-tree-node' @@ -170,6 +173,7 @@ const treeNodeByType: Record< nodeId: AnyNodeId }>, item: ItemTreeNode, + measurement: MeasurementTreeNode, } export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { @@ -238,6 +242,36 @@ export const TreeNodeWrapper = forwardRef( ref, ) { const rowRef = useRef(null) + const [autoExpanded, setAutoExpanded] = useState(true) + const autoChildIds = useScene( + useShallow((state) => + nodeId && children === undefined + ? resolveTreeChildIds(nodeId as AnyNodeId, state.nodes) + : ([] as AnyNodeId[]), + ), + ) + const hasAutoChildren = children === undefined && autoChildIds.length > 0 + const effectiveChildren = + children ?? + (hasAutoChildren + ? autoChildIds.map((childId, index) => ( + + )) + : undefined) + const effectiveHasChildren = hasChildren || hasAutoChildren + const effectiveExpanded = hasAutoChildren ? autoExpanded : expanded + const handleToggle = () => { + if (hasAutoChildren) { + setAutoExpanded((previous) => !previous) + return + } + onToggle() + } useEffect(() => { if (isSelected && rowRef.current) { @@ -282,7 +316,7 @@ export const TreeNodeWrapper = forwardRef( style={{ left: (depth - 1) * 12 + 20, width: 4 }} /> {/* Line down to children */} - {hasChildren && expanded && ( + {effectiveHasChildren && effectiveExpanded && (
( className="z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit" onClick={(e) => { e.stopPropagation() - onToggle() + handleToggle() }} > - {hasChildren ? ( + {effectiveHasChildren ? ( @@ -336,7 +370,7 @@ export const TreeNodeWrapper = forwardRef( )}
- {expanded && children && ( + {effectiveExpanded && effectiveChildren && ( ( initial={{ height: 0, opacity: 0 }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} > - {children} + {effectiveChildren} )} diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts index 89b82da63..ab0d3fff0 100644 --- a/packages/editor/src/hooks/use-auto-save.ts +++ b/packages/editor/src/hooks/use-auto-save.ts @@ -3,6 +3,7 @@ import { useScene } from '@pascal-app/core' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene' +import { serializeMeasurements, useMeasurementTool } from '../store/use-measurement-tool' const AUTOSAVE_DEBOUNCE_MS = 1000 @@ -61,6 +62,7 @@ export function useAutoSave({ useEffect(() => { let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) let lastNodeCount = Object.keys(useScene.getState().nodes).length + let lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements()) // Collections + scene materials are document-level state that persists with // the graph but lives outside `nodes`. Track them by reference (zustand // hands out a new object on every mutation) so a material edit or a @@ -76,7 +78,8 @@ export function useAutoSave({ } const { nodes, rootNodeIds, collections, materials } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph + const measurements = serializeMeasurements() + const sceneGraph = { nodes, rootNodeIds, collections, materials, measurements } as SceneGraph // Guard: refuse to autosave if the scene went from populated to nearly empty. // This catches accidental full deletions before they're persisted. @@ -126,6 +129,7 @@ export function useAutoSave({ lastNodesSnapshot = JSON.stringify(state.nodes) lastCollectionsRef = state.collections lastMaterialsRef = state.materials + lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements()) return } @@ -134,6 +138,7 @@ export function useAutoSave({ lastNodesSnapshot = JSON.stringify(state.nodes) lastCollectionsRef = state.collections lastMaterialsRef = state.materials + lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements()) return } @@ -164,6 +169,40 @@ export function useAutoSave({ }, AUTOSAVE_DEBOUNCE_MS) }) + const handleDirtyMeasurementSnapshot = (currentMeasurementsSnapshot: string) => { + lastMeasurementsSnapshot = currentMeasurementsSnapshot + hasDirtyChangesRef.current = true + onDirtyRef.current?.() + setSaveStatus('pending') + + if (isSavingRef.current) { + pendingSaveRef.current = true + return + } + + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) + + saveTimeoutRef.current = setTimeout(() => { + saveTimeoutRef.current = undefined + executeSave() + }, AUTOSAVE_DEBOUNCE_MS) + } + + const unsubscribeMeasurements = useMeasurementTool.subscribe(() => { + const currentMeasurementsSnapshot = JSON.stringify(serializeMeasurements()) + if (currentMeasurementsSnapshot === lastMeasurementsSnapshot) return + + if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { + lastMeasurementsSnapshot = currentMeasurementsSnapshot + if (isVersionPreviewModeRef.current) { + setSaveStatus('paused') + } + return + } + + handleDirtyMeasurementSnapshot(currentMeasurementsSnapshot) + }) + // Flush any unsaved change while the page is going away. The network // save MUST set `keepalive` — a normal fetch is cancelled by the browser // the moment the page unloads, so a quick refresh right after an edit @@ -173,7 +212,8 @@ export function useAutoSave({ if (!hasDirtyChangesRef.current) return hasDirtyChangesRef.current = false const { nodes, rootNodeIds, collections, materials } = useScene.getState() - const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph + const measurements = serializeMeasurements() + const sceneGraph = { nodes, rootNodeIds, collections, materials, measurements } as SceneGraph if (onSaveRef.current) { onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {}) } else { @@ -191,6 +231,7 @@ export function useAutoSave({ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) flushOnExit() unsubscribe() + unsubscribeMeasurements() } }, [setSaveStatus]) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 0ab01d851..84701b0b2 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -298,6 +298,13 @@ export const useKeyboard = ({ // Set the wall tool explicitly so B never inherits a stale tool // (e.g. fence) left over from a prior build session. useEditor.getState().setTool('wall') + } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('build') + useEditor.getState().setTool('measurement') } else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) { if (isVersionPreviewMode) return e.preventDefault() diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index df5284ea2..ce9250818 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { resolveSelectModeHelpHints } from './contextual-help' +import { resolveMeasurementHelpHints, resolveSelectModeHelpHints } from './contextual-help' describe('resolveSelectModeHelpHints', () => { test('stays hidden in idle select mode with no selection', () => { @@ -110,3 +110,91 @@ describe('resolveSelectModeHelpHints', () => { ]) }) }) + +describe('resolveMeasurementHelpHints', () => { + test('shows length drawing and quick measure hints before placement', () => { + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: false, + mode: 'distance', + modifierPressed: false, + polygonDraftActive: false, + shiftPressed: false, + }), + ).toEqual([ + { keys: ['Click'], label: 'Start length' }, + { + keys: [['Alt', 'Cmd/Ctrl'], 'Click'], + label: 'Quick measure object', + active: false, + }, + { keys: ['Esc'], label: 'Cancel measurement action' }, + ]) + }) + + test('does not use Shift as a measurement mode shortcut while a length draft is active', () => { + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: true, + mode: 'distance', + modifierPressed: false, + polygonDraftActive: false, + shiftPressed: true, + }), + ).toEqual([ + { keys: ['Click'], label: 'Finish length' }, + { keys: [['Alt', 'Cmd/Ctrl'], 'Click'], label: 'Quick measure object', active: false }, + { keys: ['Esc'], label: 'Cancel measurement action' }, + ]) + }) + + test('shows direct surface hints for area and perimeter modes', () => { + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: false, + mode: 'area', + modifierPressed: false, + polygonDraftActive: false, + shiftPressed: false, + }), + ).toContainEqual({ keys: ['Click'], label: 'Measure area' }) + + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: false, + mode: 'perimeter', + modifierPressed: false, + polygonDraftActive: false, + shiftPressed: false, + }), + ).toContainEqual({ keys: ['Click'], label: 'Measure perimeter' }) + }) + + test('shows polygon continuation hints for active area and perimeter drafts', () => { + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: false, + mode: 'area', + modifierPressed: false, + polygonDraftActive: true, + shiftPressed: false, + }), + ).toContainEqual({ keys: ['Click'], label: 'Place area point' }) + + expect( + resolveMeasurementHelpHints({ + angleDraftActive: false, + draftActive: false, + mode: 'perimeter', + modifierPressed: false, + polygonDraftActive: true, + shiftPressed: false, + }), + ).toContainEqual({ keys: ['Click'], label: 'Place perimeter point' }) + }) +}) diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 098bc3849..d0e1c5894 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -54,6 +54,15 @@ export type SelectModeHelpContext = { mepSelection?: 'run' | 'fitting' | null } +export type MeasurementHelpContext = { + angleDraftActive: boolean + draftActive: boolean + mode: 'distance' | 'area' | 'perimeter' | 'angle' + modifierPressed: boolean + polygonDraftActive?: boolean + shiftPressed: boolean +} + const COMMAND_KEY = 'Cmd/Ctrl' const LEFT_CLICK = 'Left click' const RIGHT_CLICK = 'Right click' @@ -142,3 +151,40 @@ export function resolveSelectModeHelpHints({ return hints } + +export function resolveMeasurementHelpHints({ + angleDraftActive, + draftActive, + mode, + modifierPressed, + polygonDraftActive, +}: MeasurementHelpContext): ContextualShortcutHint[] { + const hints: ContextualShortcutHint[] = [] + + if (mode === 'area') { + hints.push({ keys: [CLICK], label: polygonDraftActive ? 'Place area point' : 'Measure area' }) + } else if (mode === 'perimeter') { + hints.push({ + keys: [CLICK], + label: polygonDraftActive ? 'Place perimeter point' : 'Measure perimeter', + }) + } else if (mode === 'angle' || angleDraftActive) { + hints.push({ + keys: [CLICK], + label: angleDraftActive ? 'Place next angle point' : 'Start angle', + }) + } else { + hints.push({ + keys: [CLICK], + label: draftActive ? 'Finish length' : 'Start length', + }) + hints.push({ + keys: [[ALT_KEY, COMMAND_KEY], CLICK], + label: 'Quick measure object', + active: modifierPressed, + }) + } + + hints.push({ keys: [ESC_KEY], label: 'Cancel measurement action' }) + return hints +} diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts index cd08bcff5..7a1c09390 100644 --- a/packages/editor/src/lib/level-duplication.ts +++ b/packages/editor/src/lib/level-duplication.ts @@ -23,6 +23,17 @@ const STRUCTURAL_NODE_TYPES = new Set([ 'door', ]) +type LevelDuplicateCreateOp = { + node: AnyNode + parentId: AnyNodeId | undefined +} + +type LevelDuplicateCreateResult = { + createOps: LevelDuplicateCreateOp[] + newLevelId: AnyNodeId + shiftedLevels: Array<{ id: AnyNodeId; level: number }> +} + function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { if (NON_DUPLICABLE_NODE_TYPES.has(node.type)) return false if (preset === 'everything') return true @@ -116,7 +127,7 @@ export function buildLevelDuplicateCreateOps({ level: LevelNode levels: LevelNode[] preset: LevelDuplicatePreset -}) { +}): LevelDuplicateCreateResult { const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id) const parentBuildingId = (level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id) diff --git a/packages/editor/src/lib/local-guide-image.ts b/packages/editor/src/lib/local-guide-image.ts index 7dfe17751..aeaeeefd5 100644 --- a/packages/editor/src/lib/local-guide-image.ts +++ b/packages/editor/src/lib/local-guide-image.ts @@ -25,7 +25,7 @@ export async function createLocalGuideImage({ file: File levelId: string position?: [number, number, number] -}) { +}): Promise { const assetUrl = await saveAsset(file) const guide = GuideNode.parse({ name: getGuideImageName(file.name), diff --git a/packages/editor/src/lib/measurement-attachments.ts b/packages/editor/src/lib/measurement-attachments.ts new file mode 100644 index 000000000..b358194e4 --- /dev/null +++ b/packages/editor/src/lib/measurement-attachments.ts @@ -0,0 +1,359 @@ +'use client' + +import { + type AnyNode, + type LiveNodeOverrides, + type LiveTransform, + nodeRegistry, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useLayoutEffect, useMemo, useState } from 'react' +import { Box3, BufferGeometry, type Matrix4, type Object3D, Vector3 } from 'three' +import { + type MeasurementPoint, + type MeasurementPointAttachment, + type MeasurementSegment, + useMeasurementTool, +} from '../store/use-measurement-tool' +import { EDITOR_LAYER } from './constants' +import { collectNodePlanMeasurementSnapGeometry } from './measurement-snapping' + +const EMPTY_LIVE_TRANSFORMS = new Map() +const EMPTY_LIVE_OVERRIDES = new Map() + +function applyLiveTransform(node: AnyNode, live: LiveTransform | undefined): AnyNode { + if (!live) return node + + const parentFrameProjection = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame + ?.floorplanLiveTransform + if (parentFrameProjection) return parentFrameProjection({ node, live }) + + const measurementProjection = nodeRegistry.get(node.type)?.measurement?.applyLiveTransform + if (measurementProjection) return measurementProjection(node as never, live) + + if (!Array.isArray((node as { position?: unknown }).position)) return node + const currentRotation = (node as { rotation?: unknown }).rotation + const rotation = Array.isArray(currentRotation) + ? [currentRotation[0] ?? 0, live.rotation, currentRotation[2] ?? 0] + : typeof currentRotation === 'number' + ? live.rotation + : currentRotation + + return { + ...node, + position: live.position, + ...(rotation !== undefined ? { rotation } : {}), + parentId: null, + } as AnyNode +} + +function effectiveMeasurementNodes( + nodes: Readonly>, + liveTransforms: ReadonlyMap, + liveOverrides: ReadonlyMap, +): Record { + return Object.fromEntries( + Object.entries(nodes).map(([id, node]) => { + const override = liveOverrides.get(id) + const overridden = override ? ({ ...node, ...override } as AnyNode) : node + return [id, applyLiveTransform(overridden, liveTransforms.get(id))] + }), + ) +} + +function addBoxCorners(box: Box3, transform: Matrix4, target: Box3) { + const { min, max } = box + for (const x of [min.x, max.x]) { + for (const y of [min.y, max.y]) { + for (const z of [min.z, max.z]) + target.expandByPoint(new Vector3(x, y, z).applyMatrix4(transform)) + } + } +} + +const EDITOR_OVERLAY_RENDER_ORDER = 1000 +const EDITOR_LAYER_MASK = 1 << EDITOR_LAYER + +function isEditorOverlayObject(object: Object3D): boolean { + let current: Object3D | null = object + while (current) { + if ((current.layers.mask & EDITOR_LAYER_MASK) !== 0) return true + if (current.renderOrder >= EDITOR_OVERLAY_RENDER_ORDER) return true + if (current.userData.measurementBoundsIgnore === true) return true + current = current.parent + } + return false +} + +function nodeLocalBounds(root: Object3D): Box3 | null { + root.updateWorldMatrix(true, true) + const rootInverse = root.matrixWorld.clone().invert() + const bounds = new Box3() + root.traverse((object) => { + if (isEditorOverlayObject(object)) return + const geometry = (object as { geometry?: unknown }).geometry + if (!(geometry instanceof BufferGeometry)) return + if (!geometry.boundingBox) geometry.computeBoundingBox() + if (!geometry.boundingBox) return + addBoxCorners(geometry.boundingBox, rootInverse.clone().multiply(object.matrixWorld), bounds) + }) + return bounds.isEmpty() ? null : bounds +} + +function worldPointFromMeasurementPoint( + point: MeasurementPoint, + buildingId: string | undefined, +): Vector3 { + const world = new Vector3(...point) + const building = buildingId ? sceneRegistry.nodes.get(buildingId as never) : null + return building ? building.localToWorld(world) : world +} + +function measurementPointFromWorldPoint( + point: Vector3, + buildingId: string | undefined, +): MeasurementPoint { + const building = buildingId ? sceneRegistry.nodes.get(buildingId as never) : null + const local = building ? building.worldToLocal(point) : point + return [local.x, local.y, local.z] +} + +export function createNodeBoundsAttachment( + nodeId: string, + point: MeasurementPoint, + buildingId?: string, +): MeasurementPointAttachment | undefined { + const root = sceneRegistry.nodes.get(nodeId as never) + if (!root) return undefined + const bounds = nodeLocalBounds(root) + if (!bounds) return undefined + + const local = root.worldToLocal(worldPointFromMeasurementPoint(point, buildingId)) + const size = bounds.getSize(new Vector3()) + const normalized: MeasurementPoint = [ + size.x > 1e-8 ? (local.x - bounds.min.x) / size.x : 0.5, + size.y > 1e-8 ? (local.y - bounds.min.y) / size.y : 0.5, + size.z > 1e-8 ? (local.z - bounds.min.z) / size.z : 0.5, + ] + + return { + buildingId, + feature: { kind: 'node-bounds', normalized }, + nodeId, + } +} + +export function createPlanMeasurementAttachment( + nodeId: string, + point: MeasurementPoint, + nodes: Readonly> = useScene.getState().nodes, +): MeasurementPointAttachment | undefined { + const node = nodes[nodeId] + if (!node) return undefined + const geometry = collectNodePlanMeasurementSnapGeometry(node, nodes) + let best: { attachment: MeasurementPointAttachment; distanceSq: number } | undefined + + for (const [index, anchor] of geometry.anchors.entries()) { + const distanceSq = + (point[0] - anchor.point[0]) ** 2 + + (point[1] - anchor.point[1]) ** 2 + + (point[2] - anchor.point[2]) ** 2 + if (!best || distanceSq < best.distanceSq) { + best = { + attachment: { feature: { index, kind: 'plan-anchor' }, nodeId }, + distanceSq, + } + } + } + + for (const [index, segment] of geometry.segments.entries()) { + const dx = segment.end[0] - segment.start[0] + const dy = segment.end[1] - segment.start[1] + const dz = segment.end[2] - segment.start[2] + const lengthSq = dx * dx + dy * dy + dz * dz + if (lengthSq < 1e-8) continue + const t = Math.min( + 1, + Math.max( + 0, + ((point[0] - segment.start[0]) * dx + + (point[1] - segment.start[1]) * dy + + (point[2] - segment.start[2]) * dz) / + lengthSq, + ), + ) + const projected: MeasurementPoint = [ + segment.start[0] + dx * t, + segment.start[1] + dy * t, + segment.start[2] + dz * t, + ] + const distanceSq = + (point[0] - projected[0]) ** 2 + + (point[1] - projected[1]) ** 2 + + (point[2] - projected[2]) ** 2 + if (!best || distanceSq < best.distanceSq) { + best = { + attachment: { feature: { index, kind: 'plan-segment', t }, nodeId }, + distanceSq, + } + } + } + + return best && best.distanceSq <= 1e-6 ? best.attachment : undefined +} + +function resolveNodeBoundsAttachment( + attachment: MeasurementPointAttachment, +): MeasurementPoint | null { + if (attachment.feature.kind !== 'node-bounds') return null + const root = sceneRegistry.nodes.get(attachment.nodeId as never) + if (!root) return null + const bounds = nodeLocalBounds(root) + if (!bounds) return null + const [x, y, z] = attachment.feature.normalized + const local = new Vector3( + bounds.min.x + (bounds.max.x - bounds.min.x) * x, + bounds.min.y + (bounds.max.y - bounds.min.y) * y, + bounds.min.z + (bounds.max.z - bounds.min.z) * z, + ) + return measurementPointFromWorldPoint(root.localToWorld(local), attachment.buildingId) +} + +export function resolveMeasurementAttachmentPoint( + attachment: MeasurementPointAttachment | undefined, + fallback: MeasurementPoint, + nodes: Readonly>, +): MeasurementPoint { + if (!attachment || !nodes[attachment.nodeId]) return fallback + if (attachment.feature.kind === 'node-bounds') { + return resolveNodeBoundsAttachment(attachment) ?? fallback + } + + const node = nodes[attachment.nodeId] + if (!node) return fallback + const geometry = collectNodePlanMeasurementSnapGeometry(node, nodes) + if (attachment.feature.kind === 'plan-anchor') { + return geometry.anchors[attachment.feature.index]?.point ?? fallback + } + + const segment = geometry.segments[attachment.feature.index] + if (!segment) return fallback + const t = attachment.feature.t + return [ + segment.start[0] + (segment.end[0] - segment.start[0]) * t, + segment.start[1] + (segment.end[1] - segment.start[1]) * t, + segment.start[2] + (segment.end[2] - segment.start[2]) * t, + ] +} + +export function resolveAttachedMeasurementSegments( + segments: ReadonlyArray, + nodes: Readonly>, + liveTransforms: ReadonlyMap = EMPTY_LIVE_TRANSFORMS, + liveOverrides: ReadonlyMap = EMPTY_LIVE_OVERRIDES, +): MeasurementSegment[] { + const effectiveNodes = effectiveMeasurementNodes(nodes, liveTransforms, liveOverrides) + return segments.map((segment) => { + const start = resolveMeasurementAttachmentPoint( + segment.startAttachment, + segment.start, + effectiveNodes, + ) + const end = resolveMeasurementAttachmentPoint( + segment.endAttachment, + segment.end, + effectiveNodes, + ) + const attachmentMoved = + (segment.startAttachment && + (start[0] !== segment.start[0] || + start[1] !== segment.start[1] || + start[2] !== segment.start[2])) || + (segment.endAttachment && + (end[0] !== segment.end[0] || end[1] !== segment.end[1] || end[2] !== segment.end[2])) + return { + ...segment, + start, + end, + measuredDistanceMeters: attachmentMoved ? undefined : segment.measuredDistanceMeters, + } + }) +} + +function sameMeasurementPoint(a: MeasurementPoint, b: MeasurementPoint) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +export function sameResolvedMeasurementSegments( + previous: ReadonlyArray, + next: ReadonlyArray, +) { + return ( + previous.length === next.length && + next.every((segment, index) => { + const current = previous[index] + return ( + current?.id === segment.id && + sameMeasurementPoint(current.start, segment.start) && + sameMeasurementPoint(current.end, segment.end) && + current.measuredDistanceMeters === segment.measuredDistanceMeters + ) + }) + ) +} + +export function refreshRenderedBoundsMeasurementSegments( + previous: ReadonlyArray, + segments: ReadonlyArray, + nodes: Readonly>, + liveTransforms: ReadonlyMap = EMPTY_LIVE_TRANSFORMS, + liveOverrides: ReadonlyMap = EMPTY_LIVE_OVERRIDES, +): MeasurementSegment[] { + const next = resolveAttachedMeasurementSegments(segments, nodes, liveTransforms, liveOverrides) + return sameResolvedMeasurementSegments(previous, next) ? (previous as MeasurementSegment[]) : next +} + +export function getResolvedMeasurementSegments(): MeasurementSegment[] { + return resolveAttachedMeasurementSegments( + useMeasurementTool.getState().segments, + useScene.getState().nodes, + useLiveTransforms.getState().transforms, + useLiveNodeOverrides.getState().overrides, + ) +} + +export function useResolvedMeasurementSegments( + segments: ReadonlyArray, +): MeasurementSegment[] { + const nodes = useScene((state) => state.nodes) + const liveTransforms = useLiveTransforms((state) => state.transforms) + const liveOverrides = useLiveNodeOverrides((state) => state.overrides) + const resolved = useMemo( + () => resolveAttachedMeasurementSegments(segments, nodes, liveTransforms, liveOverrides), + [segments, nodes, liveTransforms, liveOverrides], + ) + const hasRenderedBoundsAttachment = segments.some( + (segment) => + segment.startAttachment?.feature.kind === 'node-bounds' || + segment.endAttachment?.feature.kind === 'node-bounds', + ) + const [postCommitResolved, setPostCommitResolved] = useState(resolved) + + useLayoutEffect(() => { + if (!hasRenderedBoundsAttachment) return + setPostCommitResolved((previous) => + refreshRenderedBoundsMeasurementSegments( + previous, + segments, + nodes, + liveTransforms, + liveOverrides, + ), + ) + }, [hasRenderedBoundsAttachment, segments, nodes, liveTransforms, liveOverrides]) + + return hasRenderedBoundsAttachment ? postCommitResolved : resolved +} diff --git a/packages/editor/src/lib/measurement-scene-nodes.ts b/packages/editor/src/lib/measurement-scene-nodes.ts new file mode 100644 index 000000000..fef55a81c --- /dev/null +++ b/packages/editor/src/lib/measurement-scene-nodes.ts @@ -0,0 +1,102 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + AnyNode as AnyNodeSchema, + MeasurementNode, + nodeRegistry, + useScene, +} from '@pascal-app/core' +import { useEffect } from 'react' +import { type MeasurementSegment, useMeasurementTool } from '../store/use-measurement-tool' + +function measurementSceneParentId( + segment: MeasurementSegment, + nodes: Readonly>, +): AnyNodeId | null { + for (const attachment of [segment.startAttachment, segment.endAttachment]) { + if (attachment?.ownerNodeId && nodes[attachment.ownerNodeId]) { + return attachment.ownerNodeId as AnyNodeId + } + if (attachment && nodes[attachment.nodeId]) return attachment.nodeId as AnyNodeId + } + return null +} + +function appendMeasurementSceneChild(parent: AnyNode, id: AnyNodeId): AnyNode { + const currentChildren = (parent as { children?: unknown }).children + const children = Array.isArray(currentChildren) ? currentChildren : [] + const candidate = { + ...parent, + children: Array.from(new Set([...children, id])), + } + + const schema = nodeRegistry.get(parent.type)?.schema ?? AnyNodeSchema + return schema.safeParse(candidate).success ? (candidate as AnyNode) : parent +} + +export function syncLinearMeasurementSceneNodes(segments: ReadonlyArray) { + useScene.setState((state) => { + const existing = Object.values(state.nodes).filter( + (node): node is Extract => node.type === 'measurement', + ) + const existingIds = new Set(existing.map((node) => node.id)) + const existingByMeasurementId = new Map(existing.map((node) => [node.measurementId, node])) + const nodes = Object.fromEntries( + Object.entries(state.nodes) + .filter(([id]) => !existingIds.has(id as AnyNodeId)) + .map(([id, node]) => { + const children = (node as { children?: unknown }).children + if (!Array.isArray(children)) return [id, node] + return [ + id, + { ...node, children: children.filter((childId) => !existingIds.has(childId)) }, + ] + }), + ) as Record + const rootNodeIds = state.rootNodeIds.filter((id) => !existingIds.has(id)) + + for (const segment of segments) { + const parentId = measurementSceneParentId(segment, nodes) + const id = `measurement_${segment.id}` as AnyNodeId + nodes[id] = MeasurementNode.parse({ + ...existingByMeasurementId.get(segment.id), + end: segment.end, + endAttachment: segment.endAttachment, + id, + measuredDistanceMeters: segment.measuredDistanceMeters, + measurementId: segment.id, + parentId, + start: segment.start, + startAttachment: segment.startAttachment, + view: segment.view, + }) + + if (!parentId) { + rootNodeIds.push(id) + continue + } + const parent = nodes[parentId] + if (parent) { + nodes[parentId] = appendMeasurementSceneChild(parent, id) + } + } + + return { nodes, rootNodeIds } + }) +} + +export function MeasurementSceneGraphSync() { + const segments = useMeasurementTool((state) => state.segments) + const draggingSegmentEndpoint = useMeasurementTool((state) => state.draggingSegmentEndpoint) + + useEffect(() => { + if (draggingSegmentEndpoint) return + syncLinearMeasurementSceneNodes(segments) + }, [draggingSegmentEndpoint, segments]) + + useEffect(() => () => syncLinearMeasurementSceneNodes([]), []) + + return null +} diff --git a/packages/editor/src/lib/measurement-snapping.test.ts b/packages/editor/src/lib/measurement-snapping.test.ts new file mode 100644 index 000000000..d9cee0e6b --- /dev/null +++ b/packages/editor/src/lib/measurement-snapping.test.ts @@ -0,0 +1,747 @@ +import { beforeAll, describe, expect, test } from 'bun:test' +import { sceneRegistry } from '@pascal-app/core' +import { BoxGeometry, Group, Mesh } from 'three' +import type { MeasurementSegment } from '../store/use-measurement-tool' +import { EDITOR_LAYER } from './constants' +import { + createNodeBoundsAttachment, + refreshRenderedBoundsMeasurementSegments, + resolveAttachedMeasurementSegments, +} from './measurement-attachments' +import { + collectCommittedMeasurementSnapGeometry, + collectPlanMeasurementSnapGeometry, + type MeasurementSnapGeometry, + mergeMeasurementSnapGeometry, + resolvePlanMeasurementConstraint, + resolvePlanMeasurementSnap, +} from './measurement-snapping' +import { registerMeasurementTestNodes } from './register-measurement-test-nodes' + +beforeAll(() => { + registerMeasurementTestNodes() +}) + +describe('measurement snapping', () => { + test('prefers semantic snap priority over a closer grid point', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0.49, 0, 0.49], priority: 0 }], + segments: [], + } + + const result = resolvePlanMeasurementSnap([0.51, 0, 0.51], geometry, { + radiusMeters: 0.1, + view: '2d', + gridStep: 0.5, + }) + + expect(result.point).toEqual([0.49, 0, 0.49]) + expect(result.target?.kind).toBe('endpoint') + }) + + test('projects cursor points onto snap segments', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [], + segments: [ + { + kind: 'edge', + label: 'Wall edge', + start: [0, 0, 0], + end: [4, 0, 0], + priority: 3, + }, + ], + } + + const result = resolvePlanMeasurementSnap([2, 0, 0.04], geometry, { + radiusMeters: 0.1, + view: '2d', + }) + + expect(result.point).toEqual([2, 0, 0]) + expect(result.target?.kind).toBe('edge') + expect(result.target?.targetLine).toEqual({ + start: [0, 0, 0], + end: [4, 0, 0], + }) + }) + + test('keeps plan attachments on the same node feature as geometry changes', () => { + const wall = { + object: 'node', + id: 'attached-wall', + type: 'wall', + name: 'Attached wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + } as any + const geometry = collectPlanMeasurementSnapGeometry([wall]) + const snap = resolvePlanMeasurementSnap([1.7, 0, 0.04], geometry, { + enabledSnapKinds: { grid: false }, + radiusMeters: 0.1, + view: '2d', + }) + + expect(snap.target?.attachment).toMatchObject({ + feature: { kind: 'plan-segment' }, + nodeId: wall.id, + }) + + const measurement: MeasurementSegment = { + id: 'attached-measurement', + start: [0, 0, 1], + end: snap.point, + endAttachment: snap.target?.attachment, + view: '2d', + } + const resizedWall = { ...wall, end: [8, 0] } + const [resolved] = resolveAttachedMeasurementSegments([measurement], { + [resizedWall.id]: resizedWall, + }) + + expect(resolved?.end).toEqual([3.4, 0, 0]) + }) + + test('allows the two measurement endpoints to follow different nodes', () => { + const firstWall = { + object: 'node', + id: 'first-attached-wall', + type: 'wall', + name: 'First wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [2, 0], + } as any + const secondWall = { ...firstWall, id: 'second-attached-wall', start: [4, 0], end: [6, 0] } + const firstGeometry = collectPlanMeasurementSnapGeometry([firstWall]) + const secondGeometry = collectPlanMeasurementSnapGeometry([secondWall]) + const startAttachment = firstGeometry.anchors.find( + (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 0, + )?.attachment + const endAttachment = secondGeometry.anchors.find( + (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 6, + )?.attachment + const measurement: MeasurementSegment = { + id: 'two-owner-measurement', + start: [0, 0, 0], + end: [6, 0, 0], + startAttachment, + endAttachment, + view: '2d', + } + const movedFirst = { ...firstWall, start: [1, 0], end: [3, 0] } + const movedSecond = { ...secondWall, start: [7, 0], end: [9, 0] } + + const [resolved] = resolveAttachedMeasurementSegments([measurement], { + [movedFirst.id]: movedFirst, + [movedSecond.id]: movedSecond, + }) + + expect(resolved?.start).toEqual([1, 0, 0]) + expect(resolved?.end).toEqual([9, 0, 0]) + }) + + test('resolves attached endpoints from live shape overrides and move transforms', () => { + const wall = { + object: 'node', + id: 'live-attached-wall', + type: 'wall', + name: 'Live attached wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + } as any + const wallEndAttachment = collectPlanMeasurementSnapGeometry([wall]).anchors.find( + (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 4, + )?.attachment + const wallMeasurement: MeasurementSegment = { + id: 'live-wall-measurement', + start: [0, 0, 1], + end: [4, 0, 0], + endAttachment: wallEndAttachment, + view: '2d', + } + const [liveWallMeasurement] = resolveAttachedMeasurementSegments( + [wallMeasurement], + { [wall.id]: wall }, + new Map(), + new Map([[wall.id, { end: [7, 0] }]]), + ) + expect(liveWallMeasurement?.end).toEqual([7, 0, 0]) + + const zone = { + object: 'node', + id: 'live-attached-zone', + type: 'zone', + name: 'Live attached zone', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + } as any + const zoneAttachment = collectPlanMeasurementSnapGeometry([zone]).anchors.find( + (anchor) => anchor.kind === 'vertex' && anchor.point[0] === 0 && anchor.point[2] === 0, + )?.attachment + const [liveZoneMeasurement] = resolveAttachedMeasurementSegments( + [ + { + id: 'live-zone-measurement', + start: [0, 0, 0], + end: [1, 0, 1], + startAttachment: zoneAttachment, + view: '2d', + }, + ], + { [zone.id]: zone }, + new Map([[zone.id, { position: [3, 0, 4], rotation: 0 }]]), + ) + expect(liveZoneMeasurement?.start).toEqual([3, 0, 4]) + }) + + test('keeps 3D bounds attachments on the same corner through resize and movement', () => { + const nodeId = 'rendered-bounds-node' + const root = new Group() + const mesh = new Mesh(new BoxGeometry(2, 2, 2)) + root.add(mesh) + root.updateWorldMatrix(true, true) + sceneRegistry.nodes.set(nodeId as never, root) + + const attachment = createNodeBoundsAttachment(nodeId, [1, 1, 1]) + expect(attachment).toMatchObject({ + feature: { kind: 'node-bounds', normalized: [1, 1, 1] }, + nodeId, + }) + + mesh.scale.x = 2 + root.position.x = 3 + root.updateWorldMatrix(true, true) + const [resolved] = resolveAttachedMeasurementSegments( + [ + { + id: 'rendered-bounds-measurement', + start: [0, 0, 0], + end: [1, 1, 1], + endAttachment: attachment, + measuredDistanceMeters: 2, + view: '3d', + }, + ], + { [nodeId]: { id: nodeId } as never }, + ) + + expect(resolved?.end).toEqual([5, 1, 1]) + expect(resolved?.measuredDistanceMeters).toBeUndefined() + sceneRegistry.nodes.delete(nodeId as never) + mesh.geometry.dispose() + }) + + test('refreshes a rendered bounds attachment after an imperative mesh resize', () => { + const nodeId = 'rendered-bounds-refresh-node' + const root = new Group() + const mesh = new Mesh(new BoxGeometry(2, 2, 2)) + root.add(mesh) + root.updateWorldMatrix(true, true) + sceneRegistry.nodes.set(nodeId as never, root) + + const segment: MeasurementSegment = { + id: 'rendered-bounds-refresh-measurement', + start: [0, 0, 0], + end: [1, 1, 1], + endAttachment: createNodeBoundsAttachment(nodeId, [1, 1, 1]), + view: '3d', + } + const nodes = { [nodeId]: { id: nodeId } as never } + const initial = resolveAttachedMeasurementSegments([segment], nodes) + + mesh.scale.x = 2 + root.updateWorldMatrix(true, true) + const refreshed = refreshRenderedBoundsMeasurementSegments(initial, [segment], nodes) + + expect(refreshed).not.toBe(initial) + expect(refreshed[0]?.end).toEqual([2, 1, 1]) + sceneRegistry.nodes.delete(nodeId as never) + mesh.geometry.dispose() + }) + + test('ignores editor handles when resolving rendered bounds attachments', () => { + const nodeId = 'rendered-bounds-editor-handle-node' + const root = new Group() + const mesh = new Mesh(new BoxGeometry(2, 2, 2)) + root.add(mesh) + root.updateWorldMatrix(true, true) + sceneRegistry.nodes.set(nodeId as never, root) + + const attachment = createNodeBoundsAttachment(nodeId, [1, 1, 1]) + const overlayHandle = new Mesh(new BoxGeometry(20, 20, 20)) + overlayHandle.position.set(100, 0, 0) + overlayHandle.renderOrder = 1010 + root.add(overlayHandle) + const overlayLayerHandle = new Mesh(new BoxGeometry(20, 20, 20)) + overlayLayerHandle.position.set(-100, 0, 0) + overlayLayerHandle.layers.set(EDITOR_LAYER) + root.add(overlayLayerHandle) + root.updateWorldMatrix(true, true) + + const [resolved] = resolveAttachedMeasurementSegments( + [ + { + id: 'rendered-bounds-editor-handle-measurement', + start: [0, 0, 0], + end: [1, 1, 1], + endAttachment: attachment, + view: '3d', + }, + ], + { [nodeId]: { id: nodeId } as never }, + ) + + expect(resolved?.end).toEqual([1, 1, 1]) + sceneRegistry.nodes.delete(nodeId as never) + mesh.geometry.dispose() + overlayHandle.geometry.dispose() + overlayLayerHandle.geometry.dispose() + }) + + test('keeps rendered bounds refresh identity when geometry did not move', () => { + const nodeId = 'rendered-bounds-refresh-stable-node' + const root = new Group() + const mesh = new Mesh(new BoxGeometry(2, 2, 2)) + root.add(mesh) + root.updateWorldMatrix(true, true) + sceneRegistry.nodes.set(nodeId as never, root) + + const segment: MeasurementSegment = { + id: 'rendered-bounds-refresh-stable-measurement', + start: [0, 0, 0], + end: [1, 1, 1], + endAttachment: createNodeBoundsAttachment(nodeId, [1, 1, 1]), + view: '3d', + } + const nodes = { [nodeId]: { id: nodeId } as never } + const initial = resolveAttachedMeasurementSegments([segment], nodes) + const refreshed = refreshRenderedBoundsMeasurementSegments(initial, [segment], nodes) + + expect(refreshed).toBe(initial) + sceneRegistry.nodes.delete(nodeId as never) + mesh.geometry.dispose() + }) + + test('uses grid as fallback when no geometry is close enough', () => { + const result = resolvePlanMeasurementSnap( + [1.98, 0, 2.03], + { anchors: [], segments: [] }, + { + radiusMeters: 0.1, + view: '3d', + gridStep: 0.5, + }, + ) + + expect(result.point).toEqual([2, 0, 2]) + expect(result.target?.kind).toBe('grid') + }) + + test('does not use disabled snap families', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0, 0, 0], priority: 0 }], + segments: [ + { + kind: 'edge', + label: 'Wall edge', + start: [0, 0, 0], + end: [4, 0, 0], + priority: 3, + }, + ], + } + + const result = resolvePlanMeasurementSnap([0.02, 0, 0.02], geometry, { + enabledSnapKinds: { + edge: false, + endpoint: false, + grid: false, + }, + radiusMeters: 0.1, + view: '2d', + }) + + expect(result.point).toEqual([0.02, 0, 0.02]) + expect(result.target).toBeNull() + }) + + test('skips grid fallback when grid snapping is disabled', () => { + const result = resolvePlanMeasurementSnap( + [1.98, 0, 2.03], + { anchors: [], segments: [] }, + { + enabledSnapKinds: { grid: false }, + radiusMeters: 0.1, + view: '3d', + gridStep: 0.5, + }, + ) + + expect(result.point).toEqual([1.98, 0, 2.03]) + expect(result.target).toBeNull() + }) + + test('exposes committed measurements from every view for cross-view snapping', () => { + const segments: MeasurementSegment[] = [ + { + id: '2d-measurement', + start: [0, 0, 0], + end: [2, 0, 0], + view: '2d', + }, + { + id: '3d-measurement', + start: [0, 1, 0], + end: [0, 1, 2], + view: '3d', + }, + ] + + const geometry = collectCommittedMeasurementSnapGeometry(segments) + + expect(geometry.anchors).toHaveLength(6) + expect(geometry.segments).toHaveLength(2) + expect(geometry.anchors.every((anchor) => anchor.kind === 'measurement')).toBe(true) + expect(geometry.anchors.some((anchor) => anchor.point[1] === 1)).toBe(true) + }) + + test('collects surface opening hole snap geometry', () => { + const geometry = collectPlanMeasurementSnapGeometry([ + { + object: 'node', + id: 'slab-with-hole', + type: 'slab', + name: 'Slab with hole', + parentId: null, + visible: true, + metadata: {}, + children: [], + elevation: 0, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [ + [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + ], + }, + ] as any[]) + + expect( + geometry.anchors.some( + (anchor) => + anchor.kind === 'vertex' && + anchor.label === 'Surface opening vertex' && + anchor.point[0] === 1 && + anchor.point[2] === 1, + ), + ).toBe(true) + expect( + geometry.anchors.find( + (anchor) => + anchor.kind === 'vertex' && + anchor.label === 'Surface opening vertex' && + anchor.point[0] === 1 && + anchor.point[2] === 1, + )?.targetLine, + ).toEqual({ + start: [1, 0, 1], + end: [2, 0, 1], + }) + expect( + geometry.segments.some( + (segment) => + segment.kind === 'edge' && + segment.label === 'Surface opening edge' && + segment.start[0] === 1 && + segment.end[0] === 2, + ), + ).toBe(true) + }) + + test('collects site property line polygon snap geometry', () => { + const geometry = collectPlanMeasurementSnapGeometry([ + { + object: 'node', + id: 'site-property-line', + type: 'site', + name: 'Site', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: { + type: 'polygon', + points: [ + [-2, -1], + [2, -1], + [2, 1], + [-2, 1], + ], + }, + }, + ] as any[]) + + expect( + geometry.anchors.some( + (anchor) => + anchor.kind === 'vertex' && + anchor.label === 'Property line vertex' && + anchor.point[0] === -2 && + anchor.point[2] === -1, + ), + ).toBe(true) + expect( + geometry.segments.some( + (segment) => + segment.kind === 'edge' && + segment.label === 'Property line edge' && + segment.start[0] === -2 && + segment.end[0] === 2, + ), + ).toBe(true) + }) + + test('collects roof-hosted accessory snap geometry in world plan space', () => { + const geometry = collectPlanMeasurementSnapGeometry([ + { + object: 'node', + id: 'roof-a', + type: 'roof', + name: 'Roof', + parentId: null, + visible: true, + metadata: {}, + children: ['segment-a'], + position: [10, 0, 20], + rotation: Math.PI / 2, + }, + { + object: 'node', + id: 'segment-a', + type: 'roof-segment', + name: 'Roof Segment', + parentId: 'roof-a', + visible: true, + metadata: {}, + children: ['skylight-a'], + position: [2, 0, 0], + rotation: 0, + roofType: 'gable', + width: 4, + depth: 2, + pitch: 40, + wallHeight: 0.5, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + gambrelLowerWidthRatio: 0.5, + mansardSteepWidthRatio: 0.15, + dutchHipWidthRatio: 0.25, + dutchWaistLengthRatio: 0.98, + }, + { + object: 'node', + id: 'skylight-a', + type: 'skylight', + name: 'Skylight', + parentId: 'segment-a', + visible: true, + metadata: {}, + children: [], + roofSegmentId: 'segment-a', + position: [0, 0, 0], + rotation: 0, + width: 2, + height: 1, + }, + ] as any[]) + + expect( + geometry.anchors.some( + (anchor) => + anchor.kind === 'center' && + anchor.label === 'Skylight center' && + Math.abs(anchor.point[0] - 10) < 1e-6 && + Math.abs(anchor.point[2] - 18) < 1e-6, + ), + ).toBe(true) + expect( + geometry.segments.some( + (segment) => + segment.kind === 'edge' && + segment.label === 'Skylight edge' && + Math.abs(segment.start[0] - 10.5) < 1e-6 && + Math.abs(segment.end[0] - 10.5) < 1e-6, + ), + ).toBe(true) + }) + + test('collects intersections without treating shared sampled source endpoints as crossings', () => { + const nodes = [ + { + object: 'node', + id: 'wall-a', + type: 'wall', + name: 'Wall A', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + }, + { + object: 'node', + id: 'wall-b', + type: 'wall', + name: 'Wall B', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [2, -1], + end: [2, 1], + }, + ] as any[] + + const geometry = collectPlanMeasurementSnapGeometry(nodes) + + expect( + geometry.anchors.some( + (anchor) => + anchor.kind === 'intersection' && + Math.abs(anchor.point[0] - 2) < 1e-6 && + Math.abs(anchor.point[2]) < 1e-6, + ), + ).toBe(true) + expect( + geometry.anchors.filter( + (anchor) => + anchor.kind === 'intersection' && + Math.abs(anchor.point[0]) < 1e-6 && + Math.abs(anchor.point[2]) < 1e-6, + ), + ).toHaveLength(0) + }) + + test('constrains active measurements parallel and perpendicular to nearby geometry', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [], + segments: [ + { + kind: 'edge', + label: 'Wall edge', + start: [0, 0, 0], + end: [4, 0, 0], + priority: 3, + }, + ], + } + + const parallel = resolvePlanMeasurementConstraint([1, 0, 0.02], [2, 0, 0.08], geometry, { + radiusMeters: 0.15, + view: '2d', + }) + const perpendicular = resolvePlanMeasurementConstraint([1, 0, 0.02], [1.08, 0, 1], geometry, { + radiusMeters: 0.15, + view: '2d', + }) + + expect(parallel.point[2]).toBeCloseTo(0.02) + expect(parallel.target?.kind).toBe('guide') + expect(parallel.target?.guideLine).toBeDefined() + expect(perpendicular.point[0]).toBeCloseTo(1) + expect(perpendicular.target?.kind).toBe('guide') + }) + + test('constrains active measurements to common polar guide angles without nearby geometry', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [], + segments: [], + } + + const diagonal = resolvePlanMeasurementConstraint([0, 0, 0], [1, 0, 1.08], geometry, { + radiusMeters: 0.15, + view: '2d', + }) + const horizontal = resolvePlanMeasurementConstraint([0, 0, 0], [2, 0, 0.06], geometry, { + radiusMeters: 0.15, + view: '2d', + }) + + expect(diagonal.point[0]).toBeCloseTo(diagonal.point[2]) + expect(diagonal.target?.kind).toBe('guide') + expect(diagonal.target?.label).toBe('Polar guide 45°') + expect(horizontal.point[2]).toBeCloseTo(0) + expect(horizontal.target?.kind).toBe('guide') + expect(horizontal.target?.label).toBe('Polar guide 0°') + }) + + test('does not constrain active measurements when guide snapping is disabled', () => { + const geometry: MeasurementSnapGeometry = { + anchors: [], + segments: [ + { + kind: 'edge', + label: 'Wall edge', + start: [0, 0, 0], + end: [4, 0, 0], + priority: 3, + }, + ], + } + + const result = resolvePlanMeasurementConstraint([1, 0, 0.02], [2, 0, 0.08], geometry, { + enabledSnapKinds: { guide: false }, + radiusMeters: 0.15, + view: '2d', + }) + + expect(result.point).toEqual([2, 0, 0.08]) + expect(result.target).toBeNull() + }) + + test('merges plan and committed measurement snap geometry', () => { + const planGeometry: MeasurementSnapGeometry = { + anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0, 0, 0] }], + segments: [], + } + const savedGeometry: MeasurementSnapGeometry = { + anchors: [{ kind: 'measurement', label: 'Measurement endpoint', point: [1, 0, 0] }], + segments: [], + } + + const merged = mergeMeasurementSnapGeometry(planGeometry, savedGeometry) + + expect(merged.anchors.map((anchor) => anchor.kind)).toEqual(['endpoint', 'measurement']) + }) +}) diff --git a/packages/editor/src/lib/measurement-snapping.ts b/packages/editor/src/lib/measurement-snapping.ts new file mode 100644 index 000000000..2f8aa0431 --- /dev/null +++ b/packages/editor/src/lib/measurement-snapping.ts @@ -0,0 +1,808 @@ +import { + type AnyNode, + type AnyNodeId, + type GeometryContext, + type MeasurementDefinitionSnapGeometry, + nodeAlignmentAnchors, + nodeRegistry, +} from '@pascal-app/core' +import type { + MeasurementPoint, + MeasurementPointAttachment, + MeasurementSegment, + MeasurementSnapKind, + MeasurementSnapSettings, + MeasurementSnapTarget, + MeasurementView, +} from '../store/use-measurement-tool' + +export type MeasurementSnapAnchor = { + attachment?: MeasurementPointAttachment + kind?: MeasurementSnapKind + label: string + point: MeasurementPoint + priority?: number + targetLine?: { + end: MeasurementPoint + start: MeasurementPoint + } +} + +export type MeasurementSnapSegment = { + attachment?: MeasurementPointAttachment + kind?: MeasurementSnapKind + label: string + sourceId?: string + start: MeasurementPoint + end: MeasurementPoint + priority?: number +} + +export type MeasurementSnapGeometry = { + anchors: MeasurementSnapAnchor[] + segments: MeasurementSnapSegment[] +} + +type PlanPoint = { x: number; y: number } +type PlanAABB = { minX: number; minZ: number; maxX: number; maxZ: number } +type UnknownPlanPolygonObject = { points?: unknown; type?: unknown } + +const EPSILON = 1e-8 +const DEFAULT_GRID_STEP = 0.5 +const DEFAULT_GRID_PRIORITY = 100 +const CURVE_SNAP_SEGMENTS = 32 +const SNAP_PRIORITY_BUCKET = 1000 + +function snapPriority(anchor: Pick): number { + return ( + anchor.priority ?? SNAP_KIND_PRIORITY[anchor.kind ?? measurementSnapKindFromLabel(anchor.label)] + ) +} + +const SNAP_KIND_PRIORITY: Record = { + endpoint: 0, + intersection: 0, + vertex: 0, + measurement: 1, + midpoint: 2, + center: 3, + surface: 3, + edge: 4, + guide: 5, + grid: DEFAULT_GRID_PRIORITY, +} + +function snapScore(distanceSq: number, priority: number): number { + return priority * SNAP_PRIORITY_BUCKET + distanceSq +} + +export function measurementSnapKindFromLabel(label: string): MeasurementSnapKind { + const normalized = label.toLowerCase() + if (normalized.includes('surface')) return 'surface' + if (normalized.includes('measurement')) return 'measurement' + if (normalized.includes('intersection')) return 'intersection' + if ( + normalized.includes('parallel') || + normalized.includes('perpendicular') || + normalized.includes('polar') || + normalized.includes('guide') + ) + return 'guide' + if (normalized.includes('grid')) return 'grid' + if (normalized.includes('midpoint')) return 'midpoint' + if (normalized.includes('endpoint')) return 'endpoint' + if (normalized.includes('vertex') || normalized.includes('corner')) return 'vertex' + if (normalized.includes('center')) return 'center' + if (normalized.includes('edge')) return 'edge' + return 'vertex' +} + +export function polygonAreaAndCentroid(polygon: ReadonlyArray): { + area: number + centroid: PlanPoint +} { + let cx = 0 + let cy = 0 + let area = 0 + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const p1 = polygon[j]! + const p2 = polygon[i]! + const f = p1[0] * p2[1] - p2[0] * p1[1] + cx += (p1[0] + p2[0]) * f + cy += (p1[1] + p2[1]) * f + area += f + } + + area /= 2 + + if (Math.abs(area) < EPSILON) { + const fallback = polygon[0] ?? [0, 0] + return { area: 0, centroid: { x: fallback[0], y: fallback[1] } } + } + + return { + area: Math.abs(area), + centroid: { x: cx / (6 * area), y: cy / (6 * area) }, + } +} + +function planDistanceSq(a: MeasurementPoint, b: MeasurementPoint): number { + const dx = a[0] - b[0] + const dz = a[2] - b[2] + return dx * dx + dz * dz +} + +function projectPointToPlanSegment( + point: MeasurementPoint, + start: MeasurementPoint, + end: MeasurementPoint, +): { point: MeasurementPoint; t: number } | null { + const dx = end[0] - start[0] + const dz = end[2] - start[2] + const lengthSq = dx * dx + dz * dz + if (lengthSq < EPSILON) return null + const t = Math.min( + 1, + Math.max(0, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSq), + ) + return { + point: [start[0] + dx * t, start[1] + (end[1] - start[1]) * t, start[2] + dz * t], + t, + } +} + +function addPolygonGeometry( + geometry: MeasurementSnapGeometry, + polygon: ReadonlyArray, + y: number, + vertexLabel: string, + edgeLabel: string, + centerLabel: string, +) { + if (polygon.length === 0) return + const centroid = polygonAreaAndCentroid(polygon).centroid + const points = polygon.map((point) => [point[0], y, point[1]] as MeasurementPoint) + + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: vertexLabel, + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: edgeLabel, kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: centerLabel, + kind: 'center', + point: [centroid.x, y, centroid.y], + priority: 2, + }) +} + +function normalizePlanPolygon(polygon: unknown): ReadonlyArray | null { + const points = + Array.isArray(polygon) || !polygon || typeof polygon !== 'object' + ? polygon + : (polygon as UnknownPlanPolygonObject).points + if (!Array.isArray(points) || points.length < 3) return null + + const normalized = points + .map((point): readonly [number, number] | null => { + if (!Array.isArray(point) || point.length < 2) return null + const [x, z] = point + return typeof x === 'number' && typeof z === 'number' ? [x, z] : null + }) + .filter((point): point is readonly [number, number] => point !== null) + + return normalized.length >= 3 ? normalized : null +} + +function genericPolygonElevation(node: AnyNode): number { + const maybePosition = (node as { position?: unknown }).position + if (Array.isArray(maybePosition) && typeof maybePosition[1] === 'number') { + return maybePosition[1] + } + const maybeElevation = (node as { elevation?: unknown }).elevation + return typeof maybeElevation === 'number' ? maybeElevation : 0 +} + +function addGenericPolygonGeometry(geometry: MeasurementSnapGeometry, node: AnyNode): boolean { + const polygon = normalizePlanPolygon((node as { polygon?: unknown }).polygon) + if (!polygon) return false + + const y = genericPolygonElevation(node) + const labelPrefix = 'Polygon' + addPolygonGeometry( + geometry, + polygon, + y, + `${labelPrefix} vertex`, + `${labelPrefix} edge`, + `${labelPrefix} center`, + ) + + const holes = (node as { holes?: unknown }).holes + if (Array.isArray(holes)) { + for (const hole of holes) { + const normalizedHole = normalizePlanPolygon(hole) + if (!normalizedHole) continue + addPolygonGeometry( + geometry, + normalizedHole, + y, + `${labelPrefix} opening vertex`, + `${labelPrefix} opening edge`, + `${labelPrefix} opening center`, + ) + } + } + + return true +} + +function addRectangleGeometry( + geometry: MeasurementSnapGeometry, + polygon: ReadonlyArray, +) { + if (polygon.length === 0) return + const centroid = { + x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length, + y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length, + } + const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementPoint) + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: 'Corner', + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: 'Edge', kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: 'Center', + kind: 'center', + point: [centroid.x, 0, centroid.y], + priority: 2, + }) +} + +function addPlanAABBGeometry( + geometry: MeasurementSnapGeometry, + aabb: PlanAABB, + labels: { + center: string + edge: string + vertex: string + }, +) { + addRectangleGeometry(geometry, [ + { x: aabb.minX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.maxZ }, + { x: aabb.minX, y: aabb.maxZ }, + ]) + const addedAnchorCount = 9 + const addedSegmentCount = 4 + for (const anchor of geometry.anchors.slice(-addedAnchorCount)) { + if (anchor.kind === 'center') anchor.label = labels.center + else if (anchor.kind === 'midpoint') anchor.label = 'Edge midpoint' + else anchor.label = labels.vertex + } + for (const segment of geometry.segments.slice(-addedSegmentCount)) { + segment.label = labels.edge + } +} + +function addAlignmentAnchorGeometry( + geometry: MeasurementSnapGeometry, + node: AnyNode, + nodesById: Readonly>, +) { + const anchors = nodeAlignmentAnchors(node, nodesById) + if (anchors.length === 0) return + + const uniquePoints = new Map() + for (const anchor of anchors) { + const key = `${anchor.x.toFixed(6)}:${anchor.z.toFixed(6)}` + if (!uniquePoints.has(key)) uniquePoints.set(key, [anchor.x, 0, anchor.z]) + } + + const points = [...uniquePoints.values()] + if (points.length === 4) { + const xs = [...new Set(points.map((point) => point[0]))] + const zs = [...new Set(points.map((point) => point[2]))] + if (xs.length === 2 && zs.length === 2) { + addPlanAABBGeometry( + geometry, + { + minX: Math.min(...xs), + minZ: Math.min(...zs), + maxX: Math.max(...xs), + maxZ: Math.max(...zs), + }, + { + center: 'Footprint center', + edge: 'Footprint edge', + vertex: 'Footprint vertex', + }, + ) + return + } + } + + for (const point of points) { + geometry.anchors.push({ + label: 'Footprint vertex', + kind: 'vertex', + point, + priority: 0, + }) + } +} + +function addPathGeometry(geometry: MeasurementSnapGeometry, node: AnyNode) { + const path = (node as { path?: unknown }).path + if (!Array.isArray(path) || path.length < 2) return false + + const points = path + .map((point): MeasurementPoint | null => { + if (!Array.isArray(point) || point.length < 3) return null + const [x, , z] = point + return typeof x === 'number' && typeof z === 'number' ? [x, 0, z] : null + }) + .filter((point): point is MeasurementPoint => point !== null) + + if (points.length < 2) return false + + geometry.anchors.push( + { label: 'Run endpoint', kind: 'endpoint', point: points[0]!, priority: 0 }, + { label: 'Run endpoint', kind: 'endpoint', point: points[points.length - 1]!, priority: 0 }, + ) + + for (let index = 1; index < points.length - 1; index += 1) { + geometry.anchors.push({ + label: 'Run vertex', + kind: 'vertex', + point: points[index]!, + priority: 0, + }) + } + + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1]! + const end = points[index]! + geometry.anchors.push({ + label: 'Run midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ + label: 'Run edge', + kind: 'edge', + sourceId: node.id, + start, + end, + priority: 3, + }) + } + + return true +} + +function measurementGeometryContext( + node: AnyNode, + nodesById: Readonly>, +): GeometryContext { + const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? [] + return { + children: childIds.flatMap((id: AnyNodeId) => { + const child = nodesById[id] + return child ? [child] : [] + }), + parent: node.parentId ? (nodesById[node.parentId] ?? null) : null, + resolve: (id: AnyNodeId) => nodesById[id] as N | undefined, + siblings: node.parentId + ? Object.values(nodesById).filter( + (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id, + ) + : [], + } +} + +function addDefinitionSnapGeometry( + geometry: MeasurementSnapGeometry, + contribution: MeasurementDefinitionSnapGeometry, +): boolean { + const anchors = contribution.anchors ?? [] + const segments = contribution.segments ?? [] + geometry.anchors.push( + ...anchors.map((anchor) => ({ + ...anchor, + point: [...anchor.point] as MeasurementPoint, + targetLine: anchor.targetLine + ? { + end: [...anchor.targetLine.end] as MeasurementPoint, + start: [...anchor.targetLine.start] as MeasurementPoint, + } + : undefined, + })), + ) + geometry.segments.push( + ...segments.map((segment) => ({ + ...segment, + start: [...segment.start] as MeasurementPoint, + end: [...segment.end] as MeasurementPoint, + })), + ) + return anchors.length > 0 || segments.length > 0 +} + +function lineIntersection2D( + a: MeasurementSnapSegment, + b: MeasurementSnapSegment, +): MeasurementPoint | null { + if (a.sourceId && a.sourceId === b.sourceId && segmentsSharePlanEndpoint(a, b)) { + return null + } + + const x1 = a.start[0] + const y1 = a.start[2] + const x2 = a.end[0] + const y2 = a.end[2] + const x3 = b.start[0] + const y3 = b.start[2] + const x4 = b.end[0] + const y4 = b.end[2] + const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) + if (Math.abs(denominator) < EPSILON) return null + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denominator + const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denominator + if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return null + return [ + x1 + t * (x2 - x1), + (a.start[1] + a.end[1] + b.start[1] + b.end[1]) / 4, + y1 + t * (y2 - y1), + ] +} + +function segmentsSharePlanEndpoint(a: MeasurementSnapSegment, b: MeasurementSnapSegment): boolean { + return ( + planDistanceSq(a.start, b.start) < EPSILON || + planDistanceSq(a.start, b.end) < EPSILON || + planDistanceSq(a.end, b.start) < EPSILON || + planDistanceSq(a.end, b.end) < EPSILON + ) +} + +export function collectPlanMeasurementSnapGeometry( + nodes: ReadonlyArray, +): MeasurementSnapGeometry { + const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] } + const nodesById = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record< + string, + AnyNode + > + + for (const node of nodes) { + const contribution = collectNodePlanMeasurementSnapGeometry(node, nodesById) + geometry.anchors.push(...contribution.anchors) + geometry.segments.push(...contribution.segments) + } + + for (let i = 0; i < geometry.segments.length; i += 1) { + for (let j = i + 1; j < geometry.segments.length; j += 1) { + const point = lineIntersection2D(geometry.segments[i]!, geometry.segments[j]!) + if (point) + geometry.anchors.push({ label: 'Intersection', kind: 'intersection', point, priority: 0 }) + } + } + + return geometry +} + +export function collectNodePlanMeasurementSnapGeometry( + node: AnyNode, + nodesById: Readonly>, +): MeasurementSnapGeometry { + const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] } + const measurement = nodeRegistry.get(node.type)?.measurement + const contributed = measurement?.snapGeometry?.( + node as never, + measurementGeometryContext(node, nodesById), + ) + if (!(contributed && addDefinitionSnapGeometry(geometry, contributed))) { + if (!addGenericPolygonGeometry(geometry, node) && !addPathGeometry(geometry, node)) { + addAlignmentAnchorGeometry(geometry, node, nodesById) + } + } + + return { + anchors: geometry.anchors.map((anchor, index) => ({ + ...anchor, + attachment: { + feature: { index, kind: 'plan-anchor' }, + nodeId: node.id, + }, + })), + segments: geometry.segments.map((segment, index) => ({ + ...segment, + attachment: { + feature: { index, kind: 'plan-segment', t: 0 }, + nodeId: node.id, + }, + })), + } +} + +export function collectCommittedMeasurementSnapGeometry( + segments: ReadonlyArray, +): MeasurementSnapGeometry { + const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] } + + for (const segment of segments) { + const midpoint: MeasurementPoint = [ + (segment.start[0] + segment.end[0]) / 2, + (segment.start[1] + segment.end[1]) / 2, + (segment.start[2] + segment.end[2]) / 2, + ] + geometry.anchors.push( + { label: 'Measurement endpoint', kind: 'measurement', point: segment.start, priority: 0 }, + { label: 'Measurement midpoint', kind: 'measurement', point: midpoint, priority: 1 }, + { label: 'Measurement endpoint', kind: 'measurement', point: segment.end, priority: 0 }, + ) + geometry.segments.push({ + label: 'Measurement edge', + kind: 'measurement', + start: segment.start, + end: segment.end, + priority: 3, + }) + } + + return geometry +} + +export function mergeMeasurementSnapGeometry( + ...geometries: ReadonlyArray +): MeasurementSnapGeometry { + return { + anchors: geometries.flatMap((geometry) => geometry.anchors), + segments: geometries.flatMap((geometry) => geometry.segments), + } +} + +function isMeasurementSnapKindEnabled( + kind: MeasurementSnapKind, + enabledSnapKinds: Partial | undefined, +): boolean { + return enabledSnapKinds?.[kind] ?? true +} + +export function resolvePlanMeasurementSnap( + point: MeasurementPoint, + geometry: MeasurementSnapGeometry, + options: { + enabledSnapKinds?: Partial + radiusMeters: number + view: MeasurementView + gridStep?: number + }, +): { point: MeasurementPoint; target: MeasurementSnapTarget | null } { + const maxDistanceSq = options.radiusMeters * options.radiusMeters + let closest: MeasurementSnapAnchor | null = null + let closestScore = Number.POSITIVE_INFINITY + + const consider = ( + anchor: MeasurementSnapAnchor, + distanceSq = planDistanceSq(point, anchor.point), + ) => { + const kind = anchor.kind ?? measurementSnapKindFromLabel(anchor.label) + if (!isMeasurementSnapKindEnabled(kind, options.enabledSnapKinds)) return + if (distanceSq > maxDistanceSq) return + const priority = snapPriority(anchor) + const score = snapScore(distanceSq, priority) + if ( + score < closestScore - EPSILON || + (Math.abs(score - closestScore) <= EPSILON && priority < snapPriority(closest ?? anchor)) + ) { + closest = anchor + closestScore = score + } + } + + for (const anchor of geometry.anchors) { + consider(anchor) + } + + for (const segment of geometry.segments) { + const projection = projectPointToPlanSegment(point, segment.start, segment.end) + if (!projection) continue + consider({ + attachment: segment.attachment + ? { + ...segment.attachment, + feature: + segment.attachment.feature.kind === 'plan-segment' + ? { ...segment.attachment.feature, t: projection.t } + : segment.attachment.feature, + } + : undefined, + kind: segment.kind, + label: segment.label, + point: projection.point, + priority: segment.priority, + targetLine: { + end: segment.end, + start: segment.start, + }, + }) + } + + if (!closest && isMeasurementSnapKindEnabled('grid', options.enabledSnapKinds)) { + const gridStep = options.gridStep ?? DEFAULT_GRID_STEP + const gridPoint: MeasurementPoint = [ + Math.round(point[0] / gridStep) * gridStep, + point[1], + Math.round(point[2] / gridStep) * gridStep, + ] + if (planDistanceSq(point, gridPoint) <= maxDistanceSq) { + closest = { label: 'Grid', kind: 'grid', point: gridPoint, priority: DEFAULT_GRID_PRIORITY } + } + } + + return { + point: closest?.point ?? point, + target: closest + ? { + kind: closest.kind ?? measurementSnapKindFromLabel(closest.label), + attachment: closest.attachment, + label: closest.label, + point: closest.point, + targetLine: closest.targetLine, + view: options.view, + } + : null, + } +} + +export function resolvePlanMeasurementConstraint( + start: MeasurementPoint, + point: MeasurementPoint, + geometry: MeasurementSnapGeometry, + options: { + enabledSnapKinds?: Partial + radiusMeters: number + view: MeasurementView + }, +): { point: MeasurementPoint; target: MeasurementSnapTarget | null } { + if (!isMeasurementSnapKindEnabled('guide', options.enabledSnapKinds)) { + return { point, target: null } + } + + const cursorDx = point[0] - start[0] + const cursorDz = point[2] - start[2] + const cursorLengthSq = cursorDx * cursorDx + cursorDz * cursorDz + if (cursorLengthSq < EPSILON) return { point, target: null } + + const maxDistanceSq = options.radiusMeters * options.radiusMeters + type MeasurementConstraintGuideCandidate = { + guideLine: { end: MeasurementPoint; start: MeasurementPoint } + label: string + point: MeasurementPoint + score: number + } + let closest: MeasurementConstraintGuideCandidate | null = null + + const considerGuideDirection = ( + label: string, + direction: { x: number; z: number }, + scoreBias = 0, + minDistanceSq = 0, + ) => { + const projectionLength = cursorDx * direction.x + cursorDz * direction.z + const constrainedPoint: MeasurementPoint = [ + start[0] + direction.x * projectionLength, + point[1], + start[2] + direction.z * projectionLength, + ] + const distanceSq = planDistanceSq(point, constrainedPoint) + if (minDistanceSq > 0 && distanceSq <= minDistanceSq) return + if (distanceSq > maxDistanceSq) return + const score = distanceSq + scoreBias + if (!closest || score < closest.score) { + const guideLength = Math.max(Math.sqrt(cursorLengthSq), options.radiusMeters * 3) + closest = { + guideLine: { + start: [ + start[0] - direction.x * guideLength, + start[1], + start[2] - direction.z * guideLength, + ], + end: [ + start[0] + direction.x * guideLength, + start[1], + start[2] + direction.z * guideLength, + ], + }, + label, + point: constrainedPoint, + score, + } + } + } + + const diagonal = Math.SQRT1_2 + const polarGuideBias = options.radiusMeters * options.radiusMeters * 4 + const polarDirections = [ + { label: 'Polar guide 0°', x: 1, z: 0 }, + { label: 'Polar guide 90°', x: 0, z: 1 }, + { label: 'Polar guide 45°', x: diagonal, z: diagonal }, + { label: 'Polar guide 135°', x: -diagonal, z: diagonal }, + ] + for (const direction of polarDirections) { + considerGuideDirection(direction.label, direction, polarGuideBias, EPSILON) + } + + for (const segment of geometry.segments) { + const segmentKind = segment.kind ?? measurementSnapKindFromLabel(segment.label) + if (!isMeasurementSnapKindEnabled(segmentKind, options.enabledSnapKinds)) continue + + const hostPoint = projectPointToPlanSegment(start, segment.start, segment.end)?.point + if (!hostPoint || planDistanceSq(start, hostPoint) > maxDistanceSq) continue + + const segmentDx = segment.end[0] - segment.start[0] + const segmentDz = segment.end[2] - segment.start[2] + const segmentLength = Math.hypot(segmentDx, segmentDz) + if (segmentLength < EPSILON) continue + + const unitDirections = [ + { label: 'Parallel', x: segmentDx / segmentLength, z: segmentDz / segmentLength }, + { label: 'Perpendicular', x: -segmentDz / segmentLength, z: segmentDx / segmentLength }, + ] + + for (const direction of unitDirections) { + considerGuideDirection( + direction.label, + direction, + direction.label === 'Perpendicular' ? 0 : EPSILON, + ) + } + } + + const resolvedClosest = closest as MeasurementConstraintGuideCandidate | null + + return { + point: resolvedClosest?.point ?? point, + target: resolvedClosest + ? { + kind: measurementSnapKindFromLabel(resolvedClosest.label), + guideLine: resolvedClosest.guideLine, + label: resolvedClosest.label, + point: resolvedClosest.point, + view: options.view, + } + : null, + } +} diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts index a09b7eaf0..cc389c3d3 100644 --- a/packages/editor/src/lib/measurements.test.ts +++ b/packages/editor/src/lib/measurements.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' import { + angleBetweenMeasurements, + formatAngleMeasurement, formatAreaLabel, + formatAreaMeasurement, formatLinearMeasurement, getAreaUnitLabel, getLinearUnitLabel, @@ -16,11 +19,24 @@ describe('linear measurements', () => { expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m') }) + test('formats metric measurements at selectable precision', () => { + expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'coarse' })).toBe('3.5m') + expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'standard' })).toBe('3.46m') + expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'fine' })).toBe('3.457m') + }) + test('formats imperial measurements as feet and inches', () => { expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`) expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`) }) + test('formats imperial measurements at selectable precision', () => { + expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'coarse' })).toBe(`1'1"`) + expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'standard' })).toBe(`1'1"`) + expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'fine' })).toBe(`1'1"`) + expect(formatLinearMeasurement(0.3334, 'imperial', { precision: 'fine' })).toBe(`1'1 1/8"`) + }) + test('carries rounded 12 inches into the next foot', () => { expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`) }) @@ -78,6 +94,26 @@ describe('linear measurements', () => { }) describe('area measurements', () => { + test('formats metric surface areas in square meters', () => { + expect(formatAreaMeasurement(12, 'metric')).toBe('12m²') + expect(formatAreaMeasurement(12.34, 'metric')).toBe('12.3m²') + }) + + test('formats areas at selectable precision', () => { + expect(formatAreaMeasurement(12.345, 'metric', { precision: 'coarse' })).toBe('12m²') + expect(formatAreaMeasurement(12.345, 'metric', { precision: 'standard' })).toBe('12.3m²') + expect(formatAreaMeasurement(12.345, 'metric', { precision: 'fine' })).toBe('12.35m²') + }) + + test('formats imperial surface areas in rounded square feet', () => { + expect(formatAreaMeasurement(9.290304, 'imperial')).toBe('100ft²') + }) + + test('returns a placeholder for non-finite areas', () => { + expect(formatAreaMeasurement(NaN, 'metric')).toBe('--') + expect(formatAreaMeasurement(Infinity, 'imperial')).toBe('--') + }) + test('converts square meters to the active area unit', () => { expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0) expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5) @@ -95,3 +131,24 @@ describe('area measurements', () => { expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²') }) }) + +describe('angle measurements', () => { + test('measures angle around the vertex point', () => { + expect(angleBetweenMeasurements([1, 0, 0], [0, 0, 0], [0, 0, 1])).toBeCloseTo(Math.PI / 2) + }) + + test('formats angle measurements in degrees', () => { + expect(formatAngleMeasurement(Math.PI / 2)).toBe('90°') + expect(formatAngleMeasurement(Math.PI / 3)).toBe('60°') + }) + + test('formats angle measurements at selectable precision', () => { + expect(formatAngleMeasurement(Math.PI / 7, { precision: 'coarse' })).toBe('26°') + expect(formatAngleMeasurement(Math.PI / 7, { precision: 'standard' })).toBe('25.7°') + expect(formatAngleMeasurement(Math.PI / 7, { precision: 'fine' })).toBe('25.71°') + }) + + test('returns a placeholder for non-finite angles', () => { + expect(formatAngleMeasurement(NaN)).toBe('--') + }) +}) diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts index 76e31338c..668c381b3 100644 --- a/packages/editor/src/lib/measurements.ts +++ b/packages/editor/src/lib/measurements.ts @@ -1,4 +1,5 @@ export type LinearUnit = 'metric' | 'imperial' +export type MeasurementDisplayPrecision = 'coarse' | 'standard' | 'fine' const METERS_PER_FOOT = 0.3048 const FEET_PER_METER = 1 / METERS_PER_FOOT @@ -8,6 +9,34 @@ type LinearControlValueOptions = { maxMeters?: number } +type MeasurementFormatOptions = { + precision?: MeasurementDisplayPrecision +} + +const METRIC_DECIMALS: Record = { + coarse: 1, + standard: 2, + fine: 3, +} + +const AREA_DECIMALS: Record = { + coarse: 0, + standard: 1, + fine: 2, +} + +const ANGLE_DECIMALS: Record = { + coarse: 0, + standard: 1, + fine: 2, +} + +const IMPERIAL_INCH_DENOMINATOR: Record = { + coarse: 1, + standard: 2, + fine: 8, +} + export function metersToLinearUnit(meters: number, unit: LinearUnit): number { return unit === 'imperial' ? meters * FEET_PER_METER : meters } @@ -42,35 +71,101 @@ export function getAreaUnitLabel(unit: LinearUnit): string { return unit === 'imperial' ? 'ft²' : 'm²' } -export function formatAreaLabel( - squareMeters: number, - unit: LinearUnit, - fractionDigits = 1, -): string { - return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}` +function formatDecimal(value: number, decimals: number): string { + return Number.parseFloat(value.toFixed(decimals)).toString() } -export function formatLinearMeasurement(meters: number, unit: LinearUnit): string { +function formatInches(inches: number, denominator: number): string { + if (denominator === 1) return `${Math.round(inches)}"` + + const whole = Math.floor(inches) + const numerator = Math.round((inches - whole) * denominator) + if (numerator === 0) return `${whole}"` + if (numerator === denominator) return `${whole + 1}"` + return whole > 0 ? `${whole} ${numerator}/${denominator}"` : `${numerator}/${denominator}"` +} + +export function formatLinearMeasurement( + meters: number, + unit: LinearUnit, + options: MeasurementFormatOptions = {}, +): string { if (!Number.isFinite(meters)) return '--' const absoluteMeters = Math.abs(meters) + const precision = options.precision ?? 'standard' if (unit === 'imperial') { const feet = metersToLinearUnit(absoluteMeters, unit) let wholeFeet = Math.floor(feet) - let inches = Math.round((feet - wholeFeet) * 12) - if (inches === 12) { + const denominator = IMPERIAL_INCH_DENOMINATOR[precision] + let inches = Math.round((feet - wholeFeet) * 12 * denominator) / denominator + if (inches >= 12) { wholeFeet += 1 inches = 0 } const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : '' - return `${sign}${wholeFeet}'${inches}"` + return `${sign}${wholeFeet}'${formatInches(inches, denominator)}` } - const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2)) + const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(METRIC_DECIMALS[precision])) const sign = meters < 0 && roundedMeters !== 0 ? '-' : '' return `${sign}${roundedMeters}m` } + +export function formatAreaLabel( + squareMeters: number, + unit: LinearUnit, + fractionDigits = 1, +): string { + if (!Number.isFinite(squareMeters)) return '--' + return `${formatDecimal(squareMetersToAreaUnit(squareMeters, unit), fractionDigits)}${getAreaUnitLabel(unit)}` +} + +export function formatAreaMeasurement( + squareMeters: number, + unit: LinearUnit, + options: MeasurementFormatOptions = {}, +): string { + if (!Number.isFinite(squareMeters)) return '--' + + const absoluteSquareMeters = Math.abs(squareMeters) + const precision = options.precision ?? 'standard' + + if (unit === 'imperial') { + const squareFeet = squareMetersToAreaUnit(absoluteSquareMeters, unit) + return `${formatDecimal(squareFeet, AREA_DECIMALS[precision])}ft²` + } + + return `${formatDecimal(absoluteSquareMeters, AREA_DECIMALS[precision])}m²` +} + +export function angleBetweenMeasurements( + first: readonly [number, number, number], + vertex: readonly [number, number, number], + second: readonly [number, number, number], +): number { + const ax = first[0] - vertex[0] + const ay = first[1] - vertex[1] + const az = first[2] - vertex[2] + const bx = second[0] - vertex[0] + const by = second[1] - vertex[1] + const bz = second[2] - vertex[2] + const aLength = Math.hypot(ax, ay, az) + const bLength = Math.hypot(bx, by, bz) + if (aLength < 1e-4 || bLength < 1e-4) return 0 + const dot = (ax * bx + ay * by + az * bz) / (aLength * bLength) + return Math.acos(Math.min(1, Math.max(-1, dot))) +} + +export function formatAngleMeasurement( + radians: number, + options: MeasurementFormatOptions = {}, +): string { + if (!Number.isFinite(radians)) return '--' + const precision = options.precision ?? 'standard' + return `${formatDecimal((radians * 180) / Math.PI, ANGLE_DECIMALS[precision])}°` +} diff --git a/packages/editor/src/lib/node-event-ownership.test.ts b/packages/editor/src/lib/node-event-ownership.test.ts new file mode 100644 index 000000000..8b650f474 --- /dev/null +++ b/packages/editor/src/lib/node-event-ownership.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, emitter, sceneRegistry } from '@pascal-app/core' +import { useNodeEvents } from '@pascal-app/viewer' +import type { ThreeEvent } from '@react-three/fiber' +import { Group, Mesh, Vector3 } from 'three' + +afterEach(() => { + sceneRegistry.clear() +}) + +describe('node event ownership', () => { + test('emits a nested raycast hit only for its closest registered node', () => { + const wall = { id: 'wall_event_owner', type: 'wall' } as unknown as AnyNode + const level = { id: 'level_event_parent', type: 'level' } as unknown as AnyNode + const levelObject = new Group() + const wallObject = new Group() + const hitObject = new Mesh() + wallObject.add(hitObject) + levelObject.add(wallObject) + sceneRegistry.nodes.set(level.id, levelObject) + sceneRegistry.nodes.set(wall.id, wallObject) + sceneRegistry.nodeIds.set(levelObject, level.id) + sceneRegistry.nodeIds.set(wallObject, wall.id) + + const emitted: string[] = [] + const onWallMove = () => emitted.push('wall') + const onLevelMove = () => emitted.push('level') + emitter.on('wall:move', onWallMove) + emitter.on('level:move', onLevelMove) + + const event = { + face: null, + faceIndex: null, + object: hitObject, + point: new Vector3(1, 1, 0), + stopPropagation: () => {}, + } as unknown as ThreeEvent + + useNodeEvents(wall as never, 'wall').onPointerMove(event) + useNodeEvents(level as never, 'level').onPointerMove(event) + + emitter.off('wall:move', onWallMove) + emitter.off('level:move', onLevelMove) + expect(emitted).toEqual(['wall']) + }) +}) diff --git a/packages/editor/src/lib/register-measurement-test-nodes.ts b/packages/editor/src/lib/register-measurement-test-nodes.ts new file mode 100644 index 000000000..45528458e --- /dev/null +++ b/packages/editor/src/lib/register-measurement-test-nodes.ts @@ -0,0 +1,951 @@ +import { + type AnyNode, + type AnyNodeDefinition, + type GeometryContext, + getDutchRoofMetrics, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, + getRenderableSlabPolygon, + getWallCurveFrameAt, + getWallCurveLength, + type MeasurementDefinition, + type MeasurementDefinitionPoint, + type MeasurementDefinitionSnapGeometry, + nodeRegistry, + registerNode, + sampleFenceCenterline, + sampleWallCenterline, + sceneRegistry, + stairFootprintAABB, +} from '@pascal-app/core' +import { Vector3 } from 'three' +import { z } from 'zod' + +const CURVE_SNAP_SEGMENTS = 32 +const WALL_SIDE_NORMAL_Y_THRESHOLD = 0.7 +const WALL_SIDE_HEIGHT_EPSILON = 0.05 +const TestSchema = z.object({}).passthrough() + +type TestNode = AnyNode & Record +type PlanPoint = { x: number; y: number } +type RoofPlanPoint = readonly [number, number] +type RoofPlanSegment = readonly [RoofPlanPoint, RoofPlanPoint] + +function testDefinition(kind: string, measurement: MeasurementDefinition) { + return { + kind, + schemaVersion: 1, + schema: TestSchema, + category: 'structure', + defaults: () => ({}), + capabilities: {}, + measurement, + } as unknown as AnyNodeDefinition +} + +function polygonAreaAndCentroid(polygon: ReadonlyArray) { + let cx = 0 + let cy = 0 + let area = 0 + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const p1 = polygon[j]! + const p2 = polygon[i]! + const f = p1[0] * p2[1] - p2[0] * p1[1] + cx += (p1[0] + p2[0]) * f + cy += (p1[1] + p2[1]) * f + area += f + } + area /= 2 + if (Math.abs(area) < 1e-9) { + const fallback = polygon[0] ?? [0, 0] + return { area: 0, centroid: { x: fallback[0], y: fallback[1] } } + } + return { area: Math.abs(area), centroid: { x: cx / (6 * area), y: cy / (6 * area) } } +} + +function polygonPerimeter(polygon: ReadonlyArray) { + return polygon.reduce((sum, point, index) => { + const next = polygon[(index + 1) % polygon.length] ?? point + return sum + Math.hypot(next[0] - point[0], next[1] - point[1]) + }, 0) +} + +function addPolygonSnapGeometry( + geometry: Required, + polygon: ReadonlyArray, + y: number, + labels: { center: string; edge: string; vertex: string }, +) { + const points = polygon.map((point) => [point[0], y, point[1]] as MeasurementDefinitionPoint) + const centroid = polygonAreaAndCentroid(polygon).centroid + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: labels.vertex, + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: labels.center, + kind: 'center', + point: [centroid.x, y, centroid.y], + priority: 2, + }) +} + +function addRectangleSnapGeometry( + geometry: Required, + polygon: ReadonlyArray, + labels: { center: string; edge: string; vertex: string }, +) { + const centroid = { + x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length, + y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length, + } + const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementDefinitionPoint) + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: labels.vertex, + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: labels.center, + kind: 'center', + point: [centroid.x, 0, centroid.y], + priority: 2, + }) +} + +function rotatePlanPoint(x: number, z: number, rotation: number): PlanPoint { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return { x: x * cos + z * sin, y: -x * sin + z * cos } +} + +function rotatedRectanglePolygon( + center: PlanPoint, + width: number, + depth: number, + rotation: number, +) { + const corners: Array<[number, number]> = [ + [-width / 2, -depth / 2], + [width / 2, -depth / 2], + [width / 2, depth / 2], + [-width / 2, depth / 2], + ] + return corners.map(([x, z]) => { + const rotated = rotatePlanPoint(x, z, rotation) + return { x: center.x + rotated.x, y: center.y + rotated.y } + }) +} + +function closestPointOnSegment( + point: PlanPoint, + start: PlanPoint, + end: PlanPoint, +): { distanceSq: number; t: number } { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSq = dx * dx + dy * dy + if (lengthSq < 1e-9) { + const ox = point.x - start.x + const oy = point.y - start.y + return { distanceSq: ox * ox + oy * oy, t: 0 } + } + + const t = Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq), + ) + const projected = { x: start.x + dx * t, y: start.y + dy * t } + const ox = point.x - projected.x + const oy = point.y - projected.y + return { distanceSq: ox * ox + oy * oy, t } +} + +function wallFaceAnchor( + node: TestNode, + cursorPoint: readonly [number, number, number], + cursorNormal?: readonly [number, number, number] | null, +): [number, number, number] { + const planPoint = { x: cursorPoint[0], y: cursorPoint[2] } + const sampleCount = Math.max( + CURVE_SNAP_SEGMENTS, + Math.ceil(getWallCurveLength(node as never) / 0.25), + ) + let best: { + distanceSq: number + frameT: number + } | null = null + let previous = getWallCurveFrameAt(node as never, 0).point + + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(node as never, index / sampleCount).point + const projected = closestPointOnSegment(planPoint, previous, current) + if (!best || projected.distanceSq < best.distanceSq) { + best = { + distanceSq: projected.distanceSq, + frameT: (index - 1 + projected.t) / sampleCount, + } + } + previous = current + } + + const frame = getWallCurveFrameAt(node as never, best?.frameT ?? 0) + const signedCursorOffset = + (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y + const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0 + const normalSide = + cursorNormal && normalPlanLength > 1e-6 + ? Math.sign( + (cursorNormal[0] / normalPlanLength) * frame.normal.x + + (cursorNormal[2] / normalPlanLength) * frame.normal.y, + ) + : 0 + const cursorSide = Math.sign(signedCursorOffset) + const side = normalSide || cursorSide || 1 + const offset = ((node.thickness ?? 0.1) / 2) * side + + return [ + frame.point.x + frame.normal.x * offset, + cursorPoint[1], + frame.point.y + frame.normal.y * offset, + ] +} + +function fenceFaceAnchor( + node: TestNode, + cursorPoint: readonly [number, number, number], + cursorNormal?: readonly [number, number, number] | null, +): [number, number, number] { + const planPoint = { x: cursorPoint[0], y: cursorPoint[2] } + const sampleCount = Math.max( + CURVE_SNAP_SEGMENTS, + Math.ceil(getFenceCenterlineLength(node as never) / 0.25), + ) + let best: { + distanceSq: number + frameT: number + } | null = null + let previous = getFenceCenterlineFrameAt(node as never, 0).point + + for (let index = 1; index <= sampleCount; index += 1) { + const current = getFenceCenterlineFrameAt(node as never, index / sampleCount).point + const projected = closestPointOnSegment(planPoint, previous, current) + if (!best || projected.distanceSq < best.distanceSq) { + best = { + distanceSq: projected.distanceSq, + frameT: (index - 1 + projected.t) / sampleCount, + } + } + previous = current + } + + const frame = getFenceCenterlineFrameAt(node as never, best?.frameT ?? 0) + const signedCursorOffset = + (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y + const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0 + const normalSide = + cursorNormal && normalPlanLength > 1e-6 + ? Math.sign( + (cursorNormal[0] / normalPlanLength) * frame.normal.x + + (cursorNormal[2] / normalPlanLength) * frame.normal.y, + ) + : 0 + const cursorSide = Math.sign(signedCursorOffset) + const side = normalSide || cursorSide || 1 + const offset = ((node.thickness ?? 0.08) / 2) * side + + return [ + frame.point.x + frame.normal.x * offset, + cursorPoint[1], + frame.point.y + frame.normal.y * offset, + ] +} + +function roofSegmentPlanFrame(segment: TestNode, ctx: GeometryContext) { + const parentRoof = segment.parentId ? (ctx.resolve(segment.parentId as never) as TestNode) : null + const parentPosition = parentRoof?.position ?? [0, 0, 0] + const parentRotation = parentRoof?.rotation ?? 0 + const offset = rotatePlanPoint(segment.position[0], segment.position[2], parentRotation) + return { + center: { x: parentPosition[0] + offset.x, y: parentPosition[2] + offset.y }, + rotation: parentRotation + segment.rotation, + } +} + +function roofSegmentPlanLinework(node: TestNode) { + const hw = node.width / 2 + const hd = node.depth / 2 + const ridges: RoofPlanSegment[] = [] + const hips: RoofPlanSegment[] = [] + const breaks: RoofPlanSegment[] = [] + let slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null = null + const e1: RoofPlanPoint = [-hw, hd] + const e2: RoofPlanPoint = [hw, hd] + const e3: RoofPlanPoint = [hw, -hd] + const e4: RoofPlanPoint = [-hw, -hd] + const pushHip = () => { + if (Math.abs(node.width - node.depth) < 0.01) { + const peak: RoofPlanPoint = [0, 0] + hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak]) + } else if (node.width >= node.depth) { + const r1: RoofPlanPoint = [-hw + hd, 0] + const r2: RoofPlanPoint = [hw - hd, 0] + ridges.push([r1, r2]) + hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2]) + } else { + const r1: RoofPlanPoint = [0, hd - hw] + const r2: RoofPlanPoint = [0, -hd + hw] + ridges.push([r1, r2]) + hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2]) + } + } + switch (node.roofType) { + case 'gable': + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + break + case 'shed': + slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] } + break + case 'hip': + pushHip() + break + case 'gambrel': { + const mz = hd * node.gambrelLowerWidthRatio + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + breaks.push( + [ + [-hw, mz], + [hw, mz], + ], + [ + [-hw, -mz], + [hw, -mz], + ], + ) + break + } + case 'mansard': { + const inset = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio + if (hw - inset > 0.02 && hd - inset > 0.02) { + const w1: RoofPlanPoint = [-hw + inset, hd - inset] + const w2: RoofPlanPoint = [hw - inset, hd - inset] + const w3: RoofPlanPoint = [hw - inset, -hd + inset] + const w4: RoofPlanPoint = [-hw + inset, -hd + inset] + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + } else { + pushHip() + } + break + } + case 'dutch': { + const metrics = getDutchRoofMetrics(node as never) + if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) { + pushHip() + break + } + const w1: RoofPlanPoint = [-metrics.waistHalfX, metrics.waistHalfZ] + const w2: RoofPlanPoint = [metrics.waistHalfX, metrics.waistHalfZ] + const w3: RoofPlanPoint = [metrics.waistHalfX, -metrics.waistHalfZ] + const w4: RoofPlanPoint = [-metrics.waistHalfX, -metrics.waistHalfZ] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + ridges.push([metrics.ridgeStart, metrics.ridgeEnd]) + break + } + } + return { breaks, hips, ridges, slope } +} + +function addRoofPlanSegment( + geometry: Required, + segment: RoofPlanSegment, + toPlan: (point: RoofPlanPoint) => MeasurementDefinitionPoint, + label: string, + sourceId: string, +) { + const start = toPlan(segment[0]) + const end = toPlan(segment[1]) + geometry.anchors.push( + { label: `${label} endpoint`, kind: 'endpoint', point: start, priority: 0 }, + { + label: `${label} midpoint`, + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }, + { label: `${label} endpoint`, kind: 'endpoint', point: end, priority: 0 }, + ) + geometry.segments.push({ + label: `${label} edge`, + kind: 'edge', + sourceId, + start, + end, + priority: 3, + }) +} + +function surfaceMeasurement(): MeasurementDefinition { + const boundaryPolygon = (node: TestNode): Array<[number, number]> => + node.type === 'slab' ? getRenderableSlabPolygon(node as never) : node.polygon + + return { + applyLiveTransform: (node, live) => + ({ + ...node, + holes: (node.holes ?? []).map((hole: [number, number][]) => + hole.map(([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number]), + ), + polygon: node.polygon.map( + ([x, z]: [number, number]) => + [x + live.position[0], z + live.position[2]] as [number, number], + ), + }) as AnyNode, + area: (node) => { + const boundary = boundaryPolygon(node) + const outer = polygonAreaAndCentroid(boundary) + const holes = node.holes ?? [] + const holesArea = holes.reduce((sum: number, hole: [number, number][]) => { + return sum + polygonAreaAndCentroid(hole).area + }, 0) + const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0 + return { + areaSquareMeters: Math.max(0, outer.area - holesArea), + boundaryPoints: boundary.map((point: [number, number]) => [point[0], y + 0.02, point[1]]), + labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y], + } + }, + perimeter: (node) => { + const boundary = boundaryPolygon(node) + const outer = polygonAreaAndCentroid(boundary) + const holes = node.holes ?? [] + const holesLength = holes.reduce((sum: number, hole: [number, number][]) => { + return sum + polygonPerimeter(hole) + }, 0) + const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0 + return { + boundaryPoints: boundary.map((point: [number, number]) => [point[0], y + 0.02, point[1]]), + labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y], + lengthMeters: polygonPerimeter(boundary) + holesLength, + } + }, + snapGeometry: (node) => { + const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0 + const geometry: Required = { anchors: [], segments: [] } + addPolygonSnapGeometry(geometry, boundaryPolygon(node), y, { + center: 'Center', + edge: 'Edge', + vertex: 'Vertex', + }) + for (const hole of node.holes ?? []) { + addPolygonSnapGeometry(geometry, hole, y, { + center: 'Surface opening center', + edge: 'Surface opening edge', + vertex: 'Surface opening vertex', + }) + } + return geometry + }, + } +} + +function surfacePerimeterWithoutBoundaryMeasurement(): MeasurementDefinition { + const measurement = surfaceMeasurement() + return { + area: measurement.area, + applyLiveTransform: measurement.applyLiveTransform, + perimeter: (node, ctx) => { + const perimeter = measurement.perimeter?.(node, ctx) + if (!perimeter) return null + return { + labelPoint: perimeter.labelPoint, + lengthMeters: perimeter.lengthMeters, + } + }, + snapGeometry: measurement.snapGeometry, + } +} + +function wallMeasurement(): MeasurementDefinition { + return { + directLength: (node, _ctx, cursorPoint, cursorNormal) => { + const height = node.height ?? 2.5 + const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null + const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD + const isWallSideHover = + !cursorNormal && + cursorPoint && + cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON && + cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON + if (cursorPoint && (isSideNormal || isWallSideHover)) { + const anchor = wallFaceAnchor(node, cursorPoint, cursorNormal) + return { + start: [anchor[0], 0, anchor[2]], + end: [anchor[0], height, anchor[2]], + measuredDistanceMeters: height, + } + } + return { + start: [node.start[0], 0, node.start[1]], + end: [node.end[0], 0, node.end[1]], + measuredDistanceMeters: getWallCurveLength(node as never), + } + }, + snapGeometry: (node) => { + const midpoint = getWallCurveFrameAt(node as never, 0.5).point + const geometry: Required = { + anchors: [ + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.start[0], 0, node.start[1]], + priority: 0, + }, + { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 }, + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.end[0], 0, node.end[1]], + priority: 0, + }, + ], + segments: [], + } + const samples = sampleWallCenterline(node as never, CURVE_SNAP_SEGMENTS) + for (let index = 1; index < samples.length; index += 1) { + const startPoint = samples[index - 1]! + const endPoint = samples[index]! + geometry.segments.push({ + label: 'Wall edge', + kind: 'edge', + sourceId: node.id, + start: [startPoint.x, 0, startPoint.y], + end: [endPoint.x, 0, endPoint.y], + priority: 3, + }) + } + return geometry + }, + } +} + +function fenceMeasurement(): MeasurementDefinition { + return { + directLength: (node, _ctx, cursorPoint, cursorNormal) => { + const height = node.height ?? 1.8 + const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null + const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD + const isFenceSideHover = + !cursorNormal && + cursorPoint && + cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON && + cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON + if (cursorPoint && (isSideNormal || isFenceSideHover)) { + const anchor = fenceFaceAnchor(node, cursorPoint, cursorNormal) + return { + start: [anchor[0], 0, anchor[2]], + end: [anchor[0], height, anchor[2]], + measuredDistanceMeters: height, + } + } + return { + start: [node.start[0], 0, node.start[1]], + end: [node.end[0], 0, node.end[1]], + measuredDistanceMeters: getFenceCenterlineLength(node as never), + } + }, + snapGeometry: (node) => { + const midpoint = getFenceCenterlineFrameAt(node as never, 0.5).point + const geometry: Required = { + anchors: [ + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.start[0], 0, node.start[1]], + priority: 0, + }, + ...(node.path ?? []).map((point: [number, number]) => ({ + label: 'Path point', + kind: 'vertex' as const, + point: [point[0], 0, point[1]] as MeasurementDefinitionPoint, + priority: 0, + })), + { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 }, + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.end[0], 0, node.end[1]], + priority: 0, + }, + ], + segments: [], + } + const samples = sampleFenceCenterline(node as never, CURVE_SNAP_SEGMENTS) + for (let index = 1; index < samples.length; index += 1) { + const startPoint = samples[index - 1]! + const endPoint = samples[index]! + geometry.segments.push({ + label: 'Fence edge', + kind: 'edge', + sourceId: node.id, + start: [startPoint.x, 0, startPoint.y], + end: [endPoint.x, 0, endPoint.y], + priority: 3, + }) + } + return geometry + }, + } +} + +function openingMeasurement(): MeasurementDefinition { + type OpeningFrame = { + bottomY: number + centerX: number + centerZ: number + dirX: number + dirZ: number + leftS: number + normalX: number + normalZ: number + rightS: number + topY: number + wallStart: readonly [number, number] + } + + const frameForOpening = (node: TestNode, ctx: GeometryContext): OpeningFrame | null => { + const host = node.wallId ? (ctx.resolve(node.wallId as never) as TestNode) : null + if (!host) return null + const dx = host.end[0] - host.start[0] + const dz = host.end[1] - host.start[1] + const hostLength = Math.hypot(dx, dz) + if (hostLength < 1e-4 || node.width < 1e-4 || node.height < 1e-4) return null + const dirX = dx / hostLength + const dirZ = dz / hostLength + const halfWidth = node.width / 2 + const centerS = node.position[0] + const centerX = host.start[0] + dirX * centerS + const centerZ = host.start[1] + dirZ * centerS + const centerY = node.position[1] + return { + bottomY: centerY - node.height / 2, + centerX, + centerZ, + dirX, + dirZ, + leftS: centerS - halfWidth, + normalX: -dirZ, + normalZ: dirX, + rightS: centerS + halfWidth, + topY: centerY + node.height / 2, + wallStart: host.start, + } + } + + const pointFromFrame = ( + frame: OpeningFrame, + alongWall: number, + y: number, + faceOffset: number, + ): MeasurementDefinitionPoint => [ + frame.wallStart[0] + frame.dirX * alongWall + frame.normalX * faceOffset, + y, + frame.wallStart[1] + frame.dirZ * alongWall + frame.normalZ * faceOffset, + ] + + const directLength = ( + node: TestNode, + ctx: GeometryContext, + cursorPoint?: MeasurementDefinitionPoint | null, + ) => { + const frame = frameForOpening(node, ctx) + if (!frame) return null + if (cursorPoint) { + const faceOffset = + (cursorPoint[0] - frame.centerX) * frame.normalX + + (cursorPoint[2] - frame.centerZ) * frame.normalZ + const offset = Math.abs(faceOffset) > 1e-6 ? faceOffset : (node.position[2] ?? 0) + const cursorS = + (cursorPoint[0] - frame.wallStart[0]) * frame.dirX + + (cursorPoint[2] - frame.wallStart[1]) * frame.dirZ + const cursorY = cursorPoint[1] + const horizontalDistanceSq = (edgeY: number) => { + const clampedS = Math.max(frame.leftS, Math.min(frame.rightS, cursorS)) + const ds = cursorS - clampedS + const dy = cursorY - edgeY + return ds * ds + dy * dy + } + const verticalDistanceSq = (edgeS: number) => { + const clampedY = Math.max(frame.bottomY, Math.min(frame.topY, cursorY)) + const ds = cursorS - edgeS + const dy = cursorY - clampedY + return ds * ds + dy * dy + } + const candidates = [ + { + distanceSq: horizontalDistanceSq(frame.bottomY), + end: pointFromFrame(frame, frame.rightS, frame.bottomY, offset), + measuredDistanceMeters: node.width, + start: pointFromFrame(frame, frame.leftS, frame.bottomY, offset), + }, + { + distanceSq: horizontalDistanceSq(frame.topY), + end: pointFromFrame(frame, frame.rightS, frame.topY, offset), + measuredDistanceMeters: node.width, + start: pointFromFrame(frame, frame.leftS, frame.topY, offset), + }, + { + distanceSq: verticalDistanceSq(frame.leftS), + end: pointFromFrame(frame, frame.leftS, frame.topY, offset), + measuredDistanceMeters: node.height, + start: pointFromFrame(frame, frame.leftS, frame.bottomY, offset), + }, + { + distanceSq: verticalDistanceSq(frame.rightS), + end: pointFromFrame(frame, frame.rightS, frame.topY, offset), + measuredDistanceMeters: node.height, + start: pointFromFrame(frame, frame.rightS, frame.bottomY, offset), + }, + ] + return candidates.reduce((best, candidate) => + candidate.distanceSq < best.distanceSq ? candidate : best, + ) + } + return { + start: pointFromFrame(frame, frame.leftS, 0, node.position[2] ?? 0), + end: pointFromFrame(frame, frame.rightS, 0, node.position[2] ?? 0), + measuredDistanceMeters: node.width, + } + } + return { + directLength, + snapGeometry: (node, ctx) => { + const segment = directLength(node, ctx) + if (!segment) return null + return { + anchors: [ + { label: 'Opening endpoint', kind: 'endpoint', point: segment.start, priority: 0 }, + { + label: 'Opening center', + kind: 'center', + point: [ + (segment.start[0] + segment.end[0]) / 2, + 0, + (segment.start[2] + segment.end[2]) / 2, + ], + priority: 0, + }, + { label: 'Opening endpoint', kind: 'endpoint', point: segment.end, priority: 0 }, + ], + segments: [ + { + label: 'Opening edge', + kind: 'edge', + sourceId: node.id, + start: segment.start, + end: segment.end, + priority: 2, + }, + ], + } + }, + } +} + +function siteMeasurement(): MeasurementDefinition { + return { + pick3D: false, + snapGeometry: (node) => { + const polygon = Array.isArray(node.polygon) ? node.polygon : node.polygon.points + const geometry: Required = { anchors: [], segments: [] } + addPolygonSnapGeometry(geometry, polygon, 0, { + center: 'Property line center', + edge: 'Property line edge', + vertex: 'Property line vertex', + }) + return geometry + }, + } +} + +function stairMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const nodes = Object.fromEntries( + [node, ...ctx.children, ...ctx.siblings, ...(ctx.parent ? [ctx.parent] : [])].map( + (entry) => [entry.id, entry], + ), + ) + const aabb = stairFootprintAABB(node as never, nodes) + if (!aabb) return null + const geometry: Required = { anchors: [], segments: [] } + addRectangleSnapGeometry( + geometry, + [ + { x: aabb.minX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.maxZ }, + { x: aabb.minX, y: aabb.maxZ }, + ], + { center: 'Stair center', edge: 'Stair edge', vertex: 'Stair corner' }, + ) + return geometry + }, + } +} + +function roofSegmentMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const { center, rotation } = roofSegmentPlanFrame(node, ctx) + const geometry: Required = { anchors: [], segments: [] } + addRectangleSnapGeometry( + geometry, + rotatedRectanglePolygon(center, node.width, node.depth, rotation), + { + center: 'Roof center', + edge: 'Roof eave edge', + vertex: 'Roof eave corner', + }, + ) + for (const anchor of geometry.anchors) { + if (anchor.label === 'Roof eave corner') anchor.priority = -1 + } + const toPlan = ([localX, localZ]: RoofPlanPoint): MeasurementDefinitionPoint => { + const offsetPoint = rotatePlanPoint(localX, localZ, rotation) + return [center.x + offsetPoint.x, 0, center.y + offsetPoint.y] + } + const linework = roofSegmentPlanLinework(node) + for (const ridge of linework.ridges) + addRoofPlanSegment(geometry, ridge, toPlan, 'Roof ridge', `${node.id}:ridge`) + for (const hip of linework.hips) + addRoofPlanSegment(geometry, hip, toPlan, 'Roof hip', `${node.id}:hip`) + for (const roofBreak of linework.breaks) + addRoofPlanSegment(geometry, roofBreak, toPlan, 'Roof break', `${node.id}:break`) + if (linework.slope) { + addRoofPlanSegment( + geometry, + [linework.slope.tail, linework.slope.head], + toPlan, + 'Roof slope', + `${node.id}:slope`, + ) + } + return geometry + }, + } +} + +function skylightMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const segment = node.roofSegmentId + ? (ctx.resolve(node.roofSegmentId as never) as TestNode) + : null + if (!segment) return null + const frame = roofSegmentPlanFrame(segment, ctx) + const centerOffset = rotatePlanPoint(node.position[0], node.position[2], frame.rotation) + const center = { x: frame.center.x + centerOffset.x, y: frame.center.y + centerOffset.y } + const geometry: Required = { anchors: [], segments: [] } + addRectangleSnapGeometry( + geometry, + rotatedRectanglePolygon( + center, + node.width, + node.height, + frame.rotation + (node.rotation ?? 0), + ), + { center: 'Skylight center', edge: 'Skylight edge', vertex: 'Skylight corner' }, + ) + return geometry + }, + } +} + +function roofMeasurement(): MeasurementDefinition { + return { + resolveOwnerId: ({ ctx }) => + (ctx.children.find((child) => child.type === 'roof-segment')?.id as never) ?? null, + } +} + +function cabinetMeasurement(): MeasurementDefinition { + return { + resolveOwnerId: ({ ctx, node, worldPoint }) => { + const cabinetObject = sceneRegistry.nodes.get(node.id) + const local = cabinetObject + ? cabinetObject.worldToLocal(new Vector3(...worldPoint)) + : new Vector3(...worldPoint) + let best: { id: string; score: number } | null = null + for (const child of ctx.children) { + if (child.type !== 'cabinet-module') continue + const position = child.position ?? [0, 0, 0] + const halfWidth = child.width / 2 + Math.max(child.countertopOverhang ?? 0, 0.05) + const halfDepth = + child.depth / 2 + + Math.max(child.countertopOverhang ?? 0, child.countertopBackOverhang ?? 0, 0.05) + const dx = Math.abs(local.x - position[0]) + const dz = Math.abs(local.z - position[2]) + if (dx > halfWidth || dz > halfDepth) continue + const score = dx / Math.max(halfWidth, 1e-4) + dz / Math.max(halfDepth, 1e-4) + if (!best || score < best.score) best = { id: child.id, score } + } + return (best?.id as never) ?? null + }, + } +} + +export function registerMeasurementTestNodes() { + for (const [kind, measurement] of [ + ['wall', wallMeasurement()], + ['fence', fenceMeasurement()], + ['slab', surfaceMeasurement()], + ['ceiling', surfaceMeasurement()], + ['zone', surfaceMeasurement()], + ['site', siteMeasurement()], + ['door', openingMeasurement()], + ['window', openingMeasurement()], + ['stair', stairMeasurement()], + ['roof', roofMeasurement()], + ['roof-segment', roofSegmentMeasurement()], + ['cabinet', cabinetMeasurement()], + ['skylight', skylightMeasurement()], + ['surface-perimeter-without-boundary', surfacePerimeterWithoutBoundaryMeasurement()], + ] as const) { + if (!nodeRegistry.has(kind)) registerNode(testDefinition(kind, measurement)) + } +} diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts new file mode 100644 index 000000000..42d17d346 --- /dev/null +++ b/packages/editor/src/lib/scene.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { useScene } from '@pascal-app/core' +import { BuildingNode, LevelNode, SiteNode } from '@pascal-app/core/schema' +import { serializeMeasurements, useMeasurementTool } from '../store/use-measurement-tool' +import { + applySceneGraphToEditor, + loadSceneFromLocalStorage, + type SceneGraph, + saveSceneToLocalStorage, +} from './scene' + +const storage = new Map() + +globalThis.localStorage = { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + key: (index: number) => Array.from(storage.keys())[index] ?? null, + get length() { + return storage.size + }, + removeItem: (key: string) => { + storage.delete(key) + }, + setItem: (key: string, value: string) => { + storage.set(key, value) + }, +} as Storage + +function makeSceneGraph(): SceneGraph { + const site = SiteNode.parse({}) + const building = BuildingNode.parse({ parentId: site.id }) + const level = LevelNode.parse({ level: 0, parentId: building.id }) + + return { + nodes: { + [site.id]: { ...site, children: [building.id] }, + [building.id]: { ...building, children: [level.id] }, + [level.id]: level, + }, + rootNodeIds: [site.id], + } +} + +afterEach(() => { + useScene.getState().clearScene() + useMeasurementTool.getState().clear() + storage.clear() +}) + +describe('scene measurement persistence', () => { + test('saves and loads measurements with the scene graph payload', () => { + useMeasurementTool.getState().addSegment('2d', [0, 0, 0], [4, 0, 0]) + useMeasurementTool.getState().addArea('3d', [1, 0, 1], 24) + const sceneGraph = { + ...makeSceneGraph(), + measurements: serializeMeasurements(), + } + + saveSceneToLocalStorage(sceneGraph) + const loaded = loadSceneFromLocalStorage() + + expect(loaded?.measurements).toEqual(sceneGraph.measurements) + }) + + test('hydrates measurements when applying a scene and clears them for empty scenes', () => { + useMeasurementTool.getState().addSegment('2d', [0, 0, 0], [4, 0, 0]) + const sceneGraph = { + ...makeSceneGraph(), + measurements: serializeMeasurements(), + } + useMeasurementTool.getState().clear() + + applySceneGraphToEditor(sceneGraph) + expect(useMeasurementTool.getState().segments).toEqual(sceneGraph.measurements.segments) + + applySceneGraphToEditor(null) + expect(serializeMeasurements()).toEqual({ + version: 1, + segments: [], + areas: [], + perimeters: [], + angles: [], + }) + }) +}) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 683c66681..85e270a8f 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -7,6 +7,7 @@ import useEditor, { normalizePersistedEditorUiState, type PersistedEditorUiState, } from '../store/use-editor' +import { hydrateMeasurements, type PersistedMeasurements } from '../store/use-measurement-tool' export type SceneGraph = { nodes: Record @@ -15,6 +16,7 @@ export type SceneGraph = { // payloads (and callers that only build nodes) stay valid. collections?: Record materials?: Record + measurements?: PersistedMeasurements } type PersistedSelectionPath = { @@ -352,6 +354,7 @@ function resetEditorInteractionState() { outliner.selectedObjects.length = 0 outliner.hoveredObjects.length = 0 sceneRegistry.clear() + hydrateMeasurements(null) useEditor.setState({ phase: 'site', mode: 'select', @@ -381,8 +384,10 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { collections: collections as any, materials: materials as any, }) + hydrateMeasurements(sceneGraph.measurements) } else { useScene.getState().clearScene() + hydrateMeasurements(null) } syncEditorSelectionFromCurrentScene() diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts index d0f1841a8..1741a82e9 100644 --- a/packages/editor/src/lib/stair-levels.ts +++ b/packages/editor/src/lib/stair-levels.ts @@ -7,7 +7,15 @@ import { type StairNode, } from '@pascal-app/core' -function sortLevelsByHeight(levels: LevelNodeType[]) { +type StairDestinationLevel = { + buildingId: AnyNodeId | null + createdLevel: LevelNodeType | null + fromLevel: LevelNodeType + levels: LevelNodeType[] + toLevel: LevelNodeType +} + +function sortLevelsByHeight(levels: LevelNodeType[]): LevelNodeType[] { return [...levels].sort((left, right) => left.level - right.level) } @@ -15,7 +23,7 @@ function isLevelNode(node: AnyNode | undefined): node is LevelNodeType { return node?.type === 'level' } -function getAllSceneLevels(nodes: Record) { +function getAllSceneLevels(nodes: Record): LevelNodeType[] { return sortLevelsByHeight( Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'), ) @@ -25,7 +33,7 @@ function getBuildingLevels( nodes: Record, buildingId: AnyNodeId | string | null | undefined, source?: LevelNodeType, -) { +): LevelNodeType[] { if (!buildingId) return source ? [source] : [] const building = nodes[buildingId as AnyNodeId] if (building?.type !== 'building') return source ? [source] : [] @@ -50,7 +58,7 @@ function getBuildingLevels( export function getBuildingLevelsForLevel( nodes: Record, levelId: AnyNodeId | string | null | undefined, -) { +): LevelNodeType[] { if (!levelId) return [] const source = nodes[levelId as AnyNodeId] if (!isLevelNode(source)) return [] @@ -62,7 +70,10 @@ export function getBuildingLevelsForLevel( return getBuildingLevels(nodes, buildingId, source) } -export function getStairLevelOptions(nodes: Record, stair: StairNode) { +export function getStairLevelOptions( + nodes: Record, + stair: StairNode, +): LevelNodeType[] { for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) { if (isLevelNode(nodes[candidateId as AnyNodeId])) { return getBuildingLevelsForLevel(nodes, candidateId) @@ -76,7 +87,7 @@ export function resolveStairPlacementLevelId( nodes: Record, preferredLevelId: AnyNodeId | string | null | undefined, preferredBuildingId?: AnyNodeId | string | null, -) { +): LevelNodeType['id'] | null { if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) { return preferredLevelId as LevelNodeType['id'] } @@ -88,11 +99,13 @@ export function resolveStairPlacementLevelId( export function resolveStairFromLevelId( nodes: Record, stair: StairNode, - levels = getStairLevelOptions(nodes, stair), -) { + levels: LevelNodeType[] = getStairLevelOptions(nodes, stair), +): LevelNodeType['id'] | null { const optionIds = new Set(levels.map((level) => level.id)) - if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId - if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId + if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) { + return stair.fromLevelId as LevelNodeType['id'] + } + if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId as LevelNodeType['id'] const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined if (isLevelNode(toLevel)) { @@ -107,11 +120,11 @@ export function resolveStairToLevelId( nodes: Record, stair: StairNode, fromLevelId: AnyNodeId | string | null | undefined, - levels = getStairLevelOptions(nodes, stair), -) { + levels: LevelNodeType[] = getStairLevelOptions(nodes, stair), +): LevelNodeType['id'] | null { const optionIds = new Set(levels.map((level) => level.id)) if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) { - return stair.toLevelId + return stair.toLevelId as LevelNodeType['id'] } const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined @@ -130,7 +143,7 @@ export function resolveStairDestinationLevel({ createMissing?: boolean fromLevelId: AnyNodeId | string | null | undefined nodes: Record -}) { +}): StairDestinationLevel | null { if (!fromLevelId) return null const fromLevel = nodes[fromLevelId as AnyNodeId] if (!isLevelNode(fromLevel)) return null diff --git a/packages/editor/src/r3f.d.ts b/packages/editor/src/r3f.d.ts new file mode 100644 index 000000000..203ecf161 --- /dev/null +++ b/packages/editor/src/r3f.d.ts @@ -0,0 +1,25 @@ +export {} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + lineBasicNodeMaterial: any + } + } +} + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements { + lineBasicNodeMaterial: any + } + } +} + +declare module 'react/jsx-dev-runtime' { + namespace JSX { + interface IntrinsicElements { + lineBasicNodeMaterial: any + } + } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f2521a2b0..c3ac1a3c9 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -146,6 +146,7 @@ export type StructureTool = | 'column' | 'elevator' | 'stair' + | 'measurement' | 'item' | 'zone' | 'spawn' @@ -442,6 +443,9 @@ type EditorState = { showReferenceFloor: boolean toggleReferenceFloor: () => void setShowReferenceFloor: (show: boolean) => void + showMeasurements: boolean + toggleMeasurements: () => void + setShowMeasurements: (show: boolean) => void referenceFloorOffset: number setReferenceFloorOffset: (offset: number) => void referenceFloorOpacity: number @@ -490,6 +494,7 @@ type PersistedEditorLayoutState = Pick< | 'snappingModeByContext' | 'continuationByContext' | 'showReferenceFloor' + | 'showMeasurements' | 'referenceFloorOffset' | 'referenceFloorOpacity' > @@ -524,6 +529,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = cabinet: CONTINUATION_PROFILES.cabinet.default, }, showReferenceFloor: false, + showMeasurements: false, referenceFloorOffset: 1, referenceFloorOpacity: 0.35, } @@ -691,6 +697,7 @@ function normalizePersistedEditorLayoutState( }, continuationByContext: normalizeContinuationByContext(state), showReferenceFloor: state?.showReferenceFloor === true, + showMeasurements: state?.showMeasurements === true, referenceFloorOffset: typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1 ? Math.floor(state.referenceFloorOffset) @@ -1193,6 +1200,9 @@ const useEditor = create()( toggleReferenceFloor: () => set((state) => ({ showReferenceFloor: !state.showReferenceFloor })), setShowReferenceFloor: (show) => set({ showReferenceFloor: show }), + showMeasurements: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showMeasurements, + toggleMeasurements: () => set((state) => ({ showMeasurements: !state.showMeasurements })), + setShowMeasurements: (show) => set({ showMeasurements: show }), referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset, setReferenceFloorOffset: (offset) => set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }), @@ -1284,6 +1294,7 @@ const useEditor = create()( snappingModeByContext: state.snappingModeByContext, continuationByContext: state.continuationByContext, showReferenceFloor: state.showReferenceFloor, + showMeasurements: state.showMeasurements, referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, }), diff --git a/packages/editor/src/store/use-measurement-tool.test.ts b/packages/editor/src/store/use-measurement-tool.test.ts new file mode 100644 index 000000000..b2e523f10 --- /dev/null +++ b/packages/editor/src/store/use-measurement-tool.test.ts @@ -0,0 +1,877 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { AnyNode, ItemNode, useScene } from '@pascal-app/core' +import { syncLinearMeasurementSceneNodes } from '../lib/measurement-scene-nodes' +import { registerMeasurementTestNodes } from '../lib/register-measurement-test-nodes' +import { + axisLockedMeasurementPoint, + DEFAULT_MEASUREMENT_SNAP_SETTINGS, + distanceBetweenMeasurements, + hydrateMeasurements, + isDraggingMeasurementEndpoint, + normalizePersistedMeasurements, + serializeMeasurements, + useMeasurementTool, +} from './use-measurement-tool' + +beforeAll(() => { + registerMeasurementTestNodes() +}) + +afterEach(() => { + useScene.getState().clearScene() + useMeasurementTool.getState().clear() + useMeasurementTool.getState().setContinuousMeasurement(false) + useMeasurementTool.getState().setDisplayPrecision('standard') + for (const [kind, enabled] of Object.entries(DEFAULT_MEASUREMENT_SNAP_SETTINGS)) { + useMeasurementTool + .getState() + .setSnapKindEnabled(kind as keyof typeof DEFAULT_MEASUREMENT_SNAP_SETTINGS, enabled) + } +}) + +function zoneNode(): AnyNode { + return { + id: 'measurement_store_zone', + type: 'surface-perimeter-without-boundary', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + } as never +} + +function syncMeasurementSceneNodesForTest() { + syncLinearMeasurementSceneNodes(useMeasurementTool.getState().segments) +} + +describe('useMeasurementTool', () => { + test('isDraggingMeasurementEndpoint identifies only the active handle', () => { + expect( + isDraggingMeasurementEndpoint( + { endpoint: 'start', id: 'measurement-1' }, + 'measurement-1', + 'start', + ), + ).toBe(true) + expect( + isDraggingMeasurementEndpoint( + { endpoint: 'start', id: 'measurement-1' }, + 'measurement-1', + 'end', + ), + ).toBe(false) + expect(isDraggingMeasurementEndpoint(null, 'measurement-1', 'start')).toBe(false) + }) + + test('axisLockedMeasurementPoint constrains to the strongest drawing axis', () => { + expect(axisLockedMeasurementPoint([0, 0, 0], [1, 2, 4], '2d')).toEqual([0, 0, 4]) + expect(axisLockedMeasurementPoint([0, 0, 0], [1, 2, 4], '3d')).toEqual([0, 0, 4]) + expect(axisLockedMeasurementPoint([0, 0, 0], [1, 5, 4], '3d')).toEqual([0, 5, 0]) + }) + + test('commits point-to-point measurements and selects the new segment', () => { + const measurement = useMeasurementTool.getState() + measurement.begin('2d', [0, 0, 0]) + measurement.update([3, 0, 4]) + measurement.commit() + + const state = useMeasurementTool.getState() + expect(state.draft).toBeNull() + expect(state.segments).toHaveLength(1) + expect(state.segments[0]?.view).toBe('2d') + expect(state.segments[0]?.start).toEqual([0, 0, 0]) + expect(state.segments[0]?.end).toEqual([3, 0, 4]) + expect(state.selectedId).toBe(state.segments[0]?.id ?? null) + }) + + test('parents attached linear measurements to the snapped mesh', () => { + useScene.setState({ + nodes: { + level_measurement: { + id: 'level_measurement', + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['item_measurement'], + level: 0, + } as never, + item_measurement: ItemNode.parse({ + id: 'item_measurement', + parentId: 'level_measurement', + asset: { + id: 'asset_measurement', + category: 'furniture', + name: 'Measured item', + thumbnail: '/item.webp', + src: '/item.glb', + }, + }), + }, + rootNodeIds: ['level_measurement' as never], + }) + + useMeasurementTool + .getState() + .addSegment( + '3d', + [0, 0, 0], + [1, 0, 0], + undefined, + { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_measurement' }, + { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_measurement' }, + ) + + syncMeasurementSceneNodesForTest() + const segment = useMeasurementTool.getState().segments[0]! + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ + measurementId: segment.id, + parentId: 'item_measurement', + startAttachment: { nodeId: 'item_measurement' }, + endAttachment: { nodeId: 'item_measurement' }, + }) + expect(sceneNode).toBeDefined() + expect( + (useScene.getState().nodes.item_measurement as { children: string[] }).children, + ).toContain(sceneNode!.id) + expect(AnyNode.safeParse(useScene.getState().nodes.item_measurement).success).toBe(true) + }) + + test('creates the item child list when an attached measurement is parented to an unparsed item', () => { + useScene.setState({ + nodes: { + item_measurement: { + id: 'item_measurement', + type: 'item', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + asset: { + id: 'asset_measurement', + category: 'furniture', + name: 'Measured item', + thumbnail: '/item.webp', + src: '/item.glb', + }, + } as never, + }, + rootNodeIds: ['item_measurement' as never], + }) + + useMeasurementTool + .getState() + .addSegment( + '3d', + [0, 0, 0], + [1, 0, 0], + undefined, + { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_measurement' }, + { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_measurement' }, + ) + + syncMeasurementSceneNodesForTest() + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode?.parentId).toBe('item_measurement') + expect( + (useScene.getState().nodes.item_measurement as { children?: string[] }).children, + ).toContain(sceneNode!.id) + expect(AnyNode.safeParse(useScene.getState().nodes.item_measurement).success).toBe(true) + }) + + test('adds unattached linear measurements at the scene root', () => { + useMeasurementTool.getState().addSegment('3d', [0, 0, 0], [1, 0, 0]) + syncMeasurementSceneNodesForTest() + + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode).toMatchObject({ parentId: null }) + expect(sceneNode).toBeDefined() + expect(useScene.getState().rootNodeIds).toContain(sceneNode!.id) + }) + + test('uses the start attachment as owner when endpoints attach to different meshes', () => { + useScene.setState({ + nodes: { + item_start: { + id: 'item_start', + type: 'item', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + } as never, + item_end: { + id: 'item_end', + type: 'item', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + } as never, + }, + rootNodeIds: ['item_start' as never, 'item_end' as never], + }) + + useMeasurementTool + .getState() + .addSegment( + '3d', + [0, 0, 0], + [1, 0, 0], + undefined, + { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_start' }, + { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_end' }, + ) + + syncMeasurementSceneNodesForTest() + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + expect(sceneNode?.parentId).toBe('item_start') + }) + + test('parents attached measurements under non-item scene nodes automatically', () => { + useScene.setState({ + nodes: { + sseg_measured_segment: { + attachmentSide: 'front', + children: [], + fillToFloor: true, + height: 2.5, + id: 'sseg_measured_segment', + length: 3, + metadata: {}, + object: 'node', + parentId: null, + position: [0, 0, 0], + rotation: 0, + segmentType: 'stair', + stepCount: 10, + thickness: 0.25, + type: 'stair-segment', + visible: true, + width: 1, + } as never, + }, + rootNodeIds: ['sseg_measured_segment' as never], + }) + + useMeasurementTool.getState().addSegment( + '3d', + [0, 0, 0], + [1, 0, 0], + undefined, + { + feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, + nodeId: 'sseg_measured_segment', + }, + { + feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, + nodeId: 'sseg_measured_segment', + }, + ) + + syncMeasurementSceneNodesForTest() + const sceneNode = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'measurement', + ) + const parent = useScene.getState().nodes.sseg_measured_segment as { children?: string[] } + expect(sceneNode?.parentId).toBe('sseg_measured_segment') + expect(parent.children).toContain(sceneNode!.id) + expect(AnyNode.safeParse(useScene.getState().nodes.sseg_measured_segment).success).toBe(true) + }) + + test('continuous distance mode starts the next segment from the committed endpoint', () => { + const measurement = useMeasurementTool.getState() + measurement.setContinuousMeasurement(true) + measurement.begin('2d', [0, 0, 0]) + measurement.update([3, 0, 0]) + measurement.commit() + + const state = useMeasurementTool.getState() + expect(state.segments).toHaveLength(1) + expect(state.draft).toEqual({ start: [3, 0, 0], end: null, view: '2d' }) + expect(state.selectedId).toBe(state.segments[0]?.id ?? null) + }) + + test('updates a persistent segment length along its existing direction', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [3, 0, 4]) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.updateSegmentLength(segmentId, 10) + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [6, 0, 8], + measuredDistanceMeters: 10, + view: '3d', + }) + }) + + test('updates a persistent segment endpoint and clears measured override', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [3, 0, 4], 5) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'end') + measurement.updateSegmentEndpoint(segmentId, 'end', [6, 0, 8]) + measurement.endSegmentEndpointDrag() + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [0, 0, 0], + end: [6, 0, 8], + measuredDistanceMeters: undefined, + view: '3d', + }) + expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull() + expect(useMeasurementTool.getState().selectedId).toBe(segmentId) + }) + + test('moves every measurement endpoint sharing the dragged coordinate', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('3d', [2, 0, 0], [2, 0, 3], 3) + const secondId = useMeasurementTool.getState().segments[1]!.id + + measurement.startSegmentEndpointDrag(firstId, 'end') + measurement.updateSegmentEndpoint(firstId, 'end', [3, 0, 0]) + measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0]) + measurement.endSegmentEndpointDrag() + + const segments = useMeasurementTool.getState().segments + const first = segments.find((segment) => segment.id === firstId) + const second = segments.find((segment) => segment.id === secondId) + expect(first).toMatchObject({ + end: [4, 0, 0], + measuredDistanceMeters: undefined, + }) + expect(second).toMatchObject({ + start: [4, 0, 0], + measuredDistanceMeters: undefined, + }) + expect(distanceBetweenMeasurements(first!.start, first!.end)).toBe(4) + expect(distanceBetweenMeasurements(second!.start, second!.end)).toBeCloseTo(Math.sqrt(13)) + }) + + test('does not publish a segment endpoint update when the drag state is unchanged', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [2, 0, 0], 2) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'end') + measurement.updateSegmentEndpoint(segmentId, 'end', [3, 0, 0]) + + let publishCount = 0 + const unsubscribe = useMeasurementTool.subscribe(() => { + publishCount += 1 + }) + + measurement.updateSegmentEndpoint(segmentId, 'end', [3, 0, 0]) + + unsubscribe() + expect(publishCount).toBe(0) + }) + + test('Alt detaches a linked endpoint and release-time Alt state decides the final link', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('2d', [2, 0, 0], [2, 0, 3], 3) + const secondId = useMeasurementTool.getState().segments[1]!.id + + measurement.startSegmentEndpointDrag(firstId, 'end') + measurement.updateSegmentEndpoint(firstId, 'end', [3, 0, 0]) + expect(useMeasurementTool.getState().segments[1]).toMatchObject({ + measuredDistanceMeters: undefined, + start: [3, 0, 0], + }) + + measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0], true) + expect(useMeasurementTool.getState().segments[1]).toMatchObject({ + measuredDistanceMeters: 3, + start: [2, 0, 0], + }) + + measurement.updateSegmentEndpoint(firstId, 'end', [5, 0, 0], false) + expect(useMeasurementTool.getState().segments[1]).toMatchObject({ + measuredDistanceMeters: undefined, + start: [5, 0, 0], + }) + + measurement.endSegmentEndpointDrag({ detachLinkedEndpoints: true }) + + const segments = useMeasurementTool.getState().segments + expect(segments.find((segment) => segment.id === firstId)).toMatchObject({ + end: [5, 0, 0], + measuredDistanceMeters: undefined, + }) + expect(segments.find((segment) => segment.id === secondId)).toMatchObject({ + measuredDistanceMeters: 3, + start: [2, 0, 0], + }) + }) + + test('moves linked endpoint attachments together and restores them when Alt detaches', () => { + const measurement = useMeasurementTool.getState() + const originalAttachment = { + feature: { index: 0, kind: 'plan-anchor' as const }, + nodeId: 'original-node', + } + const nextAttachment = { + feature: { index: 2, kind: 'plan-anchor' as const }, + nodeId: 'next-node', + } + measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2, undefined, originalAttachment) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('2d', [2, 0, 0], [2, 0, 3], 3, originalAttachment, undefined) + + measurement.startSegmentEndpointDrag(firstId, 'end') + measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0], false, nextAttachment) + expect(useMeasurementTool.getState().segments).toMatchObject([ + { endAttachment: nextAttachment }, + { startAttachment: nextAttachment }, + ]) + + measurement.updateSegmentEndpoint(firstId, 'end', [5, 0, 0], true, null) + expect(useMeasurementTool.getState().segments).toMatchObject([ + { endAttachment: undefined }, + { startAttachment: originalAttachment }, + ]) + }) + + test('does not link measurement endpoints separated by elevation', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [2, 0, 0]) + const firstId = useMeasurementTool.getState().segments[0]!.id + measurement.addSegment('3d', [2, 1, 0], [2, 1, 3]) + const secondId = useMeasurementTool.getState().segments[1]!.id + + measurement.startSegmentEndpointDrag(firstId, 'end') + measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0]) + measurement.endSegmentEndpointDrag() + + expect( + useMeasurementTool.getState().segments.find((segment) => segment.id === secondId)?.start, + ).toEqual([2, 1, 0]) + }) + + test('cancelDraft clears an active segment endpoint drag', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [3, 0, 4]) + const segmentId = useMeasurementTool.getState().segments[0]!.id + + measurement.startSegmentEndpointDrag(segmentId, 'start') + measurement.cancelDraft() + + expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull() + expect(useMeasurementTool.getState().segments).toHaveLength(1) + }) + + test('cancelDraft preserves committed measurements while clearing transient state', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [0, 0, 0], [1, 0, 0]) + measurement.begin('2d', [2, 0, 0]) + measurement.update([3, 0, 0]) + measurement.setCursor('2d', [3, 0, 0]) + measurement.setSnapTarget({ label: 'Grid', point: [3, 0, 0], view: '2d' }) + measurement.setPreviewSegment({ + id: 'measurement-preview', + start: [4, 0, 0], + end: [5, 0, 0], + view: '2d', + }) + measurement.setPreviewArea({ + id: 'measurement-area-preview', + areaSquareMeters: 4, + labelPoint: [0, 0, 0], + view: '2d', + }) + measurement.setPreviewPerimeter({ + id: 'measurement-perimeter-preview', + labelPoint: [0, 0, 0], + lengthMeters: 8, + view: '2d', + }) + + measurement.cancelDraft() + + expect(useMeasurementTool.getState()).toMatchObject({ + cursor: null, + draft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + snapTarget: null, + }) + expect(useMeasurementTool.getState().segments).toHaveLength(1) + }) + + test('updates an active draft length along its current direction', () => { + const measurement = useMeasurementTool.getState() + measurement.begin('3d', [0, 0, 0]) + measurement.update([3, 0, 4]) + + measurement.updateDraftLength(10) + + expect(useMeasurementTool.getState().draft).toMatchObject({ + start: [0, 0, 0], + end: [6, 0, 8], + view: '3d', + }) + }) + + test('updates a 2D active draft length and commits the exact endpoint', () => { + const measurement = useMeasurementTool.getState() + measurement.begin('2d', [1, 0, 1]) + measurement.update([1, 0, 3]) + + measurement.updateDraftLength(5) + measurement.commit() + + expect(useMeasurementTool.getState().segments[0]).toMatchObject({ + start: [1, 0, 1], + end: [1, 0, 6], + view: '2d', + }) + }) + + test('updates an active angle draft to an exact angle', () => { + const measurement = useMeasurementTool.getState() + measurement.beginAngle('2d', [0, 0, 0]) + measurement.updateAngle([0, 0, 1]) + + measurement.updateAngleDegrees(45) + measurement.commitAngle() + + const angle = useMeasurementTool.getState().angles[0] + expect(angle?.first).toEqual([1, 0, 0]) + expect(angle?.vertex).toEqual([0, 0, 0]) + expect(angle?.second[0]).toBeCloseTo(Math.SQRT1_2) + expect(angle?.second[1]).toBeCloseTo(0) + expect(angle?.second[2]).toBeCloseTo(Math.SQRT1_2) + }) + + test('updates a persistent angle measurement to an exact angle', () => { + const measurement = useMeasurementTool.getState() + measurement.beginAngle('3d', [0, 0, 0]) + measurement.commitAngle([0, 0, 1]) + const angleId = useMeasurementTool.getState().angles[0]!.id + + measurement.updateAngleMeasurementDegrees(angleId, 45) + + const angle = useMeasurementTool.getState().angles[0] + expect(angle?.first).toEqual([1, 0, 0]) + expect(angle?.vertex).toEqual([0, 0, 0]) + expect(angle?.second[0]).toBeCloseTo(Math.SQRT1_2) + expect(angle?.second[1]).toBeCloseTo(0) + expect(angle?.second[2]).toBeCloseTo(Math.SQRT1_2) + }) + + test('clear does not reset display preferences', () => { + const measurement = useMeasurementTool.getState() + measurement.setContinuousMeasurement(true) + measurement.setDisplayPrecision('fine') + measurement.setSnapKindEnabled('grid', false) + measurement.addSegment('2d', [0, 0, 0], [1, 0, 0]) + + measurement.clear() + + expect(useMeasurementTool.getState().continuousMeasurement).toBe(true) + expect(useMeasurementTool.getState().displayPrecision).toBe('fine') + expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(false) + }) + + test('toggles individual snap families and clears a disabled active target', () => { + const measurement = useMeasurementTool.getState() + measurement.setSnapTarget({ kind: 'grid', label: 'Grid', point: [1, 0, 1], view: '2d' }) + + measurement.setSnapKindEnabled('grid', false) + + expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(false) + expect(useMeasurementTool.getState().snapTarget).toBeNull() + + measurement.setSnapKindEnabled('grid', true) + + expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(true) + }) + + test('sets all snap families and resets defaults', () => { + const measurement = useMeasurementTool.getState() + measurement.setSnapTarget({ kind: 'edge', label: 'Edge', point: [1, 0, 1], view: '2d' }) + + measurement.setAllSnapKindsEnabled(false) + + expect(Object.values(useMeasurementTool.getState().enabledSnapKinds).every(Boolean)).toBe(false) + expect(useMeasurementTool.getState().snapTarget).toBeNull() + + measurement.setAllSnapKindsEnabled(true) + + expect(Object.values(useMeasurementTool.getState().enabledSnapKinds).every(Boolean)).toBe(true) + + measurement.setSnapKindEnabled('grid', false) + measurement.resetSnapKinds() + + expect(useMeasurementTool.getState().enabledSnapKinds).toEqual( + DEFAULT_MEASUREMENT_SNAP_SETTINGS, + ) + }) + + test('adds every direct measurement kind and deletes the selected row', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('3d', [0, 0, 0], [2, 0, 0], 2) + measurement.addArea('3d', [1, 0, 1], 12) + measurement.addPerimeter('3d', [1, 0, 1], 14) + measurement.beginAngle('3d', [0, 0, 0]) + measurement.commitAngle([0, 0, 1]) + + const beforeDelete = useMeasurementTool.getState() + expect(beforeDelete.segments).toHaveLength(1) + expect(beforeDelete.areas).toHaveLength(1) + expect(beforeDelete.perimeters).toHaveLength(1) + expect(beforeDelete.angles).toHaveLength(1) + expect(beforeDelete.selectedId).toBe(beforeDelete.angles[0]?.id ?? null) + + useMeasurementTool.getState().deleteSelected() + const afterDelete = useMeasurementTool.getState() + expect(afterDelete.angles).toHaveLength(0) + expect(afterDelete.segments).toHaveLength(1) + expect(afterDelete.areas).toHaveLength(1) + expect(afterDelete.perimeters).toHaveLength(1) + expect(afterDelete.selectedId).toBeNull() + }) + + test('commits freeform polygon drafts as area or perimeter measurements', () => { + const measurement = useMeasurementTool.getState() + + measurement.setMode('area') + measurement.beginPolygon('2d', [0, 0, 0]) + measurement.addPolygonPoint([4, 0, 0]) + measurement.addPolygonPoint([4, 0, 3]) + measurement.commitPolygon() + + expect(useMeasurementTool.getState().areas[0]).toMatchObject({ + areaSquareMeters: 6, + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + ], + labelPoint: [2.6666666666666665, 0, 1], + view: '2d', + }) + + measurement.setMode('perimeter') + measurement.beginPolygon('3d', [0, 0, 0]) + measurement.addPolygonPoint([4, 0, 0]) + measurement.addPolygonPoint([4, 0, 3]) + measurement.commitPolygon() + + expect(useMeasurementTool.getState().perimeters[0]).toMatchObject({ + lengthMeters: 12, + labelPoint: [2.6666666666666665, 0, 1], + view: '3d', + }) + }) + + test('clear removes drafts, cursor, selection, and all measurement collections', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [0, 0, 0], [1, 0, 0]) + measurement.addArea('2d', [0, 0, 0], 8) + measurement.addPerimeter('2d', [0, 0, 0], 12) + measurement.beginAngle('2d', [0, 0, 0]) + measurement.updateAngle([0, 0, 1]) + measurement.begin('2d', [0, 0, 0]) + measurement.update([0, 0, 2]) + measurement.setCursor('2d', [0, 0, 2]) + measurement.setSnapTarget({ label: 'Endpoint', point: [0, 0, 2], view: '2d' }) + + measurement.clear() + + expect(useMeasurementTool.getState()).toMatchObject({ + angleDraft: null, + angles: [], + areas: [], + cursor: null, + draft: null, + perimeters: [], + selectedId: null, + segments: [], + snapTarget: null, + }) + }) + + test('mode changes cancel in-flight drafts and preserve measurement collections', () => { + const measurement = useMeasurementTool.getState() + measurement.addSegment('2d', [0, 0, 0], [1, 0, 0]) + measurement.begin('2d', [0, 0, 0]) + measurement.update([0, 0, 2]) + measurement.setMode('angle') + + expect(useMeasurementTool.getState().mode).toBe('angle') + expect(useMeasurementTool.getState().draft).toBeNull() + expect(useMeasurementTool.getState().angleDraft).toBeNull() + expect(useMeasurementTool.getState().snapTarget).toBeNull() + expect(useMeasurementTool.getState().segments).toHaveLength(1) + expect(useMeasurementTool.getState().selectedId).toBeNull() + }) + + test('serializes and hydrates committed measurements without transient draft state', () => { + const measurement = useMeasurementTool.getState() + const startAttachment = { + feature: { index: 1, kind: 'plan-anchor' as const }, + nodeId: 'persisted-node', + } + measurement.addSegment('2d', [0, 0, 0], [1, 0, 0], undefined, startAttachment) + measurement.addArea('3d', [0.5, 0, 0.5], 6, [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 1], + ]) + measurement.addPerimeter('3d', [0.5, 0, 0.5], 10) + measurement.beginAngle('2d', [0, 0, 0]) + measurement.commitAngle([0, 0, 1]) + measurement.begin('2d', [0, 0, 0]) + measurement.update([0, 0, 2]) + measurement.setCursor('2d', [0, 0, 2]) + + const persisted = serializeMeasurements() + useMeasurementTool.getState().clear() + hydrateMeasurements(persisted) + + expect(useMeasurementTool.getState()).toMatchObject({ + angleDraft: null, + areas: persisted.areas, + cursor: null, + draft: null, + perimeters: persisted.perimeters, + selectedId: null, + segments: persisted.segments, + }) + expect(useMeasurementTool.getState().angles).toEqual(persisted.angles) + expect(useMeasurementTool.getState().segments[0]?.startAttachment).toEqual(startAttachment) + }) + + test('hydrates legacy surface perimeters with display boundary points', () => { + const zone = zoneNode() + useScene.getState().setScene({ [zone.id]: zone } as never, [zone.id] as never) + + hydrateMeasurements({ + version: 1, + segments: [], + areas: [], + perimeters: [ + { + id: 'measurement-perimeter-legacy-2d', + labelPoint: [2, 0, 1.5], + lengthMeters: 14, + view: '2d', + }, + { + id: 'measurement-perimeter-legacy-3d', + labelPoint: [2, 0.05, 1.5], + lengthMeters: 14, + view: '3d', + }, + ], + angles: [], + }) + + expect(useMeasurementTool.getState().perimeters).toMatchObject([ + { + boundaryPoints: [ + [0, 0, 0], + [4, 0, 0], + [4, 0, 3], + [0, 0, 3], + ], + id: 'measurement-perimeter-legacy-2d', + }, + { + boundaryPoints: [ + [0, 0.02, 0], + [4, 0.02, 0], + [4, 0.02, 3], + [0, 0.02, 3], + ], + id: 'measurement-perimeter-legacy-3d', + }, + ]) + }) + + test('normalizes invalid persisted measurement entries', () => { + const persisted = normalizePersistedMeasurements({ + segments: [ + { id: 'measurement-1', start: [0, 0, 0], end: [1, 0, 0], view: '2d' }, + { id: 'bad-segment', start: [0, 0], end: [1, 0, 0], view: '2d' }, + ], + areas: [ + { + id: 'measurement-area-2', + areaSquareMeters: 8, + boundaryPoints: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 1], + ], + labelPoint: [0, 0, 0], + view: '3d', + }, + { id: 'bad-area', areaSquareMeters: Number.NaN, labelPoint: [0, 0, 0], view: '3d' }, + ], + perimeters: [ + { + id: 'measurement-perimeter-3', + labelPoint: [0, 0, 0], + lengthMeters: 12, + view: '2d', + }, + { id: 'bad-perimeter', labelPoint: [0, 0, 0], lengthMeters: 12, view: 'side' }, + ], + angles: [ + { + id: 'measurement-angle-4', + first: [1, 0, 0], + vertex: [0, 0, 0], + second: [0, 0, 1], + view: '3d', + }, + { + id: 'bad-angle', + first: [1, 0, 0], + vertex: [0, 0, 0], + second: ['x', 0, 1], + view: '3d', + }, + ], + }) + + expect(persisted.segments).toHaveLength(1) + expect(persisted.areas).toHaveLength(1) + expect(persisted.areas[0]?.boundaryPoints).toEqual([ + [0, 0, 0], + [1, 0, 0], + [1, 0, 1], + ]) + expect(persisted.perimeters).toHaveLength(1) + expect(persisted.angles).toHaveLength(1) + }) +}) diff --git a/packages/editor/src/store/use-measurement-tool.ts b/packages/editor/src/store/use-measurement-tool.ts new file mode 100644 index 000000000..b60b8f14b --- /dev/null +++ b/packages/editor/src/store/use-measurement-tool.ts @@ -0,0 +1,1435 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type GeometryContext, + type MeasurementDefinitionArea, + type MeasurementDefinitionPerimeter, + nodeRegistry, + useScene, +} from '@pascal-app/core' +import { create } from 'zustand' + +export type MeasurementPoint = [number, number, number] +export type MeasurementView = '2d' | '3d' +export type MeasurementMode = 'distance' | 'area' | 'perimeter' | 'angle' +export type MeasurementDisplayPrecision = 'coarse' | 'standard' | 'fine' + +export type MeasurementPointAttachment = { + buildingId?: string + feature: + | { index: number; kind: 'plan-anchor' } + | { index: number; kind: 'plan-segment'; t: number } + | { kind: 'node-bounds'; normalized: MeasurementPoint } + nodeId: string + ownerNodeId?: string +} + +export type MeasurementSegment = { + id: string + start: MeasurementPoint + end: MeasurementPoint + view: MeasurementView + measuredDistanceMeters?: number + startAttachment?: MeasurementPointAttachment + endAttachment?: MeasurementPointAttachment +} + +export type MeasurementArea = { + id: string + areaSquareMeters: number + boundaryPoints?: MeasurementPoint[] + labelPoint: MeasurementPoint + view: MeasurementView +} + +export type MeasurementPerimeter = { + id: string + boundaryPoints?: MeasurementPoint[] + labelPoint: MeasurementPoint + lengthMeters: number + view: MeasurementView +} + +export type MeasurementAngle = { + id: string + first: MeasurementPoint + vertex: MeasurementPoint + second: MeasurementPoint + view: MeasurementView +} + +export type MeasurementDraft = { + start: MeasurementPoint + end: MeasurementPoint | null + view: MeasurementView + surfaceNormal?: MeasurementPoint + startAttachment?: MeasurementPointAttachment +} + +export type MeasurementAngleDraft = { + first: MeasurementPoint + referenceLine?: { + end: MeasurementPoint + start: MeasurementPoint + } + vertex: MeasurementPoint | null + second: MeasurementPoint | null + view: MeasurementView +} + +export type MeasurementPolygonDraft = { + points: MeasurementPoint[] + cursor: MeasurementPoint | null + view: MeasurementView +} + +export type MeasurementCursor = { + point: MeasurementPoint + view: MeasurementView +} + +export type MeasurementSnapTarget = { + attachment?: MeasurementPointAttachment + guideLine?: { + end: MeasurementPoint + start: MeasurementPoint + } + kind?: MeasurementSnapKind + label: string + point: MeasurementPoint + targetLine?: { + end: MeasurementPoint + start: MeasurementPoint + } + view: MeasurementView +} + +export type MeasurementSnapKind = + | 'center' + | 'edge' + | 'endpoint' + | 'grid' + | 'guide' + | 'intersection' + | 'measurement' + | 'midpoint' + | 'surface' + | 'vertex' + +export type MeasurementSnapSettings = Record +export type MeasurementSegmentEndpoint = 'end' | 'start' +export type DraggingMeasurementSegmentEndpoint = { + attachment?: MeasurementPointAttachment | null + detachLinkedEndpoints?: boolean + endpoint: MeasurementSegmentEndpoint + hasMoved?: boolean + id: string + linkedEndpoints?: Array<{ + endpoint: MeasurementSegmentEndpoint + id: string + measuredDistanceMeters?: number + point: MeasurementPoint + attachment?: MeasurementPointAttachment + }> +} + +export const DEFAULT_MEASUREMENT_SNAP_SETTINGS: MeasurementSnapSettings = { + center: true, + edge: true, + endpoint: true, + grid: true, + guide: true, + intersection: true, + measurement: true, + midpoint: true, + surface: true, + vertex: true, +} + +export type PersistedMeasurements = { + version: 1 + segments: MeasurementSegment[] + areas: MeasurementArea[] + perimeters: MeasurementPerimeter[] + angles: MeasurementAngle[] +} + +type MeasurementToolState = { + segments: MeasurementSegment[] + areas: MeasurementArea[] + perimeters: MeasurementPerimeter[] + angles: MeasurementAngle[] + draft: MeasurementDraft | null + angleDraft: MeasurementAngleDraft | null + polygonDraft: MeasurementPolygonDraft | null + previewSegment: MeasurementSegment | null + previewArea: MeasurementArea | null + previewPerimeter: MeasurementPerimeter | null + cursor: MeasurementCursor | null + snapTarget: MeasurementSnapTarget | null + mode: MeasurementMode + displayPrecision: MeasurementDisplayPrecision + continuousMeasurement: boolean + enabledSnapKinds: MeasurementSnapSettings + draggingSegmentEndpoint: DraggingMeasurementSegmentEndpoint | null + suppressNextPlacementClick: boolean + selectedId: string | null + begin: ( + view: MeasurementView, + start: MeasurementPoint, + surfaceNormal?: MeasurementPoint, + startAttachment?: MeasurementPointAttachment, + ) => void + update: (end: MeasurementPoint) => void + updateDraftLength: (lengthMeters: number) => void + commit: ( + end?: MeasurementPoint, + measuredDistanceMeters?: number, + endAttachment?: MeasurementPointAttachment, + ) => void + beginAngle: ( + view: MeasurementView, + first: MeasurementPoint, + referenceLine?: { end: MeasurementPoint; start: MeasurementPoint } | null, + ) => void + updateAngle: (point: MeasurementPoint) => void + updateAngleDegrees: (degrees: number) => void + updateAngleMeasurementDegrees: (id: string, degrees: number) => void + commitAngle: (point?: MeasurementPoint) => void + beginPolygon: (view: MeasurementView, point: MeasurementPoint) => void + updatePolygon: (point: MeasurementPoint) => void + addPolygonPoint: (point: MeasurementPoint) => void + commitPolygon: () => void + setPreviewSegment: (segment: MeasurementSegment | null) => void + setPreviewArea: (area: MeasurementArea | null) => void + setPreviewPerimeter: (perimeter: MeasurementPerimeter | null) => void + setCursor: (view: MeasurementView, point: MeasurementPoint | null) => void + setSnapTarget: (target: MeasurementSnapTarget | null) => void + setMode: (mode: MeasurementMode) => void + setDisplayPrecision: (precision: MeasurementDisplayPrecision) => void + setContinuousMeasurement: (enabled: boolean) => void + setSnapKindEnabled: (kind: MeasurementSnapKind, enabled: boolean) => void + setAllSnapKindsEnabled: (enabled: boolean) => void + resetSnapKinds: () => void + selectMeasurement: (id: string | null) => void + startSegmentEndpointDrag: (id: string, endpoint: MeasurementSegmentEndpoint) => void + updateSegmentEndpoint: ( + id: string, + endpoint: MeasurementSegmentEndpoint, + point: MeasurementPoint, + detachLinkedEndpoints?: boolean, + attachment?: MeasurementPointAttachment | null, + ) => void + endSegmentEndpointDrag: (options?: { + detachLinkedEndpoints?: boolean + suppressNextClick?: boolean + }) => void + consumeSuppressedPlacementClick: () => boolean + removeMeasurement: (id: string) => void + deleteSelected: () => void + addSegment: ( + view: MeasurementView, + start: MeasurementPoint, + end: MeasurementPoint, + measuredDistanceMeters?: number, + startAttachment?: MeasurementPointAttachment, + endAttachment?: MeasurementPointAttachment, + ) => void + updateSegmentLength: (id: string, lengthMeters: number) => void + addArea: ( + view: MeasurementView, + labelPoint: MeasurementPoint, + areaSquareMeters: number, + boundaryPoints?: MeasurementPoint[], + ) => void + addPerimeter: ( + view: MeasurementView, + labelPoint: MeasurementPoint, + lengthMeters: number, + boundaryPoints?: MeasurementPoint[], + ) => void + cancelDraft: () => void + clear: () => void +} + +let nextMeasurementId = 1 +const MEASUREMENT_ENDPOINT_LINK_TOLERANCE_METERS = 1e-5 +const PERIMETER_BACKFILL_LABEL_TOLERANCE_METERS = 0.05 +const PERIMETER_BACKFILL_LENGTH_TOLERANCE_METERS = 1e-3 + +export function distanceBetweenMeasurements(a: MeasurementPoint, b: MeasurementPoint): number { + return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) +} + +function sameMeasurementPoint(a: MeasurementPoint | undefined, b: MeasurementPoint | undefined) { + return Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) +} + +function sameMeasurementPointAttachment( + a: MeasurementPointAttachment | null | undefined, + b: MeasurementPointAttachment | null | undefined, +) { + if (!a || !b) return a == null && b == null + if (a.nodeId !== b.nodeId || a.ownerNodeId !== b.ownerNodeId || a.buildingId !== b.buildingId) { + return false + } + if (a.feature.kind !== b.feature.kind) return false + if (a.feature.kind === 'node-bounds' && b.feature.kind === 'node-bounds') { + return sameMeasurementPoint(a.feature.normalized, b.feature.normalized) + } + if (a.feature.kind === 'plan-anchor' && b.feature.kind === 'plan-anchor') { + return a.feature.index === b.feature.index + } + if (a.feature.kind === 'plan-segment' && b.feature.kind === 'plan-segment') { + return a.feature.index === b.feature.index && a.feature.t === b.feature.t + } + return false +} + +export function isDraggingMeasurementEndpoint( + dragging: DraggingMeasurementSegmentEndpoint | null, + id: string, + endpoint: MeasurementSegmentEndpoint, +): boolean { + return dragging?.id === id && dragging.endpoint === endpoint +} + +function collectLinkedMeasurementEndpoints( + segments: MeasurementSegment[], + point: MeasurementPoint, +): NonNullable { + const toleranceSq = MEASUREMENT_ENDPOINT_LINK_TOLERANCE_METERS ** 2 + const linkedEndpoints: NonNullable = [] + + for (const segment of segments) { + for (const endpoint of ['start', 'end'] as const) { + const candidate = segment[endpoint] + const dx = candidate[0] - point[0] + const dy = candidate[1] - point[1] + const dz = candidate[2] - point[2] + if (dx * dx + dy * dy + dz * dz <= toleranceSq) { + linkedEndpoints.push({ + attachment: segment[`${endpoint}Attachment`], + endpoint, + id: segment.id, + measuredDistanceMeters: segment.measuredDistanceMeters, + point: [...candidate] as MeasurementPoint, + }) + } + } + } + + return linkedEndpoints +} + +export function axisLockedMeasurementPoint( + start: MeasurementPoint, + end: MeasurementPoint, + view: MeasurementView, +): MeasurementPoint { + const axes = view === '2d' ? ([0, 2] as const) : ([0, 1, 2] as const) + let strongestAxis: 0 | 1 | 2 = axes[0] + let strongestDistance = Math.abs(end[strongestAxis] - start[strongestAxis]) + + for (const axis of axes.slice(1)) { + const distance = Math.abs(end[axis] - start[axis]) + if (distance > strongestDistance) { + strongestAxis = axis + strongestDistance = distance + } + } + + return start.map((value, axis) => + axis === strongestAxis ? end[axis as keyof MeasurementPoint] : value, + ) as MeasurementPoint +} + +export function polygonPerimeterFromMeasurements(points: MeasurementPoint[]): number { + if (points.length < 2) return 0 + return points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length] ?? point + return sum + Math.hypot(next[0] - point[0], next[2] - point[2]) + }, 0) +} + +export function polygonAreaAndLabelPointFromMeasurements(points: MeasurementPoint[]): { + areaSquareMeters: number + labelPoint: MeasurementPoint +} { + if (points.length < 3) { + const fallback = points[0] ?? ([0, 0, 0] as MeasurementPoint) + return { areaSquareMeters: 0, labelPoint: fallback } + } + + let cx = 0 + let cz = 0 + let area = 0 + for ( + let index = 0, previousIndex = points.length - 1; + index < points.length; + previousIndex = index++ + ) { + const previous = points[previousIndex]! + const point = points[index]! + const factor = previous[0] * point[2] - point[0] * previous[2] + cx += (previous[0] + point[0]) * factor + cz += (previous[2] + point[2]) * factor + area += factor + } + + area /= 2 + if (Math.abs(area) < 1e-9) { + const average = points.reduce( + (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]] as MeasurementPoint, + [0, 0, 0] as MeasurementPoint, + ) + return { + areaSquareMeters: 0, + labelPoint: [ + average[0] / points.length, + average[1] / points.length, + average[2] / points.length, + ], + } + } + + return { + areaSquareMeters: Math.abs(area), + labelPoint: [cx / (6 * area), points[0]?.[1] ?? 0, cz / (6 * area)], + } +} + +function isMeasurementView(value: unknown): value is MeasurementView { + return value === '2d' || value === '3d' +} + +function normalizePoint(value: unknown): MeasurementPoint | null { + if (!Array.isArray(value) || value.length !== 3) return null + const point = value.map((entry) => (typeof entry === 'number' ? entry : Number.NaN)) + if (!point.every(Number.isFinite)) return null + return point as MeasurementPoint +} + +function normalizePointAttachment(value: unknown): MeasurementPointAttachment | undefined { + if (!(value && typeof value === 'object')) return undefined + const source = value as Partial + if ( + typeof source.nodeId !== 'string' || + !(source.feature && typeof source.feature === 'object') + ) { + return undefined + } + + const feature = source.feature as MeasurementPointAttachment['feature'] + if (feature.kind === 'plan-anchor' && Number.isInteger(feature.index) && feature.index >= 0) { + return { + nodeId: source.nodeId, + buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined, + ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined, + feature: { kind: feature.kind, index: feature.index }, + } + } + if ( + feature.kind === 'plan-segment' && + Number.isInteger(feature.index) && + feature.index >= 0 && + Number.isFinite(feature.t) + ) { + return { + nodeId: source.nodeId, + buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined, + ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined, + feature: { kind: feature.kind, index: feature.index, t: Math.min(1, Math.max(0, feature.t)) }, + } + } + if (feature.kind === 'node-bounds') { + const normalized = normalizePoint(feature.normalized) + if (!normalized) return undefined + return { + nodeId: source.nodeId, + buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined, + ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined, + feature: { kind: feature.kind, normalized }, + } + } + return undefined +} + +function measurementGeometryContextForNode(node: AnyNode): GeometryContext { + const nodes = useScene.getState().nodes + const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? [] + return { + children: childIds.flatMap((id: AnyNodeId) => { + const child = nodes[id as AnyNodeId] + return child ? [child] : [] + }), + parent: node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null, + resolve: (id: AnyNodeId) => nodes[id] as N | undefined, + siblings: node.parentId + ? Object.values(nodes).filter( + (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id, + ) + : [], + } +} + +function definitionPoint(point: readonly [number, number, number]): MeasurementPoint { + return [...point] as MeasurementPoint +} + +function definitionPlanPoint(point: readonly [number, number, number]): MeasurementPoint { + return [point[0], 0, point[2]] +} + +function perimeterFromCurrentSceneNode(node: AnyNode): MeasurementDefinitionPerimeter | null { + return ( + (nodeRegistry + .get(node.type) + ?.measurement?.perimeter?.(node as never, measurementGeometryContextForNode(node)) as + | MeasurementDefinitionPerimeter + | null + | undefined) ?? null + ) +} + +function areaFromCurrentSceneNode(node: AnyNode): MeasurementDefinitionArea | null { + return ( + (nodeRegistry + .get(node.type) + ?.measurement?.area?.(node as never, measurementGeometryContextForNode(node)) as + | MeasurementDefinitionArea + | null + | undefined) ?? null + ) +} + +function measurementDistance(a: MeasurementPoint, b: MeasurementPoint): number { + return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) +} + +function perimeterBoundaryForView( + perimeter: MeasurementDefinitionPerimeter, + area: MeasurementDefinitionArea | null, + view: MeasurementView, +): MeasurementPoint[] { + const boundaryPoints = + (perimeter.boundaryPoints?.length ?? 0) >= 3 ? perimeter.boundaryPoints : area?.boundaryPoints + return (boundaryPoints ?? []).map((point) => + view === '2d' ? definitionPlanPoint(point) : definitionPoint(point), + ) +} + +function perimeterLabelForView( + perimeter: MeasurementDefinitionPerimeter, + view: MeasurementView, +): MeasurementPoint { + return view === '2d' + ? definitionPlanPoint(perimeter.labelPoint) + : definitionPoint(perimeter.labelPoint) +} + +function backfillPerimeterBoundaryPoints( + perimeters: MeasurementPerimeter[], +): MeasurementPerimeter[] { + if (perimeters.every((perimeter) => (perimeter.boundaryPoints?.length ?? 0) >= 3)) { + return perimeters + } + + const candidates = Object.values(useScene.getState().nodes).flatMap((node) => { + const perimeter = perimeterFromCurrentSceneNode(node) + return perimeter ? [{ area: areaFromCurrentSceneNode(node), perimeter }] : [] + }) + if (candidates.length === 0) return perimeters + + return perimeters.map((perimeter) => { + if ((perimeter.boundaryPoints?.length ?? 0) >= 3) return perimeter + const match = candidates.find(({ perimeter: definition }) => { + if ( + Math.abs(definition.lengthMeters - perimeter.lengthMeters) > + PERIMETER_BACKFILL_LENGTH_TOLERANCE_METERS + ) { + return false + } + return ( + measurementDistance( + perimeterLabelForView(definition, perimeter.view), + perimeter.labelPoint, + ) <= PERIMETER_BACKFILL_LABEL_TOLERANCE_METERS + ) + }) + if (!match) return perimeter + + const boundaryPoints = perimeterBoundaryForView(match.perimeter, match.area, perimeter.view) + return boundaryPoints.length >= 3 ? { ...perimeter, boundaryPoints } : perimeter + }) +} + +function normalizeVector(vector: MeasurementPoint): MeasurementPoint | null { + const length = Math.hypot(vector[0], vector[1], vector[2]) + if (length < 1e-8) return null + return [vector[0] / length, vector[1] / length, vector[2] / length] +} + +function crossVector(a: MeasurementPoint, b: MeasurementPoint): MeasurementPoint { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +function dotVector(a: MeasurementPoint, b: MeasurementPoint): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +function rotateVectorAroundAxis( + vector: MeasurementPoint, + axis: MeasurementPoint, + radians: number, +): MeasurementPoint { + const cos = Math.cos(radians) + const sin = Math.sin(radians) + const cross = crossVector(axis, vector) + const dot = dotVector(axis, vector) + return [ + vector[0] * cos + cross[0] * sin + axis[0] * dot * (1 - cos), + vector[1] * cos + cross[1] * sin + axis[1] * dot * (1 - cos), + vector[2] * cos + cross[2] * sin + axis[2] * dot * (1 - cos), + ] +} + +function angleReferencePointFromLine( + vertex: MeasurementPoint, + target: MeasurementPoint | null | undefined, + referenceLine: { end: MeasurementPoint; start: MeasurementPoint } | null | undefined, +): MeasurementPoint | null { + if (!referenceLine) return null + const dx = referenceLine.end[0] - referenceLine.start[0] + const dy = referenceLine.end[1] - referenceLine.start[1] + const dz = referenceLine.end[2] - referenceLine.start[2] + const lineLength = Math.hypot(dx, dy, dz) + if (lineLength < 1e-8) return null + + const referenceLength = target ? Math.max(1, distanceBetweenMeasurements(vertex, target)) : 1 + let ux = dx / lineLength + let uy = dy / lineLength + let uz = dz / lineLength + if (target) { + const tx = target[0] - vertex[0] + const ty = target[1] - vertex[1] + const tz = target[2] - vertex[2] + if (tx * ux + ty * uy + tz * uz < 0) { + ux *= -1 + uy *= -1 + uz *= -1 + } + } + + return [ + vertex[0] + ux * referenceLength, + vertex[1] + uy * referenceLength, + vertex[2] + uz * referenceLength, + ] +} + +export function gridReferencePointForAngle( + vertex: MeasurementPoint, + target?: MeasurementPoint | null, +): MeasurementPoint { + const length = target ? Math.max(1, distanceBetweenMeasurements(vertex, target)) : 1 + return [vertex[0] + length, vertex[1], vertex[2]] +} + +export function referencePointForAngle( + vertex: MeasurementPoint, + target?: MeasurementPoint | null, + referenceLine?: { end: MeasurementPoint; start: MeasurementPoint } | null, +): MeasurementPoint { + return ( + angleReferencePointFromLine(vertex, target, referenceLine) ?? + gridReferencePointForAngle(vertex, target) + ) +} + +function readMeasurementIdNumber(id: string): number { + const match = id.match(/-(\d+)$/) + return match ? Number(match[1]) : 0 +} + +function syncNextMeasurementId(measurements: PersistedMeasurements) { + const highestId = [ + ...measurements.segments, + ...measurements.areas, + ...measurements.perimeters, + ...measurements.angles, + ].reduce((max, measurement) => Math.max(max, readMeasurementIdNumber(measurement.id)), 0) + nextMeasurementId = Math.max(nextMeasurementId, highestId + 1) +} + +export function normalizePersistedMeasurements(value: unknown): PersistedMeasurements { + if (!(value && typeof value === 'object')) { + return { version: 1, segments: [], areas: [], perimeters: [], angles: [] } + } + + const source = value as Partial + + const segments = (Array.isArray(source.segments) ? source.segments : []).flatMap((segment) => { + const start = normalizePoint(segment?.start) + const end = normalizePoint(segment?.end) + if (!(start && end && typeof segment?.id === 'string' && isMeasurementView(segment.view))) { + return [] + } + return [ + { + id: segment.id, + start, + end, + view: segment.view, + startAttachment: normalizePointAttachment(segment.startAttachment), + endAttachment: normalizePointAttachment(segment.endAttachment), + measuredDistanceMeters: Number.isFinite(segment.measuredDistanceMeters) + ? segment.measuredDistanceMeters + : undefined, + }, + ] + }) + + const areas = (Array.isArray(source.areas) ? source.areas : []).flatMap((area) => { + const labelPoint = normalizePoint(area?.labelPoint) + if ( + !( + labelPoint && + typeof area?.id === 'string' && + Number.isFinite(area.areaSquareMeters) && + isMeasurementView(area.view) + ) + ) { + return [] + } + const boundaryPoints = Array.isArray(area.boundaryPoints) + ? area.boundaryPoints.flatMap((point) => { + const normalized = normalizePoint(point) + return normalized ? [normalized] : [] + }) + : undefined + return [ + { + id: area.id, + areaSquareMeters: area.areaSquareMeters, + boundaryPoints: boundaryPoints && boundaryPoints.length >= 3 ? boundaryPoints : undefined, + labelPoint, + view: area.view, + }, + ] + }) + + const perimeters = (Array.isArray(source.perimeters) ? source.perimeters : []).flatMap( + (perimeter) => { + const labelPoint = normalizePoint(perimeter?.labelPoint) + if ( + !( + labelPoint && + typeof perimeter?.id === 'string' && + Number.isFinite(perimeter.lengthMeters) && + isMeasurementView(perimeter.view) + ) + ) { + return [] + } + const boundaryPoints = Array.isArray(perimeter.boundaryPoints) + ? perimeter.boundaryPoints.flatMap((point) => { + const normalized = normalizePoint(point) + return normalized ? [normalized] : [] + }) + : undefined + return [ + { + id: perimeter.id, + boundaryPoints: boundaryPoints && boundaryPoints.length >= 3 ? boundaryPoints : undefined, + labelPoint, + lengthMeters: perimeter.lengthMeters, + view: perimeter.view, + }, + ] + }, + ) + + const angles = (Array.isArray(source.angles) ? source.angles : []).flatMap((angle) => { + const first = normalizePoint(angle?.first) + const vertex = normalizePoint(angle?.vertex) + const second = normalizePoint(angle?.second) + if ( + !(first && vertex && second && typeof angle?.id === 'string' && isMeasurementView(angle.view)) + ) { + return [] + } + return [{ id: angle.id, first, vertex, second, view: angle.view }] + }) + + return { version: 1, segments, areas, perimeters, angles } +} + +export function serializeMeasurements(): PersistedMeasurements { + const { segments, areas, perimeters, angles } = useMeasurementTool.getState() + return { + version: 1, + segments, + areas, + perimeters, + angles, + } +} + +export function hydrateMeasurements(value: unknown) { + const normalized = normalizePersistedMeasurements(value) + const measurements: PersistedMeasurements = { + ...normalized, + perimeters: backfillPerimeterBoundaryPoints(normalized.perimeters), + } + syncNextMeasurementId(measurements) + useMeasurementTool.setState({ + ...measurements, + angleDraft: null, + cursor: null, + draggingSegmentEndpoint: null, + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + snapTarget: null, + suppressNextPlacementClick: false, + }) +} + +export const useMeasurementTool = create((set, get) => ({ + segments: [], + areas: [], + perimeters: [], + angles: [], + draft: null, + angleDraft: null, + polygonDraft: null, + previewSegment: null, + previewArea: null, + previewPerimeter: null, + cursor: null, + snapTarget: null, + mode: 'distance', + displayPrecision: 'standard', + continuousMeasurement: false, + enabledSnapKinds: DEFAULT_MEASUREMENT_SNAP_SETTINGS, + draggingSegmentEndpoint: null, + suppressNextPlacementClick: false, + selectedId: null, + begin: (view, start, surfaceNormal, startAttachment) => + set({ + draft: { start, end: null, surfaceNormal, startAttachment, view }, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + }), + update: (end) => set((state) => (state.draft ? { draft: { ...state.draft, end } } : state)), + updateDraftLength: (lengthMeters) => { + if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-4)) return + set((state) => { + if (!state.draft?.end) return state + const currentLength = distanceBetweenMeasurements(state.draft.start, state.draft.end) + if (currentLength < 1e-4) return state + const scale = lengthMeters / currentLength + const end: MeasurementPoint = [ + state.draft.start[0] + (state.draft.end[0] - state.draft.start[0]) * scale, + state.draft.start[1] + (state.draft.end[1] - state.draft.start[1]) * scale, + state.draft.start[2] + (state.draft.end[2] - state.draft.start[2]) * scale, + ] + return { draft: { ...state.draft, end } } + }) + }, + commit: (end, measuredDistanceMeters, endAttachment) => { + const draft = get().draft + if (!draft) return + + const resolvedEnd = end ?? draft.end + if (!resolvedEnd || distanceBetweenMeasurements(draft.start, resolvedEnd) < 1e-4) { + set({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + }) + return + } + + const id = `measurement-${nextMeasurementId++}` + set((state) => ({ + draft: state.continuousMeasurement + ? { start: resolvedEnd, end: null, startAttachment: endAttachment, view: draft.view } + : null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: id, + segments: [ + ...state.segments, + { + id, + start: draft.start, + end: resolvedEnd, + startAttachment: draft.startAttachment, + endAttachment, + view: draft.view, + measuredDistanceMeters, + }, + ], + })) + }, + beginAngle: (view, vertex, referenceLine) => + set({ + angleDraft: { + first: referencePointForAngle(vertex, null, referenceLine), + referenceLine: referenceLine ?? undefined, + vertex, + second: null, + view, + }, + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + }), + updateAngle: (point) => + set((state) => { + if (!state.angleDraft) return state + return { + angleDraft: state.angleDraft.vertex + ? { + ...state.angleDraft, + first: referencePointForAngle( + state.angleDraft.vertex, + point, + state.angleDraft.referenceLine, + ), + second: point, + } + : { ...state.angleDraft, vertex: point }, + } + }), + updateAngleDegrees: (degrees) => { + if (!(Number.isFinite(degrees) && degrees >= 0 && degrees < 360)) return + set((state) => { + const draft = state.angleDraft + if (!(draft?.vertex && draft.second)) return state + const firstVector: MeasurementPoint = [ + draft.first[0] - draft.vertex[0], + draft.first[1] - draft.vertex[1], + draft.first[2] - draft.vertex[2], + ] + const secondVector: MeasurementPoint = [ + draft.second[0] - draft.vertex[0], + draft.second[1] - draft.vertex[1], + draft.second[2] - draft.vertex[2], + ] + const firstUnit = normalizeVector(firstVector) + const secondLength = Math.hypot(secondVector[0], secondVector[1], secondVector[2]) + if (!(firstUnit && secondLength > 1e-4)) return state + + const currentNormal = normalizeVector(crossVector(firstVector, secondVector)) + const axis = + currentNormal ?? + (draft.view === '2d' ? ([0, -1, 0] as MeasurementPoint) : ([0, 1, 0] as MeasurementPoint)) + const rotated = rotateVectorAroundAxis(firstUnit, axis, (degrees * Math.PI) / 180) + const second: MeasurementPoint = [ + draft.vertex[0] + rotated[0] * secondLength, + draft.vertex[1] + rotated[1] * secondLength, + draft.vertex[2] + rotated[2] * secondLength, + ] + return { angleDraft: { ...draft, second } } + }) + }, + updateAngleMeasurementDegrees: (id, degrees) => { + if (!(Number.isFinite(degrees) && degrees >= 0 && degrees < 360)) return + set((state) => ({ + angles: state.angles.map((angle) => { + if (angle.id !== id) return angle + const firstVector: MeasurementPoint = [ + angle.first[0] - angle.vertex[0], + angle.first[1] - angle.vertex[1], + angle.first[2] - angle.vertex[2], + ] + const secondVector: MeasurementPoint = [ + angle.second[0] - angle.vertex[0], + angle.second[1] - angle.vertex[1], + angle.second[2] - angle.vertex[2], + ] + const firstUnit = normalizeVector(firstVector) + const secondLength = Math.hypot(secondVector[0], secondVector[1], secondVector[2]) + if (!(firstUnit && secondLength > 1e-4)) return angle + + const currentNormal = normalizeVector(crossVector(firstVector, secondVector)) + const axis = + currentNormal ?? + (angle.view === '2d' ? ([0, -1, 0] as MeasurementPoint) : ([0, 1, 0] as MeasurementPoint)) + const rotated = rotateVectorAroundAxis(firstUnit, axis, (degrees * Math.PI) / 180) + return { + ...angle, + second: [ + angle.vertex[0] + rotated[0] * secondLength, + angle.vertex[1] + rotated[1] * secondLength, + angle.vertex[2] + rotated[2] * secondLength, + ] as MeasurementPoint, + } + }), + })) + }, + commitAngle: (point) => { + const angleDraft = get().angleDraft + if (!angleDraft) return + + if (!angleDraft.vertex) { + const vertex = point + if (!vertex || distanceBetweenMeasurements(angleDraft.first, vertex) < 1e-4) { + set({ angleDraft: null }) + return + } + set({ angleDraft: { ...angleDraft, vertex, second: null } }) + return + } + + const second = point ?? angleDraft.second + const first = referencePointForAngle(angleDraft.vertex, second, angleDraft.referenceLine) + if (!second || distanceBetweenMeasurements(angleDraft.vertex, second) < 1e-4) { + set({ angleDraft: null }) + return + } + + const id = `measurement-angle-${nextMeasurementId++}` + set((state) => ({ + angleDraft: null, + angles: [ + ...state.angles, + { + id, + first, + vertex: angleDraft.vertex!, + second, + view: angleDraft.view, + }, + ], + selectedId: id, + })) + }, + beginPolygon: (view, point) => + set({ + angleDraft: null, + draft: null, + polygonDraft: { points: [point], cursor: null, view }, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + }), + updatePolygon: (point) => + set((state) => + state.polygonDraft ? { polygonDraft: { ...state.polygonDraft, cursor: point } } : state, + ), + addPolygonPoint: (point) => + set((state) => + state.polygonDraft + ? { + polygonDraft: { + ...state.polygonDraft, + cursor: null, + points: [...state.polygonDraft.points, point], + }, + } + : state, + ), + commitPolygon: () => { + const { mode, polygonDraft } = get() + if (!polygonDraft || polygonDraft.points.length < 3) { + set({ polygonDraft: null, previewArea: null, previewPerimeter: null }) + return + } + + const { areaSquareMeters, labelPoint } = polygonAreaAndLabelPointFromMeasurements( + polygonDraft.points, + ) + if (mode === 'area') { + get().addArea(polygonDraft.view, labelPoint, areaSquareMeters, polygonDraft.points) + } else if (mode === 'perimeter') { + get().addPerimeter( + polygonDraft.view, + labelPoint, + polygonPerimeterFromMeasurements(polygonDraft.points), + polygonDraft.points, + ) + } + set({ polygonDraft: null, previewArea: null, previewPerimeter: null }) + }, + setPreviewSegment: (previewSegment) => set({ previewSegment }), + setPreviewArea: (previewArea) => set({ previewArea }), + setPreviewPerimeter: (previewPerimeter) => set({ previewPerimeter }), + setCursor: (view, point) => + set((state) => { + if (!point) { + return state.cursor?.view === view ? { cursor: null } : state + } + + const previousPoint = state.cursor?.point + if ( + state.cursor?.view === view && + previousPoint?.[0] === point[0] && + previousPoint[1] === point[1] && + previousPoint[2] === point[2] + ) { + return state + } + + return { cursor: { point, view } } + }), + setSnapTarget: (target) => set({ snapTarget: target }), + setMode: (mode) => + set({ + angleDraft: null, + draft: null, + mode, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + snapTarget: null, + }), + setDisplayPrecision: (displayPrecision) => set({ displayPrecision }), + setContinuousMeasurement: (continuousMeasurement) => set({ continuousMeasurement }), + setSnapKindEnabled: (kind, enabled) => + set((state) => ({ + enabledSnapKinds: { + ...state.enabledSnapKinds, + [kind]: enabled, + }, + snapTarget: state.snapTarget?.kind === kind && !enabled ? null : state.snapTarget, + })), + setAllSnapKindsEnabled: (enabled) => + set((state) => { + const enabledSnapKinds = Object.fromEntries( + Object.keys(DEFAULT_MEASUREMENT_SNAP_SETTINGS).map((kind) => [kind, enabled]), + ) as MeasurementSnapSettings + return { + enabledSnapKinds, + snapTarget: state.snapTarget?.kind && !enabled ? null : state.snapTarget, + } + }), + resetSnapKinds: () => + set((state) => ({ + enabledSnapKinds: DEFAULT_MEASUREMENT_SNAP_SETTINGS, + snapTarget: + state.snapTarget?.kind && !DEFAULT_MEASUREMENT_SNAP_SETTINGS[state.snapTarget.kind] + ? null + : state.snapTarget, + })), + selectMeasurement: (id) => set({ selectedId: id }), + startSegmentEndpointDrag: (id, endpoint) => + set((state) => { + const segment = state.segments.find((candidate) => candidate.id === id) + const linkedEndpoints = segment + ? collectLinkedMeasurementEndpoints(state.segments, segment[endpoint]) + : [] + return { + draggingSegmentEndpoint: { + attachment: segment?.[`${endpoint}Attachment`] ?? null, + detachLinkedEndpoints: false, + endpoint, + hasMoved: false, + id, + linkedEndpoints, + }, + selectedId: id, + suppressNextPlacementClick: false, + } + }), + updateSegmentEndpoint: (id, endpoint, point, detachLinkedEndpoints = false, attachment) => { + set((state) => { + const drag = state.draggingSegmentEndpoint + const activeDrag = drag?.id === id && drag.endpoint === endpoint ? drag : null + const sourceSegment = state.segments.find((segment) => segment.id === id) + const linkedEndpoints = activeDrag?.linkedEndpoints?.length + ? activeDrag.linkedEndpoints + : sourceSegment + ? [ + { + attachment: sourceSegment[`${endpoint}Attachment`], + endpoint, + id, + measuredDistanceMeters: sourceSegment.measuredDistanceMeters, + point: [...sourceSegment[endpoint]] as MeasurementPoint, + }, + ] + : [] + const linkedBySegment = new Map() + for (const linkedEndpoint of linkedEndpoints) { + const endpoints = linkedBySegment.get(linkedEndpoint.id) ?? [] + endpoints.push(linkedEndpoint) + linkedBySegment.set(linkedEndpoint.id, endpoints) + } + const nextDraggingSegmentEndpoint = activeDrag + ? { + ...activeDrag, + attachment: attachment === undefined ? activeDrag.attachment : attachment, + detachLinkedEndpoints, + hasMoved: true, + } + : state.draggingSegmentEndpoint + const draggingChanged = + nextDraggingSegmentEndpoint !== state.draggingSegmentEndpoint && + (nextDraggingSegmentEndpoint?.detachLinkedEndpoints !== activeDrag?.detachLinkedEndpoints || + nextDraggingSegmentEndpoint?.hasMoved !== activeDrag?.hasMoved || + !sameMeasurementPointAttachment( + nextDraggingSegmentEndpoint?.attachment, + activeDrag?.attachment, + )) + let segmentsChanged = false + const nextSegments = state.segments.map((segment) => { + const endpoints = linkedBySegment.get(segment.id) + if (!endpoints) return segment + let start = segment.start + let end = segment.end + let startAttachment = segment.startAttachment + let endAttachment = segment.endAttachment + for (const linkedEndpoint of endpoints) { + const isPrimary = linkedEndpoint.id === id && linkedEndpoint.endpoint === endpoint + const nextPoint = isPrimary || !detachLinkedEndpoints ? point : linkedEndpoint.point + const nextAttachment = + isPrimary || !detachLinkedEndpoints + ? attachment === undefined + ? linkedEndpoint.attachment + : (attachment ?? undefined) + : linkedEndpoint.attachment + if (linkedEndpoint.endpoint === 'start') { + start = nextPoint + startAttachment = nextAttachment + } else { + end = nextPoint + endAttachment = nextAttachment + } + } + const measuredDistanceMeters = + segment.id === id || !detachLinkedEndpoints + ? undefined + : endpoints[0]?.measuredDistanceMeters + if ( + sameMeasurementPoint(segment.start, start) && + sameMeasurementPoint(segment.end, end) && + sameMeasurementPointAttachment(segment.startAttachment, startAttachment) && + sameMeasurementPointAttachment(segment.endAttachment, endAttachment) && + segment.measuredDistanceMeters === measuredDistanceMeters + ) { + return segment + } + segmentsChanged = true + return { + ...segment, + start, + end, + startAttachment, + endAttachment, + measuredDistanceMeters, + } + }) + + if (!draggingChanged && !segmentsChanged) return state + + return { + draggingSegmentEndpoint: nextDraggingSegmentEndpoint, + segments: nextSegments, + } + }) + }, + endSegmentEndpointDrag: (options) => { + const drag = get().draggingSegmentEndpoint + if ( + drag?.hasMoved && + typeof options?.detachLinkedEndpoints === 'boolean' && + options.detachLinkedEndpoints !== (drag.detachLinkedEndpoints ?? false) + ) { + const segment = get().segments.find((candidate) => candidate.id === drag.id) + if (segment) { + get().updateSegmentEndpoint( + drag.id, + drag.endpoint, + segment[drag.endpoint], + options.detachLinkedEndpoints, + drag.attachment, + ) + } + } + set({ + draggingSegmentEndpoint: null, + suppressNextPlacementClick: Boolean(options?.suppressNextClick), + }) + }, + consumeSuppressedPlacementClick: () => { + const shouldSuppress = get().suppressNextPlacementClick + if (shouldSuppress) set({ suppressNextPlacementClick: false }) + return shouldSuppress + }, + removeMeasurement: (id) => { + set((state) => ({ + areas: state.areas.filter((area) => area.id !== id), + angles: state.angles.filter((angle) => angle.id !== id), + perimeters: state.perimeters.filter((perimeter) => perimeter.id !== id), + selectedId: state.selectedId === id ? null : state.selectedId, + segments: state.segments.filter((segment) => segment.id !== id), + })) + }, + deleteSelected: () => { + const selectedId = get().selectedId + if (!selectedId) return + get().removeMeasurement(selectedId) + }, + addSegment: (view, start, end, measuredDistanceMeters, startAttachment, endAttachment) => { + if (distanceBetweenMeasurements(start, end) < 1e-4) { + set({ draft: null, previewArea: null, previewPerimeter: null, previewSegment: null }) + return + } + + const id = `measurement-${nextMeasurementId++}` + set((state) => ({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: id, + segments: [ + ...state.segments, + { + id, + start, + end, + startAttachment, + endAttachment, + view, + measuredDistanceMeters, + }, + ], + })) + }, + updateSegmentLength: (id, lengthMeters) => { + if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-4)) return + set((state) => ({ + segments: state.segments.map((segment) => { + if (segment.id !== id) return segment + const currentLength = distanceBetweenMeasurements(segment.start, segment.end) + if (currentLength < 1e-4) return segment + const scale = lengthMeters / currentLength + const end: MeasurementPoint = [ + segment.start[0] + (segment.end[0] - segment.start[0]) * scale, + segment.start[1] + (segment.end[1] - segment.start[1]) * scale, + segment.start[2] + (segment.end[2] - segment.start[2]) * scale, + ] + return { ...segment, end, endAttachment: undefined, measuredDistanceMeters: lengthMeters } + }), + })) + }, + addArea: (view, labelPoint, areaSquareMeters, boundaryPoints) => { + if (!(Number.isFinite(areaSquareMeters) && areaSquareMeters > 1e-6)) { + set({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + }) + return + } + + const id = `measurement-area-${nextMeasurementId++}` + set((state) => ({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: id, + areas: [ + ...state.areas, + { + id, + areaSquareMeters, + boundaryPoints, + labelPoint, + view, + }, + ], + })) + }, + addPerimeter: (view, labelPoint, lengthMeters, boundaryPoints) => { + if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-6)) { + set({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + }) + return + } + + const id = `measurement-perimeter-${nextMeasurementId++}` + set((state) => ({ + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + perimeters: [ + ...state.perimeters, + { + id, + boundaryPoints, + labelPoint, + lengthMeters, + view, + }, + ], + selectedId: id, + })) + }, + cancelDraft: () => + set({ + angleDraft: null, + cursor: null, + draggingSegmentEndpoint: null, + draft: null, + polygonDraft: null, + previewArea: null, + previewPerimeter: null, + previewSegment: null, + snapTarget: null, + suppressNextPlacementClick: false, + }), + clear: () => { + set({ + angleDraft: null, + angles: [], + areas: [], + cursor: null, + draggingSegmentEndpoint: null, + draft: null, + polygonDraft: null, + perimeters: [], + previewArea: null, + previewPerimeter: null, + previewSegment: null, + selectedId: null, + segments: [], + snapTarget: null, + suppressNextPlacementClick: false, + }) + }, +})) diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index ad3d5a140..61f23729d 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -162,6 +162,11 @@ function getLengthUnitFactor(ifcApi: WebIFC.IfcAPI, modelID: number): number { type Mat4 = number[] +type IfcLocalPlacementLine = { + PlacementRelTo?: { value?: number } + RelativePlacement?: { value?: number } +} + function identity(): Mat4 { return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } @@ -270,7 +275,7 @@ function resolveWorldTransform(ifcApi: WebIFC.IfcAPI, modelID: number, placement let current: number | null = placementId while (current) { - const placement = ifcApi.GetLine(modelID, current) + const placement: IfcLocalPlacementLine = ifcApi.GetLine(modelID, current) if (placement.RelativePlacement?.value) { chain.push(placement.RelativePlacement.value) } diff --git a/packages/mcp/src/tools/variants/mutations.ts b/packages/mcp/src/tools/variants/mutations.ts index e3d89e479..6bf21077c 100644 --- a/packages/mcp/src/tools/variants/mutations.ts +++ b/packages/mcp/src/tools/variants/mutations.ts @@ -51,6 +51,11 @@ const WALL_THICKNESS_OPTIONS = [0.1, 0.15, 0.2, 0.25] as const const WALL_HEIGHT_OPTIONS = [2.4, 2.6, 2.7, 3.0] as const const FENCE_STYLES = ['privacy', 'slat', 'rail'] as const +type Segment2DNode = { + end?: [number, number] + start?: [number, number] +} + /** Fisher–Yates shuffle in place using the provided RNG. */ function shuffleInPlace(arr: T[], rng: Rng): void { for (let i = arr.length - 1; i > 0; i--) { @@ -95,7 +100,7 @@ function siteBounds( * still exercise something on partial scenes). */ function isPerimeterWall( - wall: AnyNode & { start?: [number, number]; end?: [number, number] }, + wall: Segment2DNode, bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null, epsilon = 0.01, ): boolean { @@ -148,10 +153,7 @@ function applyRoomProportions(graph: SceneGraph, rng: Rng): SceneGraph { const bounds = siteBounds(out) for (const node of Object.values(out.nodes)) { if (node.type !== 'wall') continue - const wall = node as AnyNode & { - start?: [number, number] - end?: [number, number] - } + const wall = node as Segment2DNode if (!(wall.start && wall.end)) continue if (isPerimeterWall(wall, bounds)) continue // Nudge each endpoint by ±10% of its current value. @@ -174,7 +176,7 @@ function applyOpenPlan(graph: SceneGraph, rng: Rng): SceneGraph { const interiorWallIds: AnyNodeId[] = [] for (const [id, node] of Object.entries(out.nodes)) { if (node.type !== 'wall') continue - if (isPerimeterWall(node as AnyNode, bounds)) continue + if (isPerimeterWall(node as Segment2DNode, bounds)) continue interiorWallIds.push(id as AnyNodeId) } if (interiorWallIds.length === 0) return out diff --git a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts index e8ac5d906..f17afce63 100644 --- a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts +++ b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts @@ -25,6 +25,54 @@ function treeContainsDescendant( } describe('cabinet tree structure', () => { + test('preserves measurement children on cabinet runs and modules', () => { + const run = { + ...CabinetNode.parse({ + id: 'cabinet_measurement-tree-run', + parentId: 'level_measurement_tree' as AnyNodeId, + }), + children: ['cabinet-module_measurement-tree-module', 'measurement_run'], + } + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_measurement-tree-module', + parentId: run.id, + children: ['measurement_module'], + }) + const runMeasurement = { + id: 'measurement_run', + type: 'measurement', + object: 'node', + parentId: run.id, + visible: true, + metadata: {}, + measurementId: 'measurement-run', + start: [0, 0, 0], + end: [1, 0, 0], + view: '3d', + } as AnyNode + const moduleMeasurement = { + id: 'measurement_module', + type: 'measurement', + object: 'node', + parentId: module.id, + visible: true, + metadata: {}, + measurementId: 'measurement-module', + start: [0, 0, 0], + end: [1, 0, 0], + view: '3d', + } as AnyNode + const nodes = { + [run.id]: run, + [module.id]: module, + [runMeasurement.id]: runMeasurement, + [moduleMeasurement.id]: moduleMeasurement, + } as Record + + expect(treeChildIds(run.id, nodes)).toEqual([module.id, runMeasurement.id]) + expect(treeChildIds(module.id, nodes)).toEqual([moduleMeasurement.id]) + }) + test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => { const sourceRun = { ...CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 3079d663a..0d8f8fc32 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -10,7 +10,8 @@ import type { NodeDefinition, SceneApi, } from '@pascal-app/core' -import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core' +import { findLevelAncestorId, sceneRegistry, selectionProxyIdFromMetadata } from '@pascal-app/core' +import { Vector3 } from 'three' import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' @@ -69,6 +70,40 @@ type CabinetLocalBounds = { center: [number, number, number] } +function resolveCabinetMeasurementOwnerId({ + ctx, + node, + worldPoint, +}: { + ctx: { children: AnyNode[] } + node: CabinetNodeType + worldPoint: readonly [number, number, number] +}): AnyNodeId | null { + const cabinetObject = sceneRegistry.nodes.get(node.id) + if (!cabinetObject) return null + + cabinetObject.updateWorldMatrix(true, false) + const local = cabinetObject.worldToLocal(new Vector3(...worldPoint)) + let best: { id: AnyNodeId; score: number } | null = null + + for (const child of ctx.children) { + if (child.type !== 'cabinet-module') continue + const module = child as CabinetModuleNodeType + const position = module.position ?? [0, 0, 0] + const halfWidth = module.width / 2 + Math.max(module.countertopOverhang ?? 0, 0.05) + const halfDepth = + module.depth / 2 + + Math.max(module.countertopOverhang ?? 0, module.countertopBackOverhang ?? 0, 0.05) + const dx = Math.abs(local.x - position[0]) + const dz = Math.abs(local.z - position[2]) + if (dx > halfWidth || dz > halfDepth) continue + const score = dx / Math.max(halfWidth, 1e-4) + dz / Math.max(halfDepth, 1e-4) + if (!best || score < best.score) best = { id: module.id as AnyNodeId, score } + } + + return best?.id ?? null +} + function isCabinetDuplicableNode(node: AnyNode | null | undefined): node is CabinetDuplicableNode { return ( (node?.type === 'cabinet' || node?.type === 'cabinet-module') && @@ -976,6 +1011,10 @@ export const cabinetDefinition: NodeDefinition = { slots: () => cabinetSlots(), }, + measurement: { + resolveOwnerId: resolveCabinetMeasurementOwnerId, + }, + // Dirty-cascade: a dirtied run re-marks its hosted modules so their // composite geometry re-flows with the run (see `cascadeDirty`). relations: { diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts index c28827b18..59671be17 100644 --- a/packages/nodes/src/cabinet/tree-structure.ts +++ b/packages/nodes/src/cabinet/tree-structure.ts @@ -82,7 +82,10 @@ function resolveCabinetRunChildIds( resolved.push(child.id as AnyNodeId) continue } - if (!isCabinetRun(child)) continue + if (!isCabinetRun(child)) { + if (child) resolved.push(child.id as AnyNodeId) + continue + } const link = cornerDerivedRunLink(child.metadata) if (link?.role === 'base-leg') { resolved.push(...resolveCabinetRunChildIds(child, nodes)) @@ -137,7 +140,10 @@ export function cabinetTreeChildIds( resolved.push(child.id as AnyNodeId) continue } - if (!isCabinetRun(child)) continue + if (!isCabinetRun(child)) { + if (child) resolved.push(child.id as AnyNodeId) + continue + } const link = cornerDerivedRunLink(child.metadata) if (link) { resolved.push(...cabinetModuleChildren(child, nodes).map((module) => module.id as AnyNodeId)) diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 6504f5157..51694b461 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -3,6 +3,7 @@ import type { HandleDescriptor, NodeDefinition, } from '@pascal-app/core' +import { surfaceMeasurement } from '../shared/measurement' import { buildCeilingFloorplan } from './floorplan' import { ceilingAddVertexAffordance, @@ -110,6 +111,7 @@ export const ceilingDefinition: NodeDefinition = { slots: () => ceilingSlots(), paint: ceilingPaint, }, + measurement: surfaceMeasurement(), relations: { hosts: ['item'], diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index 9c7d36372..e7837f70f 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -1,4 +1,9 @@ -import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core' +import { + type AnyNode, + type CeilingNode, + type FloorplanAffordance, + resolveLevelId, +} from '@pascal-app/core' import { resolveCeilingPlanPointSnap } from '@pascal-app/editor' import { createPolygonAddVertexAffordance, @@ -33,15 +38,9 @@ const ceilingSnapOptions = { }, } -export const ceilingMoveVertexAffordance = createPolygonVertexAffordance( - 'ceiling', - ceilingSnapOptions, -) -export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance( - 'ceiling', - ceilingSnapOptions, -) -export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance( - 'ceiling', - ceilingSnapOptions, -) +export const ceilingMoveVertexAffordance: FloorplanAffordance = + createPolygonVertexAffordance('ceiling', ceilingSnapOptions) +export const ceilingAddVertexAffordance: FloorplanAffordance = + createPolygonAddVertexAffordance('ceiling', ceilingSnapOptions) +export const ceilingMoveEdgeAffordance: FloorplanAffordance = + createPolygonMoveEdgeAffordance('ceiling', ceilingSnapOptions) diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 8b335cca9..918aec2d5 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -6,6 +6,7 @@ import type { RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { wallHostedOpeningMeasurement } from '../shared/measurement' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' @@ -209,6 +210,7 @@ export const doorDefinition: NodeDefinition = { slots: () => doorSlots(), paint: doorPaint, }, + measurement: wallHostedOpeningMeasurement(), parametrics: doorParametrics, handles: doorHandles, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index b283a2a0f..4a588699d 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -5,6 +5,7 @@ import { isSplineFence, type NodeDefinition, } from '@pascal-app/core' +import { fenceMeasurement } from '../shared/measurement' import { buildFenceFloorplan } from './floorplan' import { fenceControlPointAffordance, @@ -243,6 +244,7 @@ export const fenceDefinition: NodeDefinition = { // and `createFenceOnCurrentLevel`). drawTool: true, }, + measurement: fenceMeasurement(), relations: { linkedBy: 'endpoint-match', diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 9e0005ab6..61d6fb61d 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -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' @@ -107,6 +108,7 @@ export const builtinPlugin: Plugin = { hvacEquipmentDefinition as unknown as AnyNodeDefinition, linesetDefinition as unknown as AnyNodeDefinition, liquidLineDefinition as unknown as AnyNodeDefinition, + measurementDefinition as unknown as AnyNodeDefinition, // DWV plumbing — Phase 2 of the research doc's plan. pipeSegmentDefinition as unknown as AnyNodeDefinition, pipeFittingDefinition 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' diff --git a/packages/nodes/src/measurement/definition.ts b/packages/nodes/src/measurement/definition.ts new file mode 100644 index 000000000..179275104 --- /dev/null +++ b/packages/nodes/src/measurement/definition.ts @@ -0,0 +1,37 @@ +import { MeasurementNode as MeasurementNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { MeasurementNode } from './schema' + +export const measurementDefinition: NodeDefinition = { + kind: 'measurement', + bake: 'strip', + schemaVersion: 1, + schema: MeasurementNode, + category: 'site', + defaults: () => { + const stub = MeasurementNodeSchema.parse({ + id: 'measurement_default', + measurementId: 'measurement_default', + start: [0, 0, 0], + end: [1, 0, 0], + view: '3d', + }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + capabilities: { + deletable: true, + duplicable: false, + presettable: false, + }, + dirtyTracking: false, + presentation: { + label: 'Measurement', + description: 'A linear dimension attached to scene geometry.', + icon: { kind: 'url', src: '/icons/blueprint.webp' }, + paletteSection: 'site', + paletteOrder: 31, + }, + mcp: { + description: 'A linear dimension attached to scene geometry.', + }, +} diff --git a/packages/nodes/src/measurement/index.ts b/packages/nodes/src/measurement/index.ts new file mode 100644 index 000000000..9297d6530 --- /dev/null +++ b/packages/nodes/src/measurement/index.ts @@ -0,0 +1 @@ +export { measurementDefinition } from './definition' diff --git a/packages/nodes/src/measurement/schema.ts b/packages/nodes/src/measurement/schema.ts new file mode 100644 index 000000000..cdb4e093b --- /dev/null +++ b/packages/nodes/src/measurement/schema.ts @@ -0,0 +1 @@ +export { MeasurementNode } from '@pascal-app/core' diff --git a/packages/nodes/src/r3f.d.ts b/packages/nodes/src/r3f.d.ts new file mode 100644 index 000000000..597d2a688 --- /dev/null +++ b/packages/nodes/src/r3f.d.ts @@ -0,0 +1,23 @@ +export {} + +type PascalNodesElements = { + lineBasicNodeMaterial: any +} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements extends PascalNodesElements {} + } +} + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends PascalNodesElements {} + } +} + +declare module 'react/jsx-dev-runtime' { + namespace JSX { + interface IntrinsicElements extends PascalNodesElements {} + } +} diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 355cab796..a82ed35e9 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -6,6 +6,7 @@ import { RoofSegmentNode as RoofSegmentNodeSchema, type RoofSegmentNode as RoofSegmentNodeType, } from '@pascal-app/core' +import { roofSegmentMeasurement } from '../shared/measurement' import { buildRoofSegmentFloorplan } from './floorplan' import { roofSegmentMoveTarget, @@ -287,6 +288,7 @@ export const roofSegmentDefinition: NodeDefinition = { duplicable: true, deletable: true, }, + measurement: roofSegmentMeasurement(), // Bespoke move shared with roof / stair / stair-segment via // `shared/move-roof-tool` — routed through `MoveTool`'s registry- diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 17e514d64..68e652ab1 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -1,12 +1,16 @@ import { + type AnyNode, type AnyNodeId, + getRoofSegmentSurfaceY, type HandleDescriptor, type NodeDefinition, RoofNode as RoofNodeSchema, type RoofNode as RoofNodeType, type RoofSegmentNode, type SceneApi, + sceneRegistry, } from '@pascal-app/core' +import { Vector3 } from 'three' import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' import { RoofNode } from './schema' @@ -78,6 +82,43 @@ function roofMoveHandle(): HandleDescriptor { } const roofHandles: HandleDescriptor[] = [roofMoveHandle()] +const roofMeasurementWorldPoint = new Vector3() +const roofMeasurementLocalPoint = new Vector3() + +function resolveRoofMeasurementOwnerId({ + ctx, + worldPoint, +}: { + ctx: { children: AnyNode[] } + worldPoint: readonly [number, number, number] +}): AnyNodeId | null { + roofMeasurementWorldPoint.set(worldPoint[0], worldPoint[1], worldPoint[2]) + let firstSegmentId: AnyNodeId | null = null + let best: { id: AnyNodeId; score: number } | null = null + + for (const child of ctx.children) { + if (child.type !== 'roof-segment') continue + const segment = child as RoofSegmentNode + const segmentObject = sceneRegistry.nodes.get(segment.id) + if (!segmentObject) continue + segmentObject.updateWorldMatrix(true, false) + const local = segmentObject.worldToLocal( + roofMeasurementLocalPoint.copy(roofMeasurementWorldPoint), + ) + firstSegmentId ??= segment.id + + const overhang = segment.overhang ?? 0 + const halfWidth = segment.width / 2 + overhang + const halfDepth = segment.depth / 2 + overhang + if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) continue + + const surfaceY = getRoofSegmentSurfaceY(segment, local.x, local.z) + const score = Math.abs(local.y - surfaceY) + if (!best || score < best.score) best = { id: segment.id, score } + } + + return best?.id ?? firstSegmentId +} /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` @@ -167,6 +208,10 @@ export const roofDefinition: NodeDefinition = { move: () => import('../shared/move-roof-tool'), }, + measurement: { + resolveOwnerId: resolveRoofMeasurementOwnerId, + }, + parametrics: roofParametrics, handles: roofHandles, floorplan: buildRoofFloorplan, diff --git a/packages/nodes/src/shared/measurement.ts b/packages/nodes/src/shared/measurement.ts new file mode 100644 index 000000000..b2757b3ae --- /dev/null +++ b/packages/nodes/src/shared/measurement.ts @@ -0,0 +1,941 @@ +import type { + AnyNode, + CeilingNode, + DoorNode, + FenceNode, + GeometryContext, + MeasurementDefinition, + MeasurementDefinitionArea, + MeasurementDefinitionDirectLength, + MeasurementDefinitionPerimeter, + MeasurementDefinitionPoint, + MeasurementDefinitionSnapGeometry, + RoofNode, + RoofSegmentNode, + SiteNode, + SlabNode, + StairNode, + WallNode, + WindowNode, + ZoneNode, +} from '@pascal-app/core' +import { + DEFAULT_WALL_HEIGHT, + getDutchRoofMetrics, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, + getRenderableSlabPolygon, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + sampleFenceCenterline, + sampleWallCenterline, + stairFootprintAABB, +} from '@pascal-app/core' + +const CURVE_SNAP_SEGMENTS = 32 +const WALL_SIDE_NORMAL_Y_THRESHOLD = 0.7 +const WALL_SIDE_HEIGHT_EPSILON = 0.05 + +type SurfaceNode = SlabNode | CeilingNode | ZoneNode +type PlanPoint = { x: number; y: number } +type PlanAABB = { minX: number; minZ: number; maxX: number; maxZ: number } + +function polygonAreaAndCentroid(polygon: ReadonlyArray): { + area: number + centroid: { x: number; y: number } +} { + let cx = 0 + let cy = 0 + let area = 0 + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const p1 = polygon[j]! + const p2 = polygon[i]! + const f = p1[0] * p2[1] - p2[0] * p1[1] + cx += (p1[0] + p2[0]) * f + cy += (p1[1] + p2[1]) * f + area += f + } + + area /= 2 + if (Math.abs(area) < 1e-9) { + const fallback = polygon[0] ?? [0, 0] + return { area: 0, centroid: { x: fallback[0], y: fallback[1] } } + } + + return { + area: Math.abs(area), + centroid: { x: cx / (6 * area), y: cy / (6 * area) }, + } +} + +function polygonPerimeter(polygon: ReadonlyArray): number { + return polygon.reduce((sum, point, index) => { + const next = polygon[(index + 1) % polygon.length] ?? point + return sum + Math.hypot(next[0] - point[0], next[1] - point[1]) + }, 0) +} + +function surfaceY(node: SurfaceNode): number { + if (node.type === 'ceiling') return node.height + if (node.type === 'slab') return node.elevation + return 0 +} + +function surfaceBoundaryPolygon(node: SurfaceNode): Array<[number, number]> { + return node.type === 'slab' ? getRenderableSlabPolygon(node) : node.polygon +} + +function addPolygonSnapGeometry( + geometry: Required, + polygon: ReadonlyArray, + y: number, + labels: { center: string; edge: string; vertex: string }, +) { + const points = polygon.map((point) => [point[0], y, point[1]] as const) + const centroid = polygonAreaAndCentroid(polygon).centroid + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: labels.vertex, + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: labels.center, + kind: 'center', + point: [centroid.x, y, centroid.y], + priority: 2, + }) +} + +function addRectangleSnapGeometry( + geometry: Required, + polygon: ReadonlyArray, + labels: { center: string; edge: string; vertex: string }, +) { + if (polygon.length === 0) return + const centroid = { + x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length, + y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length, + } + const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementDefinitionPoint) + for (let index = 0; index < points.length; index += 1) { + const start = points[index]! + const end = points[(index + 1) % points.length]! + geometry.anchors.push({ + label: labels.vertex, + kind: 'vertex', + point: start, + priority: 0, + targetLine: { end, start }, + }) + geometry.anchors.push({ + label: 'Edge midpoint', + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }) + geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 }) + } + geometry.anchors.push({ + label: labels.center, + kind: 'center', + point: [centroid.x, 0, centroid.y], + priority: 2, + }) +} + +function addAabbSnapGeometry( + geometry: Required, + aabb: PlanAABB, + labels: { center: string; edge: string; vertex: string }, +) { + addRectangleSnapGeometry( + geometry, + [ + { x: aabb.minX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.minZ }, + { x: aabb.maxX, y: aabb.maxZ }, + { x: aabb.minX, y: aabb.maxZ }, + ], + labels, + ) +} + +function rotatePlanPoint(x: number, z: number, rotation: number): PlanPoint { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return { x: x * cos + z * sin, y: -x * sin + z * cos } +} + +function rotatedRectanglePolygon( + center: PlanPoint, + width: number, + depth: number, + rotation: number, +): PlanPoint[] { + const corners: Array<[number, number]> = [ + [-width / 2, -depth / 2], + [width / 2, -depth / 2], + [width / 2, depth / 2], + [-width / 2, depth / 2], + ] + return corners.map(([x, z]) => { + const rotated = rotatePlanPoint(x, z, rotation) + return { x: center.x + rotated.x, y: center.y + rotated.y } + }) +} + +function closestPointOnSegment( + point: PlanPoint, + start: PlanPoint, + end: PlanPoint, +): { distanceSq: number; t: number } { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSq = dx * dx + dy * dy + if (lengthSq < 1e-9) { + const ox = point.x - start.x + const oy = point.y - start.y + return { distanceSq: ox * ox + oy * oy, t: 0 } + } + + const t = Math.max( + 0, + Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq), + ) + const projected = { x: start.x + dx * t, y: start.y + dy * t } + const ox = point.x - projected.x + const oy = point.y - projected.y + return { distanceSq: ox * ox + oy * oy, t } +} + +function wallFaceAnchor( + node: WallNode, + cursorPoint: MeasurementDefinitionPoint, + cursorNormal?: MeasurementDefinitionPoint | null, +): MeasurementDefinitionPoint { + const planPoint = { x: cursorPoint[0], y: cursorPoint[2] } + const sampleCount = Math.max(CURVE_SNAP_SEGMENTS, Math.ceil(getWallCurveLength(node) / 0.25)) + let best: { + distanceSq: number + frameT: number + } | null = null + let previous = getWallCurveFrameAt(node, 0).point + + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(node, index / sampleCount).point + const projected = closestPointOnSegment(planPoint, previous, current) + if (!best || projected.distanceSq < best.distanceSq) { + best = { + distanceSq: projected.distanceSq, + frameT: (index - 1 + projected.t) / sampleCount, + } + } + previous = current + } + + const frame = getWallCurveFrameAt(node, best?.frameT ?? 0) + const signedCursorOffset = + (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y + const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0 + const normalSide = + cursorNormal && normalPlanLength > 1e-6 + ? Math.sign( + (cursorNormal[0] / normalPlanLength) * frame.normal.x + + (cursorNormal[2] / normalPlanLength) * frame.normal.y, + ) + : 0 + const cursorSide = Math.sign(signedCursorOffset) + const side = normalSide || cursorSide || 1 + const offset = (getWallThickness(node) / 2) * side + + return [ + frame.point.x + frame.normal.x * offset, + cursorPoint[1], + frame.point.y + frame.normal.y * offset, + ] +} + +function fenceFaceAnchor( + node: FenceNode, + cursorPoint: MeasurementDefinitionPoint, + cursorNormal?: MeasurementDefinitionPoint | null, +): MeasurementDefinitionPoint { + const planPoint = { x: cursorPoint[0], y: cursorPoint[2] } + const sampleCount = Math.max( + CURVE_SNAP_SEGMENTS, + Math.ceil(getFenceCenterlineLength(node) / 0.25), + ) + let best: { + distanceSq: number + frameT: number + } | null = null + let previous = getFenceCenterlineFrameAt(node, 0).point + + for (let index = 1; index <= sampleCount; index += 1) { + const current = getFenceCenterlineFrameAt(node, index / sampleCount).point + const projected = closestPointOnSegment(planPoint, previous, current) + if (!best || projected.distanceSq < best.distanceSq) { + best = { + distanceSq: projected.distanceSq, + frameT: (index - 1 + projected.t) / sampleCount, + } + } + previous = current + } + + const frame = getFenceCenterlineFrameAt(node, best?.frameT ?? 0) + const signedCursorOffset = + (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y + const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0 + const normalSide = + cursorNormal && normalPlanLength > 1e-6 + ? Math.sign( + (cursorNormal[0] / normalPlanLength) * frame.normal.x + + (cursorNormal[2] / normalPlanLength) * frame.normal.y, + ) + : 0 + const cursorSide = Math.sign(signedCursorOffset) + const side = normalSide || cursorSide || 1 + const offset = ((node.thickness ?? 0.08) / 2) * side + + return [ + frame.point.x + frame.normal.x * offset, + cursorPoint[1], + frame.point.y + frame.normal.y * offset, + ] +} + +export function wallMeasurement(): MeasurementDefinition { + return { + directLength: (node, _ctx, cursorPoint, cursorNormal) => { + const height = node.height ?? DEFAULT_WALL_HEIGHT + const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null + const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD + const isWallSideHover = + !cursorNormal && + cursorPoint && + cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON && + cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON + if (cursorPoint && (isSideNormal || isWallSideHover)) { + const anchor = wallFaceAnchor(node, cursorPoint, cursorNormal) + return { + start: [anchor[0], 0, anchor[2]], + end: [anchor[0], height, anchor[2]], + measuredDistanceMeters: height, + } + } + return { + start: [node.start[0], 0, node.start[1]], + end: [node.end[0], 0, node.end[1]], + measuredDistanceMeters: getWallCurveLength(node), + } + }, + snapGeometry: (node) => { + const midpoint = getWallCurveFrameAt(node, 0.5).point + const geometry: Required = { + anchors: [ + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.start[0], 0, node.start[1]], + priority: 0, + }, + { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 }, + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.end[0], 0, node.end[1]], + priority: 0, + }, + ], + segments: [], + } + const samples = sampleWallCenterline(node, CURVE_SNAP_SEGMENTS) + for (let index = 1; index < samples.length; index += 1) { + const startPoint = samples[index - 1]! + const endPoint = samples[index]! + geometry.segments.push({ + label: 'Wall edge', + kind: 'edge', + sourceId: node.id, + start: [startPoint.x, 0, startPoint.y], + end: [endPoint.x, 0, endPoint.y], + priority: 3, + }) + } + return geometry + }, + } +} + +export function fenceMeasurement(): MeasurementDefinition { + return { + directLength: (node, _ctx, cursorPoint, cursorNormal) => { + const height = node.height ?? 1.8 + const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null + const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD + const isFenceSideHover = + !cursorNormal && + cursorPoint && + cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON && + cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON + if (cursorPoint && (isSideNormal || isFenceSideHover)) { + const anchor = fenceFaceAnchor(node, cursorPoint, cursorNormal) + return { + start: [anchor[0], 0, anchor[2]], + end: [anchor[0], height, anchor[2]], + measuredDistanceMeters: height, + } + } + return { + start: [node.start[0], 0, node.start[1]], + end: [node.end[0], 0, node.end[1]], + measuredDistanceMeters: getFenceCenterlineLength(node), + } + }, + snapGeometry: (node) => { + const midpoint = getFenceCenterlineFrameAt(node, 0.5).point + const geometry: Required = { + anchors: [ + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.start[0], 0, node.start[1]], + priority: 0, + }, + ...(node.path ?? []).map((point) => ({ + label: 'Path point', + kind: 'vertex' as const, + point: [point[0], 0, point[1]] as MeasurementDefinitionPoint, + priority: 0, + })), + { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 }, + { + label: 'Endpoint', + kind: 'endpoint', + point: [node.end[0], 0, node.end[1]], + priority: 0, + }, + ], + segments: [], + } + const samples = sampleFenceCenterline(node, CURVE_SNAP_SEGMENTS) + for (let index = 1; index < samples.length; index += 1) { + const startPoint = samples[index - 1]! + const endPoint = samples[index]! + geometry.segments.push({ + label: 'Fence edge', + kind: 'edge', + sourceId: node.id, + start: [startPoint.x, 0, startPoint.y], + end: [endPoint.x, 0, endPoint.y], + priority: 3, + }) + } + return geometry + }, + } +} + +export function surfaceMeasurement(): MeasurementDefinition { + return { + applyLiveTransform: (node, live) => + ({ + ...node, + holes: ('holes' in node ? (node.holes ?? []) : []).map((hole) => + hole.map(([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number]), + ), + polygon: node.polygon.map( + ([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number], + ), + }) as AnyNode, + area: (node) => { + const boundary = surfaceBoundaryPolygon(node) + const outer = polygonAreaAndCentroid(boundary) + const holes = 'holes' in node ? node.holes : [] + const holesArea = holes.reduce((sum, hole) => sum + polygonAreaAndCentroid(hole).area, 0) + const y = surfaceY(node) + return { + areaSquareMeters: Math.max(0, outer.area - holesArea), + boundaryPoints: boundary.map((point) => [point[0], y + 0.02, point[1]]), + labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y], + } satisfies MeasurementDefinitionArea + }, + perimeter: (node) => { + const boundary = surfaceBoundaryPolygon(node) + const outer = polygonAreaAndCentroid(boundary) + const holes = 'holes' in node ? node.holes : [] + const holesLength = holes.reduce((sum, hole) => sum + polygonPerimeter(hole), 0) + const y = surfaceY(node) + return { + boundaryPoints: boundary.map((point) => [point[0], y + 0.02, point[1]]), + labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y], + lengthMeters: polygonPerimeter(boundary) + holesLength, + } satisfies MeasurementDefinitionPerimeter + }, + snapGeometry: (node) => { + const y = surfaceY(node) + const geometry: Required = { anchors: [], segments: [] } + addPolygonSnapGeometry(geometry, surfaceBoundaryPolygon(node), y, { + center: 'Center', + edge: 'Edge', + vertex: 'Vertex', + }) + const holes = 'holes' in node ? (node.holes ?? []) : [] + for (const hole of holes) { + addPolygonSnapGeometry(geometry, hole, y, { + center: 'Surface opening center', + edge: 'Surface opening edge', + vertex: 'Surface opening vertex', + }) + } + return geometry + }, + } +} + +export function siteMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node) => { + const geometry: Required = { anchors: [], segments: [] } + addPolygonSnapGeometry(geometry, node.polygon.points, 0, { + center: 'Property line center', + edge: 'Property line edge', + vertex: 'Property line vertex', + }) + return geometry + }, + } +} + +export function stairMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const nodes = Object.fromEntries( + [node, ...ctx.children, ...ctx.siblings, ...(ctx.parent ? [ctx.parent] : [])].map( + (entry) => [entry.id, entry], + ), + ) + const aabb = stairFootprintAABB(node, nodes) + if (!aabb) return null + const geometry: Required = { anchors: [], segments: [] } + addAabbSnapGeometry(geometry, aabb, { + center: 'Stair center', + edge: 'Stair edge', + vertex: 'Stair corner', + }) + return geometry + }, + } +} + +type RoofPlanPoint = readonly [number, number] +type RoofPlanSegment = readonly [RoofPlanPoint, RoofPlanPoint] + +function roofSegmentPlanFrame( + segment: RoofSegmentNode, + ctx: GeometryContext, +): { center: PlanPoint; rotation: number } { + const parentRoof = segment.parentId ? ctx.resolve(segment.parentId as never) : undefined + const parentPosition = parentRoof?.position ?? [0, 0, 0] + const parentRotation = parentRoof?.rotation ?? 0 + const offset = rotatePlanPoint(segment.position[0], segment.position[2], parentRotation) + return { + center: { x: parentPosition[0] + offset.x, y: parentPosition[2] + offset.y }, + rotation: parentRotation + segment.rotation, + } +} + +function roofSegmentPlanLinework(node: RoofSegmentNode): { + breaks: RoofPlanSegment[] + hips: RoofPlanSegment[] + ridges: RoofPlanSegment[] + slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null +} { + const hw = node.width / 2 + const hd = node.depth / 2 + const ridges: RoofPlanSegment[] = [] + const hips: RoofPlanSegment[] = [] + const breaks: RoofPlanSegment[] = [] + let slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null = null + const e1: RoofPlanPoint = [-hw, hd] + const e2: RoofPlanPoint = [hw, hd] + const e3: RoofPlanPoint = [hw, -hd] + const e4: RoofPlanPoint = [-hw, -hd] + const pushHip = () => { + if (Math.abs(node.width - node.depth) < 0.01) { + const peak: RoofPlanPoint = [0, 0] + hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak]) + } else if (node.width >= node.depth) { + const r1: RoofPlanPoint = [-hw + hd, 0] + const r2: RoofPlanPoint = [hw - hd, 0] + ridges.push([r1, r2]) + hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2]) + } else { + const r1: RoofPlanPoint = [0, hd - hw] + const r2: RoofPlanPoint = [0, -hd + hw] + ridges.push([r1, r2]) + hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2]) + } + } + switch (node.roofType) { + case 'flat': + break + case 'gable': + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + break + case 'shed': + slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] } + break + case 'hip': + pushHip() + break + case 'gambrel': { + const mz = hd * node.gambrelLowerWidthRatio + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + breaks.push( + [ + [-hw, mz], + [hw, mz], + ], + [ + [-hw, -mz], + [hw, -mz], + ], + ) + break + } + case 'mansard': { + const inset = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio + if (hw - inset > 0.02 && hd - inset > 0.02) { + const w1: RoofPlanPoint = [-hw + inset, hd - inset] + const w2: RoofPlanPoint = [hw - inset, hd - inset] + const w3: RoofPlanPoint = [hw - inset, -hd + inset] + const w4: RoofPlanPoint = [-hw + inset, -hd + inset] + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + } else { + pushHip() + } + break + } + case 'dutch': { + const metrics = getDutchRoofMetrics(node) + if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) { + pushHip() + break + } + const w1: RoofPlanPoint = [-metrics.waistHalfX, metrics.waistHalfZ] + const w2: RoofPlanPoint = [metrics.waistHalfX, metrics.waistHalfZ] + const w3: RoofPlanPoint = [metrics.waistHalfX, -metrics.waistHalfZ] + const w4: RoofPlanPoint = [-metrics.waistHalfX, -metrics.waistHalfZ] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + ridges.push([metrics.ridgeStart, metrics.ridgeEnd]) + break + } + } + return { breaks, hips, ridges, slope } +} + +function addRoofPlanSegment( + geometry: Required, + segment: RoofPlanSegment, + toPlan: (point: RoofPlanPoint) => MeasurementDefinitionPoint, + label: string, + sourceId: string, +) { + const start = toPlan(segment[0]) + const end = toPlan(segment[1]) + geometry.anchors.push( + { label: `${label} endpoint`, kind: 'endpoint', point: start, priority: 0 }, + { + label: `${label} midpoint`, + kind: 'midpoint', + point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], + priority: 1, + }, + { label: `${label} endpoint`, kind: 'endpoint', point: end, priority: 0 }, + ) + geometry.segments.push({ + label: `${label} edge`, + kind: 'edge', + sourceId, + start, + end, + priority: 3, + }) +} + +export function roofSegmentMeasurement(): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const { center, rotation } = roofSegmentPlanFrame(node, ctx) + const geometry: Required = { anchors: [], segments: [] } + addRectangleSnapGeometry( + geometry, + rotatedRectanglePolygon(center, node.width, node.depth, rotation), + { + center: 'Roof center', + edge: 'Roof eave edge', + vertex: 'Roof eave corner', + }, + ) + for (const anchor of geometry.anchors) { + if (anchor.label === 'Roof eave corner') anchor.priority = -1 + } + const toPlan = ([localX, localZ]: RoofPlanPoint): MeasurementDefinitionPoint => { + const offsetPoint = rotatePlanPoint(localX, localZ, rotation) + return [center.x + offsetPoint.x, 0, center.y + offsetPoint.y] + } + const linework = roofSegmentPlanLinework(node) + for (const ridge of linework.ridges) + addRoofPlanSegment(geometry, ridge, toPlan, 'Roof ridge', `${node.id}:ridge`) + for (const hip of linework.hips) + addRoofPlanSegment(geometry, hip, toPlan, 'Roof hip', `${node.id}:hip`) + for (const roofBreak of linework.breaks) + addRoofPlanSegment(geometry, roofBreak, toPlan, 'Roof break', `${node.id}:break`) + if (linework.slope) { + addRoofPlanSegment( + geometry, + [linework.slope.tail, linework.slope.head], + toPlan, + 'Roof slope', + `${node.id}:slope`, + ) + } + return geometry + }, + } +} + +export function roofHostedRectangleMeasurement< + N extends { + id: string + parentId?: string | null + position: readonly [number, number, number] + rotation?: number + roofSegmentId?: string | null + }, +>( + dimensions: (node: N) => { depth: number; width: number }, + labels: { center: string; edge: string; vertex: string }, +): MeasurementDefinition { + return { + snapGeometry: (node, ctx) => { + const hostId = node.roofSegmentId ?? node.parentId + const segment = hostId ? ctx.resolve(hostId as never) : undefined + if (!segment) return null + const { width, depth } = dimensions(node) + if (!(width > 1e-4 && depth > 1e-4)) return null + const frame = roofSegmentPlanFrame(segment, ctx) + const centerOffset = rotatePlanPoint(node.position[0], node.position[2], frame.rotation) + const center = { x: frame.center.x + centerOffset.x, y: frame.center.y + centerOffset.y } + const geometry: Required = { anchors: [], segments: [] } + addRectangleSnapGeometry( + geometry, + rotatedRectanglePolygon(center, width, depth, frame.rotation + (node.rotation ?? 0)), + labels, + ) + return geometry + }, + } +} + +export function wallHostedOpeningMeasurement< + N extends DoorNode | WindowNode, +>(): MeasurementDefinition { + type OpeningFrame = { + bottomY: number + centerX: number + centerZ: number + dirX: number + dirZ: number + host: WallNode + leftS: number + normalX: number + normalZ: number + rightS: number + topY: number + } + + const openingFrame = (node: N, ctx: GeometryContext): OpeningFrame | null => { + const hostId = node.wallId ?? node.parentId + const host = hostId ? ctx.resolve(hostId as never) : undefined + if (host?.type !== 'wall') return null + + const dx = host.end[0] - host.start[0] + const dz = host.end[1] - host.start[1] + const hostLength = Math.hypot(dx, dz) + if (hostLength < 1e-4 || node.width < 1e-4 || node.height < 1e-4) return null + + const dirX = dx / hostLength + const dirZ = dz / hostLength + const centerS = node.position[0] + const centerX = host.start[0] + dirX * centerS + const centerZ = host.start[1] + dirZ * centerS + const halfWidth = node.width / 2 + const centerY = node.position[1] + + return { + bottomY: centerY - node.height / 2, + centerX, + centerZ, + dirX, + dirZ, + host, + leftS: centerS - halfWidth, + normalX: -dirZ, + normalZ: dirX, + rightS: centerS + halfWidth, + topY: centerY + node.height / 2, + } + } + + const pointFromFrame = ( + frame: OpeningFrame, + alongWall: number, + y: number, + faceOffset: number, + ): MeasurementDefinitionPoint => [ + frame.host.start[0] + frame.dirX * alongWall + frame.normalX * faceOffset, + y, + frame.host.start[1] + frame.dirZ * alongWall + frame.normalZ * faceOffset, + ] + + const faceOffsetFromCursor = ( + node: N, + frame: OpeningFrame, + cursorPoint: MeasurementDefinitionPoint, + ): number => { + const offset = + (cursorPoint[0] - frame.centerX) * frame.normalX + + (cursorPoint[2] - frame.centerZ) * frame.normalZ + return Math.abs(offset) > 1e-6 ? offset : (node.position[2] ?? 0) + } + + const closestOpeningEdge = ( + node: N, + frame: OpeningFrame, + cursorPoint: MeasurementDefinitionPoint, + ): MeasurementDefinitionDirectLength => { + const faceOffset = faceOffsetFromCursor(node, frame, cursorPoint) + const cursorS = + (cursorPoint[0] - frame.host.start[0]) * frame.dirX + + (cursorPoint[2] - frame.host.start[1]) * frame.dirZ + const cursorY = cursorPoint[1] + const horizontalDistanceSq = (edgeY: number) => { + const clampedS = Math.max(frame.leftS, Math.min(frame.rightS, cursorS)) + const ds = cursorS - clampedS + const dy = cursorY - edgeY + return ds * ds + dy * dy + } + const verticalDistanceSq = (edgeS: number) => { + const clampedY = Math.max(frame.bottomY, Math.min(frame.topY, cursorY)) + const ds = cursorS - edgeS + const dy = cursorY - clampedY + return ds * ds + dy * dy + } + const candidates = [ + { + distanceSq: horizontalDistanceSq(frame.bottomY), + end: pointFromFrame(frame, frame.rightS, frame.bottomY, faceOffset), + measuredDistanceMeters: node.width, + start: pointFromFrame(frame, frame.leftS, frame.bottomY, faceOffset), + }, + { + distanceSq: horizontalDistanceSq(frame.topY), + end: pointFromFrame(frame, frame.rightS, frame.topY, faceOffset), + measuredDistanceMeters: node.width, + start: pointFromFrame(frame, frame.leftS, frame.topY, faceOffset), + }, + { + distanceSq: verticalDistanceSq(frame.leftS), + end: pointFromFrame(frame, frame.leftS, frame.topY, faceOffset), + measuredDistanceMeters: node.height, + start: pointFromFrame(frame, frame.leftS, frame.bottomY, faceOffset), + }, + { + distanceSq: verticalDistanceSq(frame.rightS), + end: pointFromFrame(frame, frame.rightS, frame.topY, faceOffset), + measuredDistanceMeters: node.height, + start: pointFromFrame(frame, frame.rightS, frame.bottomY, faceOffset), + }, + ] + return candidates.reduce((best, candidate) => + candidate.distanceSq < best.distanceSq ? candidate : best, + ) + } + + const directLength = ( + node: N, + ctx: GeometryContext, + cursorPoint?: MeasurementDefinitionPoint | null, + ): MeasurementDefinitionDirectLength | null => { + const frame = openingFrame(node, ctx) + if (!frame) return null + + if (cursorPoint) return closestOpeningEdge(node, frame, cursorPoint) + + return { + start: pointFromFrame(frame, frame.leftS, 0, node.position[2] ?? 0), + end: pointFromFrame(frame, frame.rightS, 0, node.position[2] ?? 0), + measuredDistanceMeters: node.width, + } + } + + return { + directLength, + snapGeometry: (node, ctx) => { + const segment = directLength(node, ctx) + if (!segment) return null + return { + anchors: [ + { label: 'Opening endpoint', kind: 'endpoint', point: segment.start, priority: 0 }, + { + label: 'Opening center', + kind: 'center', + point: [ + (segment.start[0] + segment.end[0]) / 2, + (segment.start[1] + segment.end[1]) / 2, + (segment.start[2] + segment.end[2]) / 2, + ], + priority: 0, + }, + { label: 'Opening endpoint', kind: 'endpoint', point: segment.end, priority: 0 }, + ], + segments: [ + { + label: 'Opening edge', + kind: 'edge', + sourceId: node.id, + start: segment.start, + end: segment.end, + priority: 2, + }, + ], + } + }, + } +} diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index c944325cb..c388f756d 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -1,4 +1,5 @@ import { type NodeDefinition, SiteNode as SiteNodeSchema } from '@pascal-app/core' +import { siteMeasurement } from '../shared/measurement' import { siteParametrics } from './parametrics' import { SiteNode } from './schema' @@ -28,6 +29,7 @@ export const siteDefinition: NodeDefinition = { deletable: false, presettable: false, }, + measurement: { ...siteMeasurement(), pick3D: false }, parametrics: siteParametrics, // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. diff --git a/packages/nodes/src/site/renderer.test.ts b/packages/nodes/src/site/renderer.test.ts new file mode 100644 index 000000000..c07885fe5 --- /dev/null +++ b/packages/nodes/src/site/renderer.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' +import { Path, Shape } from 'three' +import { createRenderableSiteGroundGeometry } from './renderer' + +function squareShape(size = 1): Shape { + const shape = new Shape() + shape.moveTo(0, 0) + shape.lineTo(size, 0) + shape.lineTo(size, size) + shape.lineTo(0, size) + shape.closePath() + return shape +} + +function squarePath(size = 1): Path { + const path = new Path() + path.moveTo(0, 0) + path.lineTo(size, 0) + path.lineTo(size, size) + path.lineTo(0, size) + path.closePath() + return path +} + +describe('site renderer geometry', () => { + test('skips ground fills with no drawable triangles', () => { + const shape = squareShape() + shape.holes.push(squarePath()) + + expect(createRenderableSiteGroundGeometry(shape)).toBeNull() + }) + + test('keeps drawable ground fills renderable for lit WebGPU materials', () => { + const geometry = createRenderableSiteGroundGeometry(squareShape()) + + expect(geometry?.index?.count).toBeGreaterThan(0) + expect(geometry?.getAttribute('position')?.count).toBeGreaterThan(0) + expect(geometry?.getAttribute('normal')?.count).toBeGreaterThan(0) + geometry?.dispose() + }) +}) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 514c744ae..f1d68adbc 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -55,6 +55,18 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom return geometry } +export function createRenderableSiteGroundGeometry(shape: Shape): ShapeGeometry | null { + const geometry = new ShapeGeometry(shape) + const position = geometry.getAttribute('position') + const drawableVertexCount = geometry.index ? geometry.index.count : (position?.count ?? 0) + if (!position || drawableVertexCount < 3) { + geometry.dispose() + return null + } + if (!geometry.getAttribute('normal')) geometry.computeVertexNormals() + return geometry +} + type S = ReturnType export const SiteRenderer = ({ node }: { node: SiteNode }) => { @@ -149,7 +161,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const groundGeometry = useMemo(() => { if (!groundShape) return null - return new ShapeGeometry(groundShape) + return createRenderableSiteGroundGeometry(groundShape) }, [groundShape]) useEffect(() => () => groundGeometry?.dispose(), [groundGeometry]) diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index fa7161fbb..85d842dd8 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -6,6 +6,7 @@ import { SkylightNode as SkylightNodeSchema, type SkylightNode as SkylightNodeType, } from '@pascal-app/core' +import { roofHostedRectangleMeasurement } from '../shared/measurement' import { buildSkylightFloorplan } from './floorplan' import { closeSkylightOpenState, @@ -245,6 +246,10 @@ export const skylightDefinition: NodeDefinition = { buildSkylightRoofCut(node as SkylightNodeType, hostSegment as RoofSegmentNode), }, }, + measurement: roofHostedRectangleMeasurement( + (node) => ({ width: node.width, depth: node.height }), + { center: 'Skylight center', edge: 'Skylight edge', vertex: 'Skylight corner' }, + ), parametrics: skylightParametrics, handles: skylightHandles, diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 181d918e8..1adffbfe9 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -4,6 +4,7 @@ import { pointInPolygon2D, type SlabNode as SlabNodeType, } from '@pascal-app/core' +import { surfaceMeasurement } from '../shared/measurement' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -163,6 +164,7 @@ export const slabDefinition: NodeDefinition = { slots: () => slabSlots(), paint: slabPaint, }, + measurement: surfaceMeasurement(), relations: { hosts: ['item'], @@ -172,8 +174,7 @@ export const slabDefinition: NodeDefinition = { parametrics: slabParametrics, handles: slabHandles, - // Stage D: kind-owned placement tool. Multi-click polygon drawing - // with 15° angle snap (Shift to defeat). + // Stage D: kind-owned placement tool. Multi-click polygon drawing. tool: () => import('./tool'), // Stage D — all four slab drag-affordances live in this folder. diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 5054294af..64f737179 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -1,4 +1,9 @@ -import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core' +import { + type AnyNode, + type FloorplanAffordance, + resolveLevelId, + type SlabNode, +} from '@pascal-app/core' import { resolveSlabPlanPointSnap } from '@pascal-app/editor' import { createPolygonAddVertexAffordance, @@ -41,15 +46,9 @@ const slabSnapOptions = { }, } -export const slabMoveVertexAffordance = createPolygonVertexAffordance( - 'slab', - slabSnapOptions, -) -export const slabAddVertexAffordance = createPolygonAddVertexAffordance( - 'slab', - slabSnapOptions, -) -export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance( - 'slab', - slabSnapOptions, -) +export const slabMoveVertexAffordance: FloorplanAffordance = + createPolygonVertexAffordance('slab', slabSnapOptions) +export const slabAddVertexAffordance: FloorplanAffordance = + createPolygonAddVertexAffordance('slab', slabSnapOptions) +export const slabMoveEdgeAffordance: FloorplanAffordance = + createPolygonMoveEdgeAffordance('slab', slabSnapOptions) diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index d5431ceb7..239b88862 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -392,6 +392,7 @@ function stairHandles(node: StairNodeType): HandleDescriptor[] { return handles } +import { stairMeasurement } from '../shared/measurement' import { computeStairSegmentFloorStackTransforms, getStairFloorPlacedFootprints, @@ -463,6 +464,7 @@ export const stairDefinition: NodeDefinition = { slots: (node) => stairSlots(node as StairNodeType), paint: stairPaint, }, + measurement: stairMeasurement(), // Bespoke move shared with roof / roof-segment / stair-segment via // `shared/move-roof-tool` — routed through `MoveTool`'s registry- diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index a41adfd4c..592a1db05 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { wallMeasurement } from '../shared/measurement' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances' import { wallFloorplanMoveTarget } from './floorplan-move' @@ -65,6 +66,7 @@ export const wallDefinition: NodeDefinition = { // `wallPaint`; migrating those fully into `node.slots` is a later step. slots: () => wallSlots(), }, + measurement: wallMeasurement(), relations: { hosts: ['door', 'window', 'item'], diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 6c6e8b438..ac4ab9acf 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,6 +6,7 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import { wallHostedOpeningMeasurement } from '../shared/measurement' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' @@ -200,6 +201,7 @@ export const windowDefinition: NodeDefinition = { slots: () => windowSlots(), paint: windowPaint, }, + measurement: wallHostedOpeningMeasurement(), parametrics: windowParametrics, handles: windowHandles, diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index 345731e2a..80b399027 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -1,4 +1,5 @@ import { type NodeDefinition, ZoneNode as ZoneNodeSchema } from '@pascal-app/core' +import { surfaceMeasurement } from '../shared/measurement' import { buildZoneFloorplan } from './floorplan' import { zoneAddVertexAffordance, @@ -36,6 +37,7 @@ export const zoneDefinition: NodeDefinition = { // reusable presets independent of their site context. presettable: false, }, + measurement: surfaceMeasurement(), parametrics: zoneParametrics, // No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking. diff --git a/packages/nodes/src/zone/floorplan-affordances.ts b/packages/nodes/src/zone/floorplan-affordances.ts index 44eb94315..6e41634be 100644 --- a/packages/nodes/src/zone/floorplan-affordances.ts +++ b/packages/nodes/src/zone/floorplan-affordances.ts @@ -1,4 +1,4 @@ -import type { ZoneNode } from '@pascal-app/core' +import type { FloorplanAffordance, ZoneNode } from '@pascal-app/core' import { createPolygonAddVertexAffordance, createPolygonMoveEdgeAffordance, @@ -15,6 +15,9 @@ import { * - `add-vertex` — insert a new vertex at an edge midpoint, then drag. * - `move-edge` — drag an entire edge perpendicular to itself. */ -export const zoneMoveVertexAffordance = createPolygonVertexAffordance('zone') -export const zoneAddVertexAffordance = createPolygonAddVertexAffordance('zone') -export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance('zone') +export const zoneMoveVertexAffordance: FloorplanAffordance = + createPolygonVertexAffordance('zone') +export const zoneAddVertexAffordance: FloorplanAffordance = + createPolygonAddVertexAffordance('zone') +export const zoneMoveEdgeAffordance: FloorplanAffordance = + createPolygonMoveEdgeAffordance('zone') diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index f160a4518..17294a9c0 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -15,7 +15,7 @@ import { grassDefinition } from './grass-definition' * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the * hand-maintained union today; the registry derives it post-migration. */ -export const treesPlugin: Plugin = { +export const treesPlugin = { id: 'pascal:trees', apiVersion: 1, nodes: [ @@ -23,7 +23,7 @@ export const treesPlugin: Plugin = { flowerDefinition as unknown as AnyNodeDefinition, grassDefinition as unknown as AnyNodeDefinition, ], -} +} satisfies Plugin export const treesHostPanel: EditorHostPanel = { id: 'pascal:trees:trees', diff --git a/packages/plugin-trees/src/r3f.d.ts b/packages/plugin-trees/src/r3f.d.ts new file mode 100644 index 000000000..257689aba --- /dev/null +++ b/packages/plugin-trees/src/r3f.d.ts @@ -0,0 +1,28 @@ +export {} + +type PascalPluginTreeElements = { + boxGeometry: any + group: any + instancedMesh: any + mesh: any + meshBasicMaterial: any + primitive: any +} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements extends PascalPluginTreeElements {} + } +} + +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends PascalPluginTreeElements {} + } +} + +declare module 'react/jsx-dev-runtime' { + namespace JSX { + interface IntrinsicElements extends PascalPluginTreeElements {} + } +} diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index df17bc1c2..eb31fc26d 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -2,6 +2,8 @@ import { type AnyNodeId, + type Control, + type Effect, type Interactive, type LightEffect, pointInPolygon, @@ -107,7 +109,7 @@ export function GlbInteractive({ const store = useInteractive.getState() for (const item of items) { store.initItem(item.pascalId, item.interactive) - item.interactive.controls.forEach((control, i) => { + item.interactive.controls.forEach((control: Control, i: number) => { if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true) }) } @@ -118,7 +120,10 @@ export function GlbInteractive({ }, [items]) const animationItems = useMemo( - () => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')), + () => + items.filter((item) => + item.interactive.effects.some((effect: Effect) => effect.kind === 'animation'), + ), [items], ) @@ -134,8 +139,8 @@ export function GlbInteractive({ const object = identity.get(item.pascalId) if (!object) continue const controls = item.interactive.controls - const toggleIndex = controls.findIndex((c) => c.kind === 'toggle') - const sliderIndex = controls.findIndex((c) => c.kind === 'slider') + const toggleIndex = controls.findIndex((control: Control) => control.kind === 'toggle') + const sliderIndex = controls.findIndex((control: Control) => control.kind === 'slider') const slider = sliderIndex >= 0 ? (controls[sliderIndex] as SliderControl) : null regs.push({ key: item.pascalId, @@ -465,7 +470,9 @@ function GlbItemAnimation({ actions: Record }) { const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues)) - const toggleIndex = item.interactive.controls.findIndex((c) => c.kind === 'toggle') + const toggleIndex = item.interactive.controls.findIndex( + (control: Control) => control.kind === 'toggle', + ) const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true useEffect(() => { @@ -558,7 +565,7 @@ function GlbItemControls({ transition: `opacity ${FADE_MS}ms ease`, }} > - {item.interactive.controls.map((control, i) => ( + {item.interactive.controls.map((control: Control, i: number) => ( ` adapts the bus payload to that shape. type NodeByKind = Extract +function ownsRaycastHit(node: AnyNode, object: ThreeEvent['object']): boolean { + let current: typeof object | null = object + while (current) { + const ownerId = sceneRegistry.nodeIds.get(current) + if (ownerId) return ownerId === node.id + current = current.parent + } + return true +} + export function useNodeEvents(node: NodeByKind, type: K) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { + if (!ownsRaycastHit(node, e.object)) return const eventKey = `${type}:${suffix}` as `${K}:${EventSuffix}` const localPoint = e.object.worldToLocal(e.point.clone()) const payload: NodeEvent> = { diff --git a/packages/viewer/src/store/use-item-light-pool.ts b/packages/viewer/src/store/use-item-light-pool.ts index 3b6e8e412..5148b7adc 100644 --- a/packages/viewer/src/store/use-item-light-pool.ts +++ b/packages/viewer/src/store/use-item-light-pool.ts @@ -1,4 +1,4 @@ -import type { AnyNodeId, Interactive, LightEffect, SliderControl } from '@pascal-app/core' +import type { AnyNodeId, Control, Interactive, LightEffect, SliderControl } from '@pascal-app/core' import { create } from 'zustand' export type LightRegistration = { @@ -21,8 +21,12 @@ export const useItemLightPool = create((set) => ({ registrations: new Map(), register: (key, nodeId, effect, interactive) => { - const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle') - const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider') + const toggleIndex = interactive.controls.findIndex( + (control: Control) => control.kind === 'toggle', + ) + const sliderIndex = interactive.controls.findIndex( + (control: Control) => control.kind === 'slider', + ) const sliderControl = sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index 8559f0175..3909bf317 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, + type Control, type ItemNode, pointInPolygon, sceneRegistry, @@ -132,7 +133,7 @@ const ItemControlsOverlay = ({ transition: `opacity ${FADE_MS}ms ease`, }} > - {controls.map((control, i) => ( + {controls.map((control: Control, i: number) => ( { + test('ignores measurement children attached under a roof', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_1', + parentId: 'roof_1', + position: [1, 0, 2], + }) + const measurement = { + id: 'measurement_1', + parentId: 'roof_1', + type: 'measurement', + } as unknown as AnyNode + const roof = { + children: [segment.id, measurement.id], + id: 'roof_1', + position: [0, 0, 0], + rotation: 0, + type: 'roof', + } as unknown as RoofNode + + expect( + getMergeableRoofSegments(roof, { + [measurement.id]: measurement, + [segment.id]: segment, + }), + ).toEqual([segment]) + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 850147107..d601cbf9a 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -472,6 +472,18 @@ function getMergedRoofSegmentBrushes( return cloned } +export function getMergeableRoofSegments( + roofNode: Pick, + nodes: Record, +): RoofSegmentNode[] { + return (roofNode.children ?? []).flatMap((id) => { + const node = nodes[id] + if (node?.type !== 'roof-segment') return [] + const segment = getEffectiveNode(node as RoofSegmentNode) + return hasSegmentMaterialOverride(segment) ? [] : [segment] + }) +} + function updateMergedRoofGeometry( roofNode: RoofNode, group: THREE.Group, @@ -489,12 +501,7 @@ function updateMergedRoofGeometry( // Merge each child through `getEffectiveNode` so an in-flight handle // drag (live override on width / depth / wallHeight / pitch / rotation) // is reflected in the merged shell during the drag, not only on commit. - const children = (roofNode.children ?? []) - .map((id) => { - const scn = nodes[id] as RoofSegmentNode | undefined - return scn ? getEffectiveNode(scn) : undefined - }) - .filter((n): n is RoofSegmentNode => n !== undefined && !hasSegmentMaterialOverride(n)) + const children = getMergeableRoofSegments(roofNode, nodes) if (children.length === 0) { mergedMesh.geometry.dispose()