editor: complete floorplan construction documentation (#531)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat(floorplan): add construction dimension strings

* feat(floorplan): coordinate opening dimensions

* feat(floorplan): add opening documentation

* feat(floorplan): add construction dimensions and notes

* feat(floorplan): add interior dimensions and curved note leaders

* feat(floorplan): improve construction dimensions and document plan

* feat(floorplan): harden construction document output

* feat(floorplan): add annotation collision diagnostics

* feat(floorplan): size export annotations in paper space

* feat(floorplan): automatically separate overlapping labels

* fix(floorplan): resolve dense label overlaps

* fix(floorplan): remove stale collision warning overlays

* fix(floorplan): treat mark pills as collision obstacles

* feat(floorplan): place short dimension values outside

* fix(floorplan): preserve dimension string order

* fix(floorplan): avoid architectural geometry in label layout

* feat(floorplan): add dimension side fallback leaders

* docs(floorplan): update chapter 17 implementation status

* fix(floorplan): dimension subdivided interior walls

* feat: add associative floor plan dimensions

* feat: add continuous construction dimension strings

* feat: add structural floor plan grids

* feat: coordinate columns with structural grids

Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer.

* feat: add architectural room documentation

Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior.

* feat: generate architectural room schedules

Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings.

* feat: add reliable room clear dimensions

Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums.

* feat: add architectural stair documentation

Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility.

* feat: add typed specialty construction notes

Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls.

* feat: add curved and circular dimensions

Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls.

* feat: coordinate floor plan drawing types

Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output.

* feat: add associative curved wall dimensions

Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes.

* fix: render automatic curved wall dimensions

The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility.

* fix: use radius callout for curved walls

Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth.

* Implement construction dimension string editing

* Add construction dimension standards controls

* Apply drawing standards to automatic dimensions

* Add floorplan overhead and reference visibility controls

* Add view-specific dimension segment suppression

* Add persistent drawing sheet model

* Plot floorplan exports at fixed scale

* Apply paper-space annotation profiles

* Compose floorplan PDF sheets

* Support sheet paper sizes and preflight

* Persist pinned annotation layout overrides

* Expand annotation collision obstacles

* Add floorplan annotation preflight surface

* Add reusable drawing sheet general notes

* Add drawing sheet keyed note instances

* Add drawing sheet document markers

* Expand construction note leader terminators

* Add wall assembly layer model

* Resolve wall assembly datum references

* Add wall assembly floorplan graphics

* Add opening documentation dimension policies

* Add finish-face room clear dimensions

* Extend room clear dimensions to rectilinear rooms

* Add construction module advisories

* Add clearance advisory profiles

* Add dimension completeness audit

* Expand dimension completeness audit

* Include preflight issues in completeness audit

* feat: complete floorplan construction documentation

* refactor: remove construction note node

* feat: refine floorplan documentation and unit display

* fix(editor): improve floorplan PDF dimensions

* fix(floorplan): refresh annotation collision layout

* fix(floorplan): keep annotations clear and restore registry boundaries

* feat(floorplan): refine construction dimension references

* fix(floorplan): align documentation tools with architecture

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-22 14:02:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2adb50a340
commit 77442861d9
181 changed files with 25002 additions and 800 deletions
+9
View File
@@ -9,10 +9,12 @@ import type {
CeilingNode,
ChimneyNode,
ColumnNode,
ConstructionDimensionNode,
CupolaNode,
DoorNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctFittingNode,
DuctSegmentNode,
DuctTerminalNode,
@@ -42,6 +44,7 @@ import type {
SpawnNode,
StairNode,
StairSegmentNode,
StructuralGridNode,
TurbineVentNode,
WallNode,
WindowNode,
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type ColumnEvent = NodeEvent<ColumnNode>
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
export type SkylightEvent = NodeEvent<SkylightNode>
export type DormerEvent = NodeEvent<DormerNode>
export type DownspoutEvent = NodeEvent<DownspoutNode>
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> &
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'structural-grid', StructuralGridEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
NodeEvents<'skylight', SkylightEvent> &
NodeEvents<'dormer', DormerEvent> &
NodeEvents<'downspout', DownspoutEvent> &
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
NodeEvents<'duct-segment', DuctSegmentEvent> &
NodeEvents<'duct-fitting', DuctFittingEvent> &
NodeEvents<'duct-terminal', DuctTerminalEvent> &
+6
View File
@@ -9,8 +9,10 @@ export type {
CeilingEvent,
ChimneyEvent,
ColumnEvent,
ConstructionDimensionEvent,
DoorEvent,
DormerEvent,
DrawingSheetEvent,
ElevatorEvent,
EventSuffix,
FenceEvent,
@@ -34,6 +36,7 @@ export type {
SpawnEvent,
StairEvent,
StairSegmentEvent,
StructuralGridEvent,
WallEvent,
WindowEvent,
ZoneEvent,
@@ -88,6 +91,7 @@ export {
closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -98,6 +102,7 @@ export {
measurementPerimeter,
measurementPrismVolume,
measurementReferenceNodeIds,
remapMeasurementAnchors,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export {
@@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallArcData,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement'
import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
import {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -12,6 +13,7 @@ import {
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
remapMeasurementAnchors,
} from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
})
test('remaps and collects references for arbitrary anchor strings', () => {
const anchors: MeasurementAnchor[] = [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [4, 0, 0],
},
]
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
const remapped = remapMeasurementAnchors(
anchors,
new Map([
['wall_a', 'wall_a_copy'],
['wall_b', 'wall_b_copy'],
]),
)
const first = remapped[0]!
const last = remapped[2]!
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
})
})
+40 -10
View File
@@ -1,4 +1,5 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
import type {
MeasurementAnchor,
MeasurementPayload,
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>,
): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
remapMeasurementAnchors([anchor], idMap)[0]!
switch (measurement.kind) {
case 'distance':
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
}
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
export function remapMeasurementAnchors(
anchors: readonly MeasurementAnchor[],
idMap: ReadonlyMap<string, string>,
): MeasurementAnchor[] {
return anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
})
}
export function remapConstructionDimensionReferences(
dimension: ConstructionDimensionNode,
idMap: ReadonlyMap<string, string>,
): ConstructionDimensionNode {
const controllingDimensionId = dimension.controllingDimensionId
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
dimension.controllingDimensionId)
: null
return {
...dimension,
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
controllingDimensionId,
}
}
export function measurementAnchorReferenceNodeIds(
anchors: readonly MeasurementAnchor[],
): AnyNodeId[] {
const ids = new Set<string>()
for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
return [...ids] as AnyNodeId[]
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
return measurementAnchorReferenceNodeIds(anchors)
}
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0]
+1 -1
View File
@@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport(
zone: ZoneNode,
sceneNodes: Record<string, AnyNode>,
sceneNodes: Readonly<Record<string, AnyNode>>,
): ZoneQuantityReport {
const levelId = zone.parentId
const levelNodes = levelId
+3
View File
@@ -65,6 +65,8 @@ export type {
Capabilities,
CapabilityCtx,
CuttableConfig,
DimensionTerminator,
DimensionTextPosition,
DistributionRole,
DragAction,
DuplicableConfig,
@@ -88,6 +90,7 @@ export type {
FloorplanPoint,
FloorplanStyle,
GeometryContext,
GroupMoveSnapArgs,
HostableConfig,
IconRef,
Issue,
+110 -1
View File
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
) {
const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
parentId: 'level_1',
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[1, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
fallback: [2, 0, 0],
},
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const result = cloneNodesInto([wall, dimension], {
rootId: 'wall_1' as AnyNodeId,
})
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
expect(clonedDimension?.type).toBe('construction-dimension')
if (clonedDimension?.type === 'construction-dimension') {
const anchor = clonedDimension.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
const lastAnchor = clonedDimension.anchors[2]
expect(Array.isArray(lastAnchor)).toBe(false)
if (lastAnchor && !Array.isArray(lastAnchor)) {
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps a construction dimension foundation controller when both are cloned', () => {
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
drawingType: 'foundation-plan',
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
controllingDimensionId: controller.id,
})
const result = cloneNodesInto([controller, dependent], {
rootId: controller.id as AnyNodeId,
})
const clonedDependent = result.nodes.find(
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
)
expect(clonedDependent?.type).toBe('construction-dimension')
if (clonedDependent?.type === 'construction-dimension') {
expect(clonedDependent.controllingDimensionId).toBe(
result.idMap.get(
controller.id as AnyNodeId,
) as typeof clonedDependent.controllingDimensionId,
)
}
})
test('regenerates drawing-sheet identities while preserving external level references', () => {
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
const cloned = nodes[0]
expect(cloned?.type).toBe('drawing-sheet')
if (cloned?.type === 'drawing-sheet') {
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
+12 -2
View File
@@ -1,5 +1,9 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import { generateId } from '../schema/base'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement
@@ -141,7 +145,7 @@ export function cloneNodesInto(
const out: AnyNode[] = []
let root: AnyNode | null = null
for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved
@@ -169,6 +173,12 @@ export function cloneNodesInto(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
if (original.id === opts.rootId) {
if (opts.position) {
+62 -1
View File
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs.
*/
materials?: Record<SceneMaterialId, SceneMaterial>
/** Opaque host/plugin context. Core never interprets extension values. */
extensions?: Readonly<Record<string, unknown>>
/**
* Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
export type FloorplanPoint = readonly [x: number, y: number]
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
export type DimensionTextPosition = 'above' | 'centered'
export type FloorplanStyle = {
stroke?: string
fill?: string
strokeWidth?: number
strokeDasharray?: string
opacity?: number
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
/**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
* of the floor-plan's scene rotation (default 90°).
*/
upright?: boolean
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number }
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Hatched fill overlay — same polygon shape as the kind's main fill but
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
kind: 'dimension'
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
/** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number
/** How far past the offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position relative to the baseline. Defaults above the line. */
textPosition?: DimensionTextPosition
text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
}
| {
kind: 'dimension-string'
segments: readonly {
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}[]
/** Outward-pointing unit normal shared by every segment in the string. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from each measured origin to its dimension line. */
offsetDistance: number
/** How far past each offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position shared by every segment. Defaults above the line. */
textPosition?: DimensionTextPosition
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
// ─── FloorplanAffordance ─────────────────────────────────────────────
//
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schemaVersion: number
schema: S
category: NodeCategory
/** Opaque host/plugin contributions. Core stores but never interprets them. */
extensions?: Readonly<Record<string, unknown>>
surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities
relations?: Relations
+64 -2
View File
@@ -51,8 +51,33 @@ export {
ColumnStyle,
ColumnSupportStyle,
} from './nodes/column'
export {
CONSTRUCTION_DRAWING_TYPES,
ConstructionDimensionBaseline,
ConstructionDimensionChainMode,
ConstructionDimensionDatumPolicy,
ConstructionDimensionDrawingOverride,
ConstructionDimensionDrawingPresentation,
ConstructionDimensionImperialPrecision,
ConstructionDimensionMetricNotation,
ConstructionDimensionMode,
ConstructionDimensionNode,
ConstructionDimensionTerminator,
ConstructionDimensionTextPosition,
ConstructionDrawingType,
constructionDimensionRequiredAnchorCount,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './nodes/construction-dimension'
export { CupolaNode } from './nodes/cupola'
export { DoorNode, DoorSegment } from './nodes/door'
export {
DoorNode,
DoorSegment,
OpeningConstructionType,
OpeningDimensionReference,
} from './nodes/door'
export {
DormerNode,
type DormerSurfaceMaterialRole,
@@ -60,6 +85,25 @@ export {
getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer'
export { DownspoutNode } from './nodes/downspout'
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
remapDrawingSheetReferences,
} from './nodes/drawing-sheet'
export { DuctFittingNode } from './nodes/duct-fitting'
export { DuctSegmentNode } from './nodes/duct-segment'
export { DuctTerminalNode } from './nodes/duct-terminal'
@@ -200,9 +244,13 @@ export {
StairType,
} from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent'
export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallBandSurfaceSlotId,
WallFaceBand,
WallFaceBandConfig,
@@ -215,11 +263,18 @@ export {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallBandSlotId,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
getWallFaceBandForHeight,
getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -230,11 +285,18 @@ export {
WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode,
WallTreatmentSide,
WallTrimProfile,
} from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window'
export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
WindowType,
} from './nodes/window'
export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types'
+5 -2
View File
@@ -1,13 +1,16 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { DrawingSheetNode } from './drawing-sheet'
import { ElevatorNode } from './elevator'
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, DrawingSheetNode.shape.id]))
.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(
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes and building-level systems such as elevators
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
`,
)
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
ConstructionDimensionNode,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './construction-dimension'
describe('ConstructionDimensionNode', () => {
test('creates valid free-anchor defaults', () => {
const node = ConstructionDimensionNode.parse({})
expect(node.type).toBe('construction-dimension')
expect(node.id).toMatch(/^construction-dimension_/)
expect(node.anchors).toEqual([
[0, 0, 0],
[1, 0, 0],
])
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
expect(node.chainMode).toBe('point-to-point')
expect(node).toMatchObject({
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
})
})
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[3, 0, 0],
],
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({
baseline: { origin: [0, 0], direction: [0, 0] },
}).success,
).toBe(false)
})
test('accepts continuous strings with three or more anchors', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
chainMode: 'continuous',
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
.success,
).toBe(false)
})
test('accepts curved and circular notation settings', () => {
expect(
ConstructionDimensionNode.safeParse({
mode: 'diameter',
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
}).success,
).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
})
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
const node = ConstructionDimensionNode.parse({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(node).toMatchObject({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
})
test('coordinates one associative dimension across persistent drawing types', () => {
const node = ConstructionDimensionNode.parse({
drawingType: 'foundation-plan',
drawingOverrides: [
{ drawingType: 'floor-plan', presentation: 'controlled' },
{ drawingType: 'roof-plan', presentation: 'shown' },
],
controllingDimensionId: 'construction-dimension_foundation',
})
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
})
test('stores only drawing presentations that differ from the primary defaults', () => {
const node = ConstructionDimensionNode.parse({})
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
expect(shown).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
])
expect(
setConstructionDimensionDrawingPresentation(
{ ...node, drawingOverrides: shown },
'roof-plan',
'omit',
),
).toEqual([])
})
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
const node = ConstructionDimensionNode.parse({})
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
node,
'floor-plan',
[3, 1, 1, -1],
)
expect(drawingOverrides).toEqual([
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1, 3],
},
])
expect(
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
?.suppressedSegmentIndexes,
).toEqual([1, 3])
expect(
setConstructionDimensionDrawingSuppressedSegments(
{ ...node, drawingOverrides },
'floor-plan',
[],
),
).toEqual([])
})
})
@@ -0,0 +1,205 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MeasurementAnchor } from './measurement'
const FiniteCoordinate = z.number().finite()
export const ConstructionDimensionBaseline = z
.object({
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
})
.superRefine((baseline, ctx) => {
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['direction'],
message: 'Construction dimension baseline direction must be non-zero',
})
}
})
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
export const ConstructionDimensionMode = z.enum([
'linear',
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
])
export const ConstructionDrawingType = z.enum([
'floor-plan',
'foundation-plan',
'reflected-ceiling-plan',
'roof-plan',
'site-plan',
])
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
export const ConstructionDimensionDrawingOverride = z.object({
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
})
export const ConstructionDimensionDatumPolicy = z.enum([
'centerline',
'wall-face',
'structural-face',
'finish-face',
])
export const ConstructionDimensionTerminator = z.enum([
'architectural-tick',
'filled-arrow',
'open-arrow',
'dot',
])
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
export const ConstructionDimensionNode = BaseNode.extend({
id: objectId('construction-dimension'),
type: nodeType('construction-dimension'),
anchors: z
.array(MeasurementAnchor)
.min(2)
.default([
[0, 0, 0],
[1, 0, 0],
]),
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
mode: ConstructionDimensionMode.default('linear'),
featureCount: z.number().int().min(1).max(999).default(1),
showCenterMark: z.boolean().default(true),
prefix: z.string().max(40).default(''),
suffix: z.string().max(40).default(''),
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
textPosition: ConstructionDimensionTextPosition.default('above'),
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
}).describe(
dedent`
Construction dimension node - an associative floor-plan construction dimension
- anchors: two or more free or semantic feature anchors that supply the witness origins
- baseline.origin: a point on the independently placed dimension line
- baseline.direction: the fixed plan direction used to project the witness origins
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
- showCenterMark: displays the resolved circle/angle center where applicable
- prefix/suffix/textOverride: document notation overrides without changing geometry
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
- drawingType: the primary persistent drawing that owns the dimension
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
`,
)
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
export type ConstructionDimensionDrawingPresentation = z.infer<
typeof ConstructionDimensionDrawingPresentation
>
export type ConstructionDimensionDrawingOverride = z.infer<
typeof ConstructionDimensionDrawingOverride
>
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
export type ConstructionDimensionImperialPrecision = z.infer<
typeof ConstructionDimensionImperialPrecision
>
export type ConstructionDimensionMetricNotation = z.infer<
typeof ConstructionDimensionMetricNotation
>
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
export function resolveConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingPresentation {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
}
export function resolveConstructionDimensionDrawingOverride(
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingOverride | null {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override ?? null
}
export function setConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = {
drawingType,
presentation,
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
}
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function setConstructionDimensionDrawingSuppressedSegments(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
suppressedSegmentIndexes: readonly number[],
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const presentation = existing?.presentation ?? defaultPresentation
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
}
function isDefaultConstructionDimensionDrawingOverride(
override: ConstructionDimensionDrawingOverride,
defaultPresentation: ConstructionDimensionDrawingPresentation,
): boolean {
return (
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
)
}
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
(left, right) => left - right,
)
}
+23
View File
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment>
export const DoorCategory = z.enum(['interior', 'garage'])
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
export const OpeningDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export const DoorType = z.enum([
'hinged',
'double',
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
export type DoorCategory = z.infer<typeof DoorCategory>
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9),
height: z.number().default(2.1),
// Construction-document identity. `mark` overrides the deterministic
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
// because they are manufacturer/framing inputs, not safe derivations from
// the nominal modeled size.
mark: z.string().trim().max(16).optional(),
constructionType: OpeningConstructionType.default('framed'),
dimensionReference: OpeningDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Door family
doorCategory: DoorCategory.default('interior'),
doorType: DoorType.default('hinged'),
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode } from './building'
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
describe('DrawingSheetNode', () => {
test('creates persistent sheet defaults', () => {
const sheet = DrawingSheetNode.parse({})
expect(sheet.type).toBe('drawing-sheet')
expect(sheet.id).toMatch(/^drawing-sheet_/)
expect(sheet).toMatchObject({
sheetNumber: 'A1.0',
sheetTitle: 'Floor Plan',
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
annotationProfile: 'architectural-default',
placedViews: [],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [],
keyedNoteInstances: [],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
titleBlock: {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
},
})
})
test('stores placed views, notes, schedules, and title-block metadata', () => {
const sheet = DrawingSheetNode.parse({
sheetNumber: 'A2.1',
sheetTitle: 'Enlarged Plans',
paperSize: 'custom',
customPaperWidth: 24,
customPaperHeight: 36,
placedViews: [
{
id: 'drawing-view_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
levelId: 'level_main',
scale: '1/4"=1\'-0"',
viewport: { x: 1, y: 1, width: 12, height: 8 },
},
],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-slab-1',
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
},
{
id: 'keyed-note-instance_patch-slab-2',
definitionId: 'keyed-note_patch-slab',
position: [5, 4],
},
],
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
],
schedules: [
{
id: 'sheet-schedule_room',
scheduleType: 'room',
title: 'Room Schedule',
region: { x: 15, y: 1, width: 6, height: 5 },
},
],
titleBlock: {
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
},
})
expect(sheet.placedViews[0]).toMatchObject({
drawingType: 'floor-plan',
levelId: 'level_main',
annotationProfile: 'architectural-default',
showNorthArrow: true,
showGraphicScale: true,
})
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
expect(sheet.generalNoteSets[0]).toMatchObject({
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ text: 'COORDINATE WITH OWNER.' }],
})
expect(sheet.keyedNoteLegend[0]).toEqual({
key: 'A',
text: 'ALIGN WITH EXISTING WALL.',
})
expect(sheet.keyedNoteDefinitions[0]).toEqual({
id: 'keyed-note_patch-slab',
key: 'A',
text: 'PATCH EXISTING SLAB.',
})
expect(sheet.keyedNoteInstances).toHaveLength(2)
expect(sheet.keyedNoteInstances[0]).toMatchObject({
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
})
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
expect(sheet.documentMarkers).toHaveLength(2)
expect(sheet.documentMarkers[0]).toMatchObject({
kind: 'wall-tag',
label: 'W1',
position: [2, 3],
})
expect(sheet.documentMarkers[1]).toMatchObject({
kind: 'revision-cloud',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
})
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
expect(sheet.titleBlock).toMatchObject({
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
drawnBy: '',
})
})
test('can live under a building instead of a level', () => {
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
'level_main',
sheet.id,
])
})
test('remaps sheet-local identities and their references together', () => {
const sheet = DrawingSheetNode.parse({
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a1',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
},
],
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
schedules: [{ id: 'sheet-schedule_a' }],
})
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
sheet.generalNoteSets[0]?.notes[0]?.id,
)
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
})
})
@@ -0,0 +1,263 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, generateId, nodeType, objectId } from '../base'
import { ConstructionDrawingType } from './construction-dimension'
const PositiveFinite = z.number().finite().positive()
const SheetCoordinate = z.number().finite().min(0)
export const DrawingSheetPaperSize = z.enum([
'letter',
'tabloid',
'arch-a',
'arch-b',
'arch-c',
'a4',
'a3',
'custom',
])
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
export const DrawingSheetScale = z.enum([
'1:20',
'1:25',
'1:50',
'1:75',
'1:100',
'1/8"=1\'-0"',
'1/4"=1\'-0"',
'1/2"=1\'-0"',
'1"=1\'-0"',
])
export const DrawingSheetAnnotationProfile = z.enum([
'architectural-default',
'presentation',
'permit',
])
export const DrawingSheetRect = z.object({
x: SheetCoordinate.default(0),
y: SheetCoordinate.default(0),
width: PositiveFinite.default(1),
height: PositiveFinite.default(1),
})
export const DrawingSheetPlacedView = z.object({
id: objectId('drawing-view'),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingNumber: z.string().trim().min(1).max(24).default('1'),
title: z.string().trim().min(1).max(80).default('Floor Plan'),
levelId: objectId('level').nullable().default(null),
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
showNorthArrow: z.boolean().default(true),
showGraphicScale: z.boolean().default(true),
})
export const DrawingSheetGeneralNote = z.object({
id: objectId('sheet-note'),
number: z.number().int().positive().default(1),
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
})
export const DrawingSheetGeneralNoteSet = z.object({
id: objectId('sheet-note-set'),
name: z.string().trim().min(1).max(80).default('General Notes'),
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
})
export const DrawingSheetKeyedNote = z.object({
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteDefinition = z.object({
id: objectId('keyed-note'),
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteInstance = z.object({
id: objectId('keyed-note-instance'),
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
})
export const DrawingSheetDocumentMarkerKind = z.enum([
'wall-tag',
'glazing-tag',
'assembly-tag',
'section-callout',
'elevation-callout',
'detail-reference',
'delta-marker',
'revision-cloud',
])
export const DrawingSheetDocumentMarker = z.object({
id: objectId('sheet-marker'),
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
label: z.string().trim().min(1).max(32).default('1'),
title: z.string().trim().max(120).default(''),
sheetReference: z.string().trim().max(24).default(''),
drawingReference: z.string().trim().max(24).default(''),
revisionId: z.string().trim().max(16).default(''),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
points: z
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
.max(64)
.default([]),
})
export const DrawingSheetSchedulePlacement = z.object({
id: objectId('sheet-schedule'),
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
title: z.string().trim().min(1).max(80).default('Room Schedule'),
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
})
export const DrawingSheetTitleBlock = z.object({
projectName: z.string().trim().max(120).default(''),
projectNumber: z.string().trim().max(40).default(''),
clientName: z.string().trim().max(120).default(''),
drawnBy: z.string().trim().max(40).default(''),
checkedBy: z.string().trim().max(40).default(''),
issueDate: z.string().trim().max(40).default(''),
revision: z.string().trim().max(20).default(''),
})
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
}
export const DrawingSheetNode = BaseNode.extend({
id: objectId('drawing-sheet'),
type: nodeType('drawing-sheet'),
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
paperSize: DrawingSheetPaperSize.default('arch-b'),
orientation: DrawingSheetOrientation.default('landscape'),
customPaperWidth: PositiveFinite.nullable().default(null),
customPaperHeight: PositiveFinite.nullable().default(null),
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
}).describe(
dedent`
Drawing sheet node - persistent construction-document sheet metadata
- sheetNumber/sheetTitle: sheet identity in the drawing set
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
- schedules/titleBlock: sheet-level documentation content and title-block metadata
`,
)
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
/**
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
* External scene references are preserved when they are not present in
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
*/
export function remapDrawingSheetReferences(
sheet: DrawingSheetNode,
sceneIdMap: ReadonlyMap<string, string>,
): DrawingSheetNode {
const placedViewIds = new Map(
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
)
const noteSetIds = new Map(
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
)
const noteIds = new Map(
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
(note) => [note.id, generateId('sheet-note')] as const,
),
)
const keyedDefinitionIds = new Map(
sheet.keyedNoteDefinitions.map(
(definition) => [definition.id, generateId('keyed-note')] as const,
),
)
return {
...sheet,
placedViews: sheet.placedViews.map((view) => ({
...view,
id: placedViewIds.get(view.id)!,
levelId: view.levelId
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
: null,
})),
generalNoteSetIds: sheet.generalNoteSetIds.map(
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
),
generalNoteSets: sheet.generalNoteSets.map((set) => ({
...set,
id: noteSetIds.get(set.id)!,
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
})),
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
...definition,
id: keyedDefinitionIds.get(definition.id)!,
})),
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
...instance,
id: generateId('keyed-note-instance'),
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
instance.definitionId) as typeof instance.definitionId,
placedViewId: instance.placedViewId
? ((placedViewIds.get(instance.placedViewId) ??
instance.placedViewId) as typeof instance.placedViewId)
: null,
})),
documentMarkers: sheet.documentMarkers.map((marker) => ({
...marker,
id: generateId('sheet-marker'),
placedViewId: marker.placedViewId
? ((placedViewIds.get(marker.placedViewId) ??
marker.placedViewId) as typeof marker.placedViewId)
: null,
})),
schedules: sheet.schedules.map((schedule) => ({
...schedule,
id: generateId('sheet-schedule'),
})),
}
}
+4
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { ColumnNode } from './column'
import { ConstructionDimensionNode } from './construction-dimension'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import { ShelfNode } from './shelf'
import { SlabNode } from './slab'
import { SpawnNode } from './spawn'
import { StairNode } from './stair'
import { StructuralGridNode } from './structural-grid'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({
WallNode.shape.id,
FenceNode.shape.id,
ColumnNode.shape.id,
ConstructionDimensionNode.shape.id,
StructuralGridNode.shape.id,
ItemNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { LevelNode } from './level'
import { StructuralGridNode } from './structural-grid'
describe('StructuralGridNode', () => {
test('fills stable construction-document defaults', () => {
const grid = StructuralGridNode.parse({})
expect(grid.id).toStartWith('structural-grid_')
expect(grid).toMatchObject({
type: 'structural-grid',
start: [0, 0],
end: [0, 5],
label: '1',
showStartBubble: true,
showEndBubble: true,
})
})
test('is accepted as a level child', () => {
expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([
'structural-grid_axis-1',
])
})
test('rejects empty labels and zero-length concerns stay in authoring', () => {
expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow()
expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({
start: [1, 1],
end: [1, 1],
})
})
})
@@ -0,0 +1,22 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const StructuralGridNode = BaseNode.extend({
id: objectId('structural-grid'),
type: nodeType('structural-grid'),
start: z.tuple([z.number(), z.number()]).default([0, 0]),
end: z.tuple([z.number(), z.number()]).default([0, 5]),
label: z.string().trim().min(1).max(12).default('1'),
showStartBubble: z.boolean().default(true),
showEndBubble: z.boolean().default(true),
}).describe(
dedent`
Structural grid node - a persistent floor-plan datum axis with identification bubbles
- start/end: level-local plan coordinates defining the grid axis extent
- label: axis identifier, commonly numeric in one direction and alphabetic in the other
- showStartBubble/showEndBubble: independently control the two endpoint identifiers
`,
)
export type StructuralGridNode = z.infer<typeof StructuralGridNode>
+216 -6
View File
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
import {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -13,7 +19,8 @@ import {
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig,
type WallNode,
WallNode,
type WallNode as WallNodeType,
WallTrimConfig,
} from './wall'
@@ -99,7 +106,7 @@ describe('wall face bands', () => {
lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({
enabled: true,
@@ -135,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -159,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
@@ -187,7 +194,7 @@ describe('wall face bands', () => {
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -219,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
4,
)
@@ -260,3 +267,206 @@ describe('wall trim profiles', () => {
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
})
})
describe('wall assembly layers', () => {
test('defaults to legacy thickness when no assembly layers are modeled', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(wall.assemblyLayers).toEqual([])
expect(getWallAssemblyThickness(wall)).toBe(0.14)
})
test('stores role, side, thickness, material reference, and datum eligibility', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face', 'finish-face'],
},
],
})
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
'interior-gwb',
'brick-veneer',
])
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
{ id: 'stud-core', role: 'structure', side: 'core' },
])
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
interior: -0.061,
exterior: 0.135,
})
})
test('resolves stable datum references for legacy single-thickness walls', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
{
id: 'wall:structural-face:interior',
datum: 'structural-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:structural-face:exterior',
datum: 'structural-face',
side: 'exterior',
offset: 0.07,
},
{
id: 'wall:finish-face:interior',
datum: 'finish-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:finish-face:exterior',
datum: 'finish-face',
side: 'exterior',
offset: 0.07,
},
])
})
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-sheathing',
role: 'exterior-sheathing',
side: 'exterior',
thickness: 0.012,
materialRef: 'library:sheathing',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const references = resolveWallAssemblyDatumReferences(wall)
expect(references).toContainEqual({
id: 'wall:centerline:center',
datum: 'centerline',
side: 'center',
offset: 0,
})
expect(references).toContainEqual({
id: 'wall:structural-face:interior:stud-core',
datum: 'structural-face',
side: 'interior',
layerId: 'stud-core',
offset: -0.045,
})
expect(references).toContainEqual({
id: 'wall:structural-face:exterior:stud-core',
datum: 'structural-face',
side: 'exterior',
layerId: 'stud-core',
offset: 0.045,
})
expect(references).toContainEqual({
id: 'wall:finish-face:interior:interior-gwb',
datum: 'finish-face',
side: 'interior',
layerId: 'interior-gwb',
offset: -0.061,
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
),
).toMatchObject({
datum: 'finish-face',
side: 'exterior',
layerId: 'exterior-sheathing',
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
)?.offset,
).toBeCloseTo(0.057)
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
})
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
?.offset,
).toBeCloseTo(0.147)
expect(
resolveWallAssemblyDatumReference(
wall,
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
offset: 0.147,
})
})
})
+260
View File
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallAssemblyLayerRole = z.enum([
'structure',
'interior-finish',
'exterior-sheathing',
'exterior-finish',
'masonry-veneer',
'air-space',
'concrete-block',
'structural-masonry',
'solid-concrete',
'furring',
])
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
export const WallDimensionDatum = z.enum([
'centerline',
'structural-face',
'finish-face',
'veneer-face',
])
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
export const WallAssemblyLayer = z.object({
id: z.string().trim().min(1).max(80).default('structure'),
role: WallAssemblyLayerRole.default('structure'),
side: z.enum(['core', 'interior', 'exterior']).default('core'),
thickness: z.number().finite().positive().default(0.1),
materialRef: z.string().trim().max(120).default(''),
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
})
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
export type WallAssemblyDatumReference = {
id: string
datum: WallDimensionDatum
side: WallAssemblyDatumSide
layerId?: string
offset: number
}
export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
@@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({
// in a follow-up once migrated scenes are the norm.
slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(),
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
height: z.number().optional(),
curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
@@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({
dedent`
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
- height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system
@@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId =
| 'upperExterior'
| 'topExterior'
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
return wall.assemblyLayers ?? []
}
export function getWallAssemblyThickness(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): number {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return wall.thickness ?? 0.1
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
}
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
interior: number
exterior: number
} {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return { interior: -halfThickness, exterior: halfThickness }
}
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const interiorFinishThickness = layers
.filter((layer) => layer.side === 'interior')
.reduce((sum, layer) => sum + layer.thickness, 0)
const exteriorFinishThickness = layers
.filter((layer) => layer.side === 'exterior')
.reduce((sum, layer) => sum + layer.thickness, 0)
return {
interior: -coreThickness / 2 - interiorFinishThickness,
exterior: coreThickness / 2 + exteriorFinishThickness,
}
}
export function getWallDatumEligibleLayers(
wall: Pick<WallNode, 'assemblyLayers'>,
datum: WallDimensionDatum,
): WallAssemblyLayer[] {
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
}
export function getWallAssemblyDatumReferenceId(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
layerId?: string,
): string {
return ['wall', datum, side, layerId].filter(Boolean).join(':')
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function getWallAssemblyLayerSpans(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyLayerSpan[] {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const coreInteriorFace = -coreThickness / 2
const coreExteriorFace = coreThickness / 2
const spans: WallAssemblyLayerSpan[] = []
let coreOffset = coreInteriorFace
for (const layer of coreLayers) {
const interiorOffset = coreOffset
const exteriorOffset = coreOffset + layer.thickness
spans.push({ layer, interiorOffset, exteriorOffset })
coreOffset = exteriorOffset
}
let interiorOffset = coreInteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
const exteriorOffset = interiorOffset
const nextInteriorOffset = exteriorOffset - layer.thickness
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
interiorOffset = nextInteriorOffset
}
let exteriorOffset = coreExteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
const interiorFaceOffset = exteriorOffset
const nextExteriorOffset = interiorFaceOffset + layer.thickness
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
exteriorOffset = nextExteriorOffset
}
return spans
}
function createWallAssemblyDatumReference(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
offset: number,
layerId?: string,
): WallAssemblyDatumReference {
return {
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
datum,
side,
...(layerId ? { layerId } : {}),
offset,
}
}
export function resolveWallAssemblyDatumReferences(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyDatumReference[] {
const layers = wall.assemblyLayers ?? []
const references: WallAssemblyDatumReference[] = [
createWallAssemblyDatumReference('centerline', 'center', 0),
]
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return [
...references,
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
]
}
const spans = getWallAssemblyLayerSpans(wall)
for (const span of spans) {
if (span.layer.datumEligible.includes('structural-face')) {
if (span.layer.side === 'core') {
references.push(
createWallAssemblyDatumReference(
'structural-face',
'interior',
span.interiorOffset,
span.layer.id,
),
createWallAssemblyDatumReference(
'structural-face',
'exterior',
span.exteriorOffset,
span.layer.id,
),
)
} else {
const side = span.layer.side
references.push(
createWallAssemblyDatumReference(
'structural-face',
side,
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
span.layer.id,
),
)
}
}
if (span.layer.datumEligible.includes('finish-face')) {
const side = span.layer.side === 'core' ? 'center' : span.layer.side
const offset =
span.layer.side === 'interior'
? span.interiorOffset
: span.layer.side === 'exterior'
? span.exteriorOffset
: (span.interiorOffset + span.exteriorOffset) / 2
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
}
if (span.layer.datumEligible.includes('veneer-face')) {
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
}
}
if (!references.some((reference) => reference.datum === 'structural-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
)
}
if (!references.some((reference) => reference.datum === 'finish-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
)
}
return references
}
export function resolveWallAssemblyDatumReference(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
referenceId: string,
): WallAssemblyDatumReference | null {
return (
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
null
)
}
// Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
// slot declaration (nodes) and the material resolver (viewer) share one value.
+22
View File
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
])
export type WindowType = z.infer<typeof WindowType>
export const WindowConstructionType = z.enum(['framed', 'masonry'])
export const WindowDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
export const WindowNode = BaseNode.extend({
id: objectId('window'),
type: nodeType('window'),
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5),
height: z.number().default(1.5),
// Construction-document identity and optional manufacturer rough opening.
// Legacy scenes omit these fields and continue to parse unchanged.
mark: z.string().trim().max(16).optional(),
constructionType: WindowConstructionType.default('framed'),
dimensionReference: WindowDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'),
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { ZoneNode } from './zone'
describe('ZoneNode architectural room data', () => {
test('keeps legacy zones generic while supplying room-safe defaults', () => {
const zone = ZoneNode.parse({
id: 'zone_legacy',
name: 'Landscape area',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
})
expect(zone).toMatchObject({
spaceRole: 'generic',
roomNumber: '',
enclosureStatus: 'auto',
floorFinish: '',
wallFinish: '',
ceilingFinish: '',
ceilingHeight: 2.7,
occupancy: '',
clearDimensionPolicy: 'none',
})
})
test('persists a complete architectural room profile', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
spaceRole: 'room',
roomNumber: '101',
enclosureStatus: 'enclosed',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 3,
occupancy: 'Business',
clearDimensionPolicy: 'inside-faces',
})
expect(room.spaceRole).toBe('room')
expect(room.roomNumber).toBe('101')
expect(room.clearDimensionPolicy).toBe('inside-faces')
})
})
+15
View File
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
// stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]),
// Generic zones remain available for sites and analysis. Architectural
// room documentation is opt-in so legacy zone behavior is unchanged.
spaceRole: z.enum(['generic', 'room']).default('generic'),
roomNumber: z.string().trim().max(32).default(''),
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
floorFinish: z.string().trim().max(120).default(''),
wallFinish: z.string().trim().max(120).default(''),
ceilingFinish: z.string().trim().max(120).default(''),
ceilingHeight: z.number().min(0.1).default(2.7),
occupancy: z.string().trim().max(80).default(''),
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
- polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure
- spaceRole: generic site/analysis zone or architectural room
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
- enclosureStatus: auto-detected, explicitly enclosed, or open
- clearDimensionPolicy: optional room clear-dimension datum preference
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
+6
View File
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column'
import { ConstructionDimensionNode } from './nodes/construction-dimension'
import { CupolaNode } from './nodes/cupola'
import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer'
import { DownspoutNode } from './nodes/downspout'
import { DrawingSheetNode } from './nodes/drawing-sheet'
import { DuctFittingNode } from './nodes/duct-fitting'
import { DuctSegmentNode } from './nodes/duct-segment'
import { DuctTerminalNode } from './nodes/duct-terminal'
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { StructuralGridNode } from './nodes/structural-grid'
import { TurbineVentNode } from './nodes/turbine-vent'
import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ElevatorNode,
LevelNode,
ColumnNode,
ConstructionDimensionNode,
StructuralGridNode,
WallNode,
FenceNode,
CabinetNode,
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctSegmentNode,
DuctFittingNode,
DuctTerminalNode,
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene construction-dimension migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('normalizes the legacy reference presentation before parsing', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['construction-dimension_test'],
level: 0,
},
'construction-dimension_test': {
object: 'node',
id: 'construction-dimension_test',
type: 'construction-dimension',
parentId: 'level_test',
visible: true,
metadata: {},
reference: true,
referenceStyle: 'suffix',
drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }],
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
Record<string, unknown>
expect(dimension.reference).toBeUndefined()
expect(dimension.referenceStyle).toBeUndefined()
expect(dimension.drawingOverrides).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown' },
])
})
})
+25
View File
@@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next
}
function migrateConstructionDimension(node: Record<string, any>) {
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
const hasLegacyDrawingOverride = drawingOverrides.some(
(entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
entry.presentation === 'reference',
)
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
return {
...dimension,
drawingOverrides: drawingOverrides.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
}),
}
}
// Walls whose top lands within this of the storey plane become plane-bound;
// ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both.
@@ -687,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
if (node.type === 'construction-dimension') {
patchedNodes[id] = migrateConstructionDimension(node)
}
if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {
+1 -1
View File
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
}
}
function getWallArcData(wall: WallCurveLike) {
export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall)
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true,
parentId: 'level_test',
children: [],
assemblyLayers: [],
start,
end,
thickness: 0.1,
@@ -77,6 +77,119 @@ describe('forkSceneGraph', () => {
})
})
describe('construction-dimension clone references', () => {
function sceneWithControlledDimensions(): SceneGraph {
const site = makeNode('site_1', 'site', { children: ['level_1'] })
const level = makeNode('level_1', 'level', {
parentId: 'site_1',
children: ['construction-dimension_foundation', 'construction-dimension_floor'],
})
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
name: 'Foundation controller',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: null,
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
name: 'Floor dependent',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: controller.id,
})
return {
nodes: {
[site.id]: site,
[level.id]: level,
[controller.id]: controller,
[dependent.id]: dependent,
},
rootNodeIds: [site.id],
}
}
test('remaps controller IDs in whole-scene clones', () => {
const cloned = cloneSceneGraph(sceneWithControlledDimensions())
const dimensions = Object.values(cloned.nodes).filter(
(node) => node.type === 'construction-dimension',
)
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
test('remaps controller IDs in level-subtree clones', () => {
const scene = sceneWithControlledDimensions()
const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
})
describe('drawing-sheet clone references', () => {
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
const level = makeNode('level_main', 'level')
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const cloned = cloneSceneGraph({
nodes: { [level.id]: level, [sheet.id]: sheet },
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
})
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
expect(clonedLevel).toBeDefined()
expect(clonedSheet?.type).toBe('drawing-sheet')
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
clonedSheet.keyedNoteDefinitions[0]?.id,
)
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
}
})
})
describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
+19 -3
View File
@@ -1,8 +1,12 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode>
@@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
if (clonedNode.type === 'construction-dimension') {
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
}
if (clonedNode.type === 'drawing-sheet') {
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
}
clonedNodes[newId] = clonedNode
}
@@ -221,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
@@ -274,6 +284,12 @@ export function cloneLevelSubtree(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
clonedNodes.push(cloned)
}
+4 -2
View File
@@ -40,16 +40,16 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0",
"pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6",
@@ -59,8 +59,10 @@
"@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const ctx = useFloorplanRender()
if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeasurement(distMeters, unit)
const distLabel = formatMeasurement(distMeters, unit, metricNotation)
const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
const draftLevelId = useMeasurementDraft((state) => state.levelId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
useEffect(() => {
if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
screenUpright: false,
text: formatLinearMeasurement(measurementDistance(start, end), unit),
text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
}
} else if (kind === 'angle' && livePoints.length >= 3) {
const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
text:
kind === 'area'
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
}
}
} else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
(segmentStart[1] + segmentEnd[1]) / 2,
(segmentStart[2] + segmentEnd[2]) / 2,
],
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
text: formatLinearMeasurement(
measurementDistance(segmentStart, segmentEnd),
unit,
metricNotation,
),
}
}
}
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
polygonPoints,
segmentLabel,
}
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
}, [
axisGuide,
baseNormal,
extrusionHeight,
hover,
kind,
metricNotation,
points,
stage,
unit,
vertexDrag,
])
if (smartActive) return <FloorplanQuickMeasureLayer />
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
text={`${hover.semantic.label}${
hover.semantic.length === null
? ''
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
: ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
}`}
textColor={labelText}
unitsPerPixel={unitsPerPixel}
@@ -0,0 +1,61 @@
'use client'
import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
import {
type FloorplanToolContext,
getFloorplanNodeExtension,
} from '../../lib/floorplan/floorplan-extension'
import useEditor from '../../store/use-editor'
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
if (!tool) return null
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
if (!loader) return null
const cached = lazyToolCache.get(loader)
if (cached) return cached
const component = lazy(loader)
lazyToolCache.set(loader, component)
return component
}
export function FloorplanRegisteredToolLayer() {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const toolDefaults = useEditor((state) =>
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
const sceneApi = useMemo(() => createSceneApi(useScene), [])
const selectNode = useCallback(
(id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
useViewer.getState().setSelection({ selectedIds: [id] }),
[],
)
const finishTool = useCallback(() => {
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}, [])
if (mode !== 'build') return null
const Tool = registeredFloorplanTool(tool)
return Tool ? (
<Suspense fallback={null}>
<Tool
activeLevelId={activeLevelId}
finishTool={finishTool}
gridSnapStep={gridSnapStep}
metricNotation={metricNotation}
sceneApi={sceneApi}
selectNode={selectNode}
toolDefaults={toolDefaults}
unit={unit}
/>
</Suspense>
) : null
}
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref'
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* Walls are excluded — their move is reached via the side-arrow
* handles emitted from `def.floorplan`, not via a menu button.
* - Curve (wall only): enters curve reshape mode. The selected wall's
* midpoint curve handle remains visible so it can be dragged in plan.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
* Hidden while in a move state (so we don't show buttons over a ghost).
* Hidden while moving or curving so the menu does not compete with the active affordance.
*/
export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion()
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
) as AnyNodeId | undefined
const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
// Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const canCurve = useScene((s) => {
if (!selectedId) return false
const selectedNode = s.nodes[selectedId]
if (!selectedNode) return false
const definition = nodeRegistry.get(selectedNode.type)
const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
return (
!!definition?.floorplanAffordances?.curve &&
!!canCurveNode?.({
node: selectedNode as never,
nodes: s.nodes,
})
)
})
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible =
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
isRegistryKind &&
def?.presentation?.actionMenu !== false &&
!movingNode &&
!isCurveReshape &&
isFloorplanHovered
const isWall = selectedKind === 'wall'
const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
)
}
const handleCurve = () => {
if (!canCurve) return
sfxEmitter.emit('sfx:item-pick')
useInteractionScope.getState().begin(curveReshapeScope(node.id))
}
const handleDuplicate = () => {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
>
<NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined}
onCurve={canCurve ? handleCurve : undefined}
onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,408 @@
import { describe, expect, test } from 'bun:test'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
collectAnnotationLayoutPreflightIssues,
floorplanAnnotationObstacleMode,
observeSvgAnnotationLayoutChanges,
polylineObstacleRectangles,
resolveAnnotationLabelRectangles,
} from './floorplan-annotation-layout'
describe('floorplanAnnotationObstacleMode', () => {
test('treats fixed annotation categories as layout obstacles', () => {
expect(
floorplanAnnotationObstacleMode({
kind: 'text',
x: 0,
y: 0,
text: 'BEDROOM',
fontSize: 0.18,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
}),
).toBe('bounds')
expect(
floorplanAnnotationObstacleMode({
kind: 'line',
x1: 0,
y1: 0,
x2: 1,
y2: 0,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
}),
).toBe('bounds')
expect(
floorplanAnnotationObstacleMode({
kind: 'polyline',
points: [
[0, 0],
[1, 0],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
}),
).toBe('outline')
})
})
describe('collectAnnotationLayoutPreflightIssues', () => {
test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => {
const issues = collectAnnotationLayoutPreflightIssues(
[
{
id: 'short',
x: 0,
y: 0,
width: 40,
height: 10,
priority: 10,
text: '1"',
labelPlacement: 'outside-end',
},
{
id: 'blocked',
x: 100,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'Blocked',
},
{
id: 'overlap-a',
x: 200,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'A',
},
{
id: 'overlap-b',
x: 205,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'B',
},
],
[
{ id: 'short', dx: 0, dy: 0, resolved: true },
{ id: 'blocked', dx: 0, dy: 0, resolved: true },
{ id: 'overlap-a', dx: 0, dy: 0, resolved: false },
{ id: 'overlap-b', dx: 0, dy: 0, resolved: true },
],
[{ x: 96, y: -2, width: 48, height: 14 }],
)
expect(issues.map((issue) => issue.kind)).toEqual([
'short-unreadable-segment',
'plan-geometry-conflict',
'unresolved-collision',
])
expect(issues.every((issue) => issue.severity === 'warning')).toBe(true)
})
})
describe('resolveAnnotationLabelRectangles', () => {
test('keeps the higher-priority label and moves the conflicting label', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 },
{ id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 },
])
expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 })
expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 })
expect(shifts.every((entry) => entry.resolved)).toBe(true)
})
test('keeps pinned labels at their drawing-view override and routes other labels around them', () => {
const shifts = resolveAnnotationLabelRectangles([
{
id: 'pinned',
x: 0,
y: 0,
width: 80,
height: 12,
priority: 1,
pinnedShift: { dx: 30, dy: 0 },
},
{ id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 },
])
expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({
id: 'pinned',
dx: 30,
dy: 0,
resolved: true,
})
expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({
dx: 0,
dy: 0,
})
})
test('does not move labels that are already clear', () => {
expect(
resolveAnnotationLabelRectangles([
{ id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 },
{ id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 },
]),
).toEqual([
{ id: 'left', dx: 0, dy: 0, resolved: true },
{ id: 'right', dx: 0, dy: 0, resolved: true },
])
})
test('preserves drawing order for labels on the same dimension string', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 },
{ id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 },
])
expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 })
expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 })
})
test('slides a colliding label along its dimension string before crossing tiers', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 },
{
id: 'adjacent',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
tangentX: 1,
tangentY: 0,
},
])
expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true })
expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0)
})
test('tries a short dimension alternative before generic relocation', () => {
const shifts = resolveAnnotationLabelRectangles(
[
{
id: 'short-dimension',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
preferredShifts: [{ dx: 100, dy: 0 }],
},
],
[{ x: 0, y: 0, width: 40, height: 12 }],
)
expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }])
})
test('falls back to a third position when both short-dimension sides are blocked', () => {
const shifts = resolveAnnotationLabelRectangles(
[
{
id: 'short-dimension',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
preferredShifts: [{ dx: 100, dy: 0 }],
},
],
[
{ x: 0, y: 0, width: 40, height: 12 },
{ x: 100, y: 0, width: 40, height: 12 },
],
)
expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true })
expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 })
expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 })
})
test('approximates diagonal outlines without blocking their full bounding box', () => {
expect(
polylineObstacleRectangles([
{ x: 0, y: 0 },
{ x: 4, y: 4 },
{ x: 8, y: 8 },
]),
).toEqual([
{ x: -1, y: -1, width: 6, height: 6 },
{ x: 3, y: 3, width: 6, height: 6 },
])
})
test('moves a dimension value clear of a fixed door-mark pill', () => {
const shifts = resolveAnnotationLabelRectangles(
[{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }],
[{ x: 100, y: 82, width: 48, height: 32 }],
)
expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })])
const shift = shifts[0]
expect(shift).not.toMatchObject({ dx: 0, dy: 0 })
expect(
94 + (shift?.dx ?? 0) + 42 + 6 <= 100 ||
148 + 6 <= 94 + (shift?.dx ?? 0) ||
88 + (shift?.dy ?? 0) + 16 + 6 <= 82 ||
114 + 6 <= 88 + (shift?.dy ?? 0),
).toBe(true)
})
test('finds separate nearby positions for a dense label cluster', () => {
const shifts = resolveAnnotationLabelRectangles(
Array.from({ length: 4 }, (_, index) => ({
id: `label-${index}`,
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10 - index,
})),
)
expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4)
expect(shifts.every((entry) => entry.resolved)).toBe(true)
})
test('keeps a large dense label cluster readable', () => {
const rectangles = Array.from({ length: 12 }, (_, index) => ({
id: `label-${index}`,
x: 100 + (index % 3) * 4,
y: 100 + (index % 2) * 3,
width: 48 + (index % 4) * 8,
height: 14,
priority: 20 - index,
}))
const shifts = resolveAnnotationLabelRectangles(rectangles)
const placed = rectangles.map((rectangle) => {
const shift = shifts.find(({ id }) => id === rectangle.id)
return {
...rectangle,
x: rectangle.x + (shift?.dx ?? 0),
y: rectangle.y + (shift?.dy ?? 0),
}
})
expect(shifts.every((entry) => entry.resolved)).toBe(true)
for (let index = 0; index < placed.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) {
const left = placed[index]
const right = placed[otherIndex]
if (!left || !right) continue
const overlaps = !(
left.x + left.width + 6 <= right.x ||
right.x + right.width + 6 <= left.x ||
left.y + left.height + 6 <= right.y ||
right.y + right.height + 6 <= left.y
)
expect(overlaps).toBe(false)
}
}
})
test('resolves a construction-plan label set without blocking the view transition', () => {
const labels = Array.from({ length: 25 }, (_, index) => ({
id: `label-${index}`,
x: index % 5,
y: index % 7,
width: 80,
height: 20,
priority: 25 - index,
}))
const obstacles = Array.from({ length: 100 }, (_, index) => ({
x: (index % 10) * 2,
y: (index % 13) * 2,
width: 100,
height: 40,
}))
const startedAt = performance.now()
const shifts = resolveAnnotationLabelRectangles(labels, obstacles)
const elapsedMs = performance.now() - startedAt
expect(shifts).toHaveLength(labels.length)
expect(shifts.every((entry) => entry.resolved)).toBe(true)
expect(elapsedMs).toBeLessThan(500)
})
})
describe('observeSvgAnnotationLayoutChanges', () => {
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
const OriginalMutationObserver = globalThis.MutationObserver
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined
let animationFrame: FrameRequestCallback | undefined
let disconnected = false
let observedOptions: MutationObserverInit | undefined
class FakeMutationObserver {
constructor(callback: MutationCallback) {
notify = callback
}
observe(_target: Node, options?: MutationObserverInit): void {
observedOptions = options
}
disconnect(): void {
disconnected = true
}
takeRecords(): MutationRecord[] {
return []
}
}
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrame = callback
return 1
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1
})
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
expect(layoutPasses).toBe(0)
animationFrame?.(0)
expect(layoutPasses).toBe(1)
expect(observedOptions).toMatchObject({
attributes: true,
childList: true,
subtree: true,
attributeFilter: expect.any(Array),
})
notify?.(
[
{
attributeName: 'style',
target: { closest: () => ({}) },
type: 'attributes',
} as unknown as MutationRecord,
],
{} as MutationObserver,
)
expect(layoutPasses).toBe(1)
stop()
expect(disconnected).toBe(true)
} finally {
globalThis.MutationObserver = OriginalMutationObserver
globalThis.requestAnimationFrame = originalRequestAnimationFrame
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
}
})
})
@@ -0,0 +1,688 @@
import type { FloorplanGeometry } from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
export type AnnotationLabelRectangle = {
id: string
x: number
y: number
width: number
height: number
priority: number
text?: string
labelPlacement?: 'inside' | 'outside-end'
pinnedShift?: { dx: number; dy: number }
tangentX?: number
tangentY?: number
preferredShifts?: readonly { dx: number; dy: number }[]
}
export type AnnotationObstacleRectangle = Pick<
AnnotationLabelRectangle,
'x' | 'y' | 'width' | 'height'
>
export type AnnotationLabelShift = {
id: string
dx: number
dy: number
resolved: boolean
}
const LABEL_GAP_PX = 6
const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5
const OUTLINE_SAMPLE_SPACING_PX = 6
const OUTLINE_OBSTACLE_PADDING_PX = 1
const MAX_LABEL_SHIFT_CANDIDATES = 512
const PREFERRED_SHIFT_COST_STEP = 1_000_000
const COLLISION_GRID_CELL_SIZE_PX = 64
class AnnotationObstacleIndex {
private readonly cells = new Map<string, Set<AnnotationObstacleRectangle>>()
add(rectangle: AnnotationObstacleRectangle): void {
this.forEachCell(rectangle, 0, (key) => {
let cell = this.cells.get(key)
if (!cell) {
cell = new Set()
this.cells.set(key, cell)
}
cell.add(rectangle)
})
}
findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] {
const candidates = new Set<AnnotationObstacleRectangle>()
this.forEachCell(rectangle, LABEL_GAP_PX, (key) => {
for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate)
})
return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate))
}
private forEachCell(
rectangle: AnnotationObstacleRectangle,
padding: number,
visit: (key: string) => void,
): void {
const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX)
const maxCellX = Math.floor(
(rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX,
)
const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX)
const maxCellY = Math.floor(
(rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX,
)
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`)
}
}
}
export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
export type AnnotationLayoutOverrides = Readonly<Record<string, AnnotationLayoutOverride>>
export type AnnotationPreflightIssueKind =
| 'unresolved-collision'
| 'short-unreadable-segment'
| 'plan-geometry-conflict'
export type AnnotationPreflightIssue = {
id: string
kind: AnnotationPreflightIssueKind
severity: 'warning'
message: string
}
export function resolveAnnotationLabelRectangles(
rectangles: readonly AnnotationLabelRectangle[],
obstacles: readonly AnnotationObstacleRectangle[] = [],
): AnnotationLabelShift[] {
const occupied = new AnnotationObstacleIndex()
for (const obstacle of obstacles) occupied.add(obstacle)
const shifts = new Map<string, AnnotationLabelShift>()
const ordered = rectangles
.map((rectangle, order) => ({ order, rectangle }))
.sort(
(left, right) =>
Number(Boolean(right.rectangle.pinnedShift)) -
Number(Boolean(left.rectangle.pinnedShift)) ||
right.rectangle.priority - left.rectangle.priority ||
left.order - right.order,
)
for (const { rectangle } of ordered) {
const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied)
const shift = selected ?? { dx: 0, dy: 0 }
const resolved = rectangle.pinnedShift !== undefined || selected !== undefined
occupied.add({
x: rectangle.x + shift.dx,
y: rectangle.y + shift.dy,
width: rectangle.width,
height: rectangle.height,
})
shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved })
}
return rectangles.map(
(rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false },
)
}
export function resolveSvgAnnotationCollisions(
svg: SVGSVGElement,
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
): AnnotationPreflightIssue[] {
const labels = Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
if (labels.length === 0) return []
for (const label of labels) {
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
label.removeAttribute('data-floorplan-layout-unresolved')
delete label.dataset.floorplanAnnotationLayoutDx
delete label.dataset.floorplanAnnotationLayoutDy
}
resetDimensionConnectors(svg)
const pinnedLocalById = new Map<string, { x: number; y: number }>()
const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => {
const bounds = label.getBoundingClientRect()
const matrix = label.getScreenCTM()
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
const override = options.layoutOverrides?.[id]
const pinnedLocal =
override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy)
? { dx: override.dx, dy: override.dy }
: undefined
if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy })
const pinnedShift =
pinnedLocal && matrix
? {
dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy,
dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy,
}
: undefined
const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0
const outsideStartLocalX = Number(
label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN,
)
const outsideStartLocalY = Number(
label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN,
)
const preferredShifts =
matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY)
? [
{
dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY,
dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY,
},
]
: undefined
return {
id,
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
text: label.textContent?.trim() ?? '',
labelPlacement:
label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside',
pinnedShift,
tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined,
preferredShifts,
}
})
const obstacles = Array.from(
svg.querySelectorAll<SVGGraphicsElement>('[data-floorplan-annotation-obstacle]'),
).flatMap(svgAnnotationObstacleRectangles)
const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles)
labels.forEach((label, index) => {
const rectangle = rectangles[index]
const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id)
if (!shift || (shift.dx === 0 && shift.dy === 0)) {
label.dataset.floorplanAnnotationLayoutDx = '0'
label.dataset.floorplanAnnotationLayoutDy = '0'
if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
return
}
const matrix = label.getScreenCTM()
if (!matrix) return
const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy)
label.dataset.floorplanAnnotationLayoutDx = String(local.x)
label.dataset.floorplanAnnotationLayoutDy = String(local.y)
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim())
const preferredShift = rectangle.preferredShifts?.[0]
const usedOutsideStart =
preferredShift !== undefined &&
Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5
if (usedOutsideStart) applyOutsideStartDimensionLine(label)
else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
showDimensionLeader(label, matrix, shift.dx, shift.dy)
}
if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
})
return preflightIssues
}
export function observeSvgAnnotationLayoutChanges(
svg: SVGSVGElement,
onChange: () => void,
): () => void {
let scheduledFrame: number | null = null
const schedule = () => {
if (scheduledFrame !== null) return
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
scheduledFrame = requestFrame(() => {
scheduledFrame = null
onChange()
})
}
const observer = new MutationObserver((mutations) => {
if (mutations.some(isAnnotationLayoutMutation)) schedule()
})
observer.observe(svg, {
attributes: true,
attributeFilter: [
'cx',
'cy',
'd',
'dominant-baseline',
'font-family',
'font-size',
'font-weight',
'height',
'points',
'r',
'rx',
'ry',
'stroke-width',
'text-anchor',
'transform',
'visibility',
'width',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
],
characterData: true,
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (scheduledFrame === null) return
if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
else clearTimeout(scheduledFrame)
}
}
function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
if (mutation.type !== 'attributes') return true
const attribute = mutation.attributeName ?? ''
const target = mutation.target as Element
const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
if (
attribute === 'data-floorplan-annotation-id' ||
attribute === 'data-floorplan-annotation-layout-dx' ||
attribute === 'data-floorplan-annotation-layout-dy' ||
attribute === 'data-floorplan-layout-unresolved'
) {
return false
}
if (
closest?.('[data-floorplan-annotation-label]') &&
(attribute === 'style' || attribute === 'transform')
) {
return false
}
if (
closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
(attribute === 'x1' ||
attribute === 'x2' ||
attribute === 'y1' ||
attribute === 'y2' ||
attribute === 'visibility')
) {
return false
}
return true
}
export function collectAnnotationLayoutPreflightIssues(
rectangles: readonly AnnotationLabelRectangle[],
shifts: readonly AnnotationLabelShift[],
obstacles: readonly AnnotationObstacleRectangle[] = [],
): AnnotationPreflightIssue[] {
const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
const finalRectangles = rectangles.map((rectangle) => {
const shift = shiftsById.get(rectangle.id) ?? {
id: rectangle.id,
dx: 0,
dy: 0,
resolved: false,
}
return {
source: rectangle,
shift,
bounds: {
x: rectangle.x + shift.dx,
y: rectangle.y + shift.dy,
width: rectangle.width,
height: rectangle.height,
},
}
})
const issues: AnnotationPreflightIssue[] = []
const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
issues.push({ id, kind, severity: 'warning', message })
}
for (const entry of finalRectangles) {
const label = preflightLabel(entry.source)
if (entry.source.labelPlacement === 'outside-end') {
addIssue(
entry.source.id,
'short-unreadable-segment',
`${label} is too short for inline text and uses an outside label or leader.`,
)
}
if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
addIssue(
entry.source.id,
'plan-geometry-conflict',
`${label} still conflicts with fixed plan geometry after automatic layout.`,
)
}
if (!entry.shift.resolved) {
const collidesWithLabel = finalRectangles.some(
(candidate) =>
candidate.source.id !== entry.source.id &&
rectanglesOverlap(entry.bounds, candidate.bounds),
)
if (collidesWithLabel) {
addIssue(
entry.source.id,
'unresolved-collision',
`${label} still overlaps another annotation after automatic layout.`,
)
}
}
}
return issues
}
function preflightLabel(rectangle: AnnotationLabelRectangle): string {
const text = rectangle.text?.trim()
return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
}
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
const explicit = label.dataset.floorplanAnnotationId?.trim()
if (explicit) return explicit
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
const text = label.textContent?.trim() ?? ''
return `annotation:${index}:${text}:${defaultTransform}`
}
function resetDimensionConnectors(svg: SVGSVGElement): void {
for (const line of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-line]')) {
line.setAttribute(
'x1',
line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0',
)
line.setAttribute(
'y1',
line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0',
)
line.setAttribute(
'x2',
line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0',
)
line.setAttribute(
'y2',
line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0',
)
}
for (const leader of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-leader]')) {
leader.setAttribute('visibility', 'hidden')
}
}
function applyOutsideStartDimensionLine(label: SVGGElement): void {
const dimension = label.closest('[data-floorplan-dimension]')
const line = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
if (!line) return
const { dataset } = line
if (
dataset.floorplanDimensionOutsideStartX1 === undefined ||
dataset.floorplanDimensionOutsideStartY1 === undefined ||
dataset.floorplanDimensionOutsideStartX2 === undefined ||
dataset.floorplanDimensionOutsideStartY2 === undefined
) {
return
}
line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1)
line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1)
line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2)
line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2)
}
function showDimensionLeader(
label: SVGGElement,
labelMatrix: DOMMatrix,
dx: number,
dy: number,
): void {
const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null
const leader = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-leader]')
const dimensionLine = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
const dimensionMatrix = dimension?.getScreenCTM()
if (!leader || !dimensionLine || !dimensionMatrix) return
const start = dimensionEndpoint(label, 'start')
const end = dimensionEndpoint(label, 'end')
if (!start || !end) return
dimensionLine.setAttribute('x1', String(start.x))
dimensionLine.setAttribute('y1', String(start.y))
dimensionLine.setAttribute('x2', String(end.x))
dimensionLine.setAttribute('y2', String(end.y))
const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy }
const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y)
const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y)
const anchor =
Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) <
Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y)
? start
: end
const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y)
leader.setAttribute('x1', String(anchor.x))
leader.setAttribute('y1', String(anchor.y))
leader.setAttribute('x2', String(labelPoint.x))
leader.setAttribute('y2', String(labelPoint.y))
leader.setAttribute('visibility', 'visible')
}
function dimensionEndpoint(
label: SVGGElement,
endpoint: 'start' | 'end',
): { x: number; y: number } | null {
const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`])
const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`])
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
}
function localPointToScreen(matrix: DOMMatrix, x: number, y: number) {
return {
x: matrix.a * x + matrix.c * y + matrix.e,
y: matrix.b * x + matrix.d * y + matrix.f,
}
}
function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) {
const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f)
return { x: local.x, y: local.y }
}
function svgAnnotationObstacleRectangles(
obstacle: SVGGraphicsElement,
): AnnotationObstacleRectangle[] {
const bounds = obstacle.getBoundingClientRect()
const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }]
if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback
const geometry = obstacle as SVGGeometryElement
const matrix = geometry.getScreenCTM()
if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback
try {
const length = geometry.getTotalLength()
const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d))
const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX))
const points = Array.from({ length: sampleCount + 1 }, (_, index) => {
const point = geometry.getPointAtLength((length * index) / sampleCount)
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}
})
return polylineObstacleRectangles(points)
} catch {
return fallback
}
}
export function polylineObstacleRectangles(
points: readonly { x: number; y: number }[],
): AnnotationObstacleRectangle[] {
if (points.length === 1) {
const point = points[0]!
return [
{
x: point.x - OUTLINE_OBSTACLE_PADDING_PX,
y: point.y - OUTLINE_OBSTACLE_PADDING_PX,
width: OUTLINE_OBSTACLE_PADDING_PX * 2,
height: OUTLINE_OBSTACLE_PADDING_PX * 2,
},
]
}
const rectangles: AnnotationObstacleRectangle[] = []
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1]!
const end = points[index]!
rectangles.push({
x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX,
y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX,
width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2,
height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2,
})
}
return rectangles
}
function resolveLabelShift(
rectangle: AnnotationLabelRectangle,
occupied: AnnotationObstacleIndex,
): { dx: number; dy: number } | undefined {
const candidates = new Map<string, { dx: number; dy: number; preference: number; cost: number }>()
const visited = new Set<string>()
const addCandidate = (dx: number, dy: number, preference = 0) => {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return
const key = `${dx}:${dy}`
if (visited.has(key)) return
const existing = candidates.get(key)
if (!existing || preference < existing.preference) {
candidates.set(key, {
dx,
dy,
preference,
cost: candidateCost({ dx, dy, preference }, rectangle),
})
}
}
addCandidate(0, 0, -2)
for (const preferred of rectangle.preferredShifts ?? []) {
addCandidate(preferred.dx, preferred.dy, -1)
}
while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) {
let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined
for (const queued of candidates.values()) {
if (!candidate || queued.cost < candidate.cost) candidate = queued
}
if (!candidate) return undefined
const key = `${candidate.dx}:${candidate.dy}`
candidates.delete(key)
visited.add(key)
const shifted = {
...rectangle,
x: rectangle.x + candidate.dx,
y: rectangle.y + candidate.dy,
}
const blockers = occupied.findOverlaps(shifted)
if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy }
const blockerBounds = blockers.reduce(
(bounds, blocker) => ({
minX: Math.min(bounds.minX, blocker.x),
minY: Math.min(bounds.minY, blocker.y),
maxX: Math.max(bounds.maxX, blocker.x + blocker.width),
maxY: Math.max(bounds.maxY, blocker.y + blocker.height),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
)
const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x
const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x
const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y
const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y
addCandidate(candidate.dx, above)
addCandidate(candidate.dx, below)
addCandidate(left, candidate.dy)
addCandidate(right, candidate.dy)
addCandidate(left, above)
addCandidate(right, above)
addCandidate(left, below)
addCandidate(right, below)
}
return undefined
}
function candidateCost(
candidate: { dx: number; dy: number; preference?: number },
rectangle: AnnotationLabelRectangle,
): number {
const distance = Math.hypot(candidate.dx, candidate.dy)
const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP
if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) {
return preference + distance
}
const perpendicularMovement = Math.abs(
candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX,
)
return preference + distance + perpendicularMovement * 4
}
export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean {
if (floorplanAnnotationObstacleMode(geometry)) return true
if (geometry.kind !== 'group') return false
const hasPlate = geometry.children.some(
(child) => child.kind === 'rect' || child.kind === 'circle',
)
const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright)
return hasPlate && hasUprightText
}
export function floorplanAnnotationObstacleMode(
geometry: FloorplanGeometry,
): 'bounds' | 'outline' | '' | undefined {
const metadata = readFloorplanGeometryMetadata(geometry)
if (metadata.annotationObstacle) return metadata.annotationObstacle
switch (metadata.annotationRole) {
case 'room-label':
return geometry.kind === 'text' ? 'bounds' : undefined
case 'column-center':
return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined
case 'stair-annotation':
return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds'
default:
return undefined
}
}
function rectanglesOverlap(
left: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
right: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
): boolean {
return !(
left.x + left.width + LABEL_GAP_PX <= right.x ||
right.x + right.width + LABEL_GAP_PX <= left.x ||
left.y + left.height + LABEL_GAP_PX <= right.y ||
right.y + right.height + LABEL_GAP_PX <= left.y
)
}
function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
return {
x: (matrix.d * dx - matrix.c * dy) / determinant,
y: (-matrix.b * dx + matrix.a * dy) / determinant,
}
}
@@ -0,0 +1,236 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import {
computeArchitecturalDimensionLayout,
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
floorplanDimensionAnnotationPriority,
} from './floorplan-dimension-renderer'
const dimension = {
kind: 'dimension',
start: [0, 0],
end: [4, 0],
offsetNormal: [0, 1],
offsetDistance: 0.45,
extensionOvershoot: 0.12,
text: `13'-1 1/2"`,
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
describe('architectural floor-plan dimensions', () => {
test('leaves a paper-space gap before solid extension lines', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout).not.toBeNull()
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 0.075])
expect(layout?.extensionStartTip[0]).toBe(0)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57)
expect(layout?.extensionEndTip[0]).toBe(4)
expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57)
expect(layout?.dimensionStart).toEqual([0, 0.45])
expect(layout?.dimensionEnd).toEqual([4, 0.45])
expect(layout?.dimensionLineEnd).toEqual([4, 0.45])
expect(layout?.labelPlacement).toBe('inside')
})
test('builds consistent 45-degree architectural slash terminators', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5)
expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5)
})
test('honors dimension standard overrides for gaps, terminators, and text placement', () => {
const customDimension = {
...dimension,
extensionStartGap: 0.2,
terminator: 'dot',
textPosition: 'centered',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
const layout = computeArchitecturalDimensionLayout(customDimension, 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={customDimension} />
</svg>,
)
expect(layout?.extensionStart).toEqual([0, 0.2])
expect(markup).toContain('<circle')
expect(markup).toContain('y="0.0525"')
})
test('moves a short value beyond its end tick and extends the dimension line', () => {
const shortDimension = {
...dimension,
end: [0.23, 0] as [number, number],
text: '0.23m',
}
const layout = computeArchitecturalDimensionLayout(shortDimension, 0)
expect(layout?.labelPlacement).toBe('outside-end')
expect(layout?.labelPoint[0]).toBeGreaterThan(0.23)
expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0)
expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan(
layout?.outsideStartLabelPoint?.[0] ?? 0,
)
expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0)
expect(layout?.dimensionEnd).toEqual([0.23, 0.45])
const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01)
expect(documentLayout?.labelPlacement).toBe('outside-end')
expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={shortDimension} sceneRotationDeg={37} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
expect(markup).not.toContain('data-floorplan-dimension-leader=""')
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer
annotationUnitsPerPoint={0.01}
geometry={shortDimension}
sceneRotationDeg={37}
/>
</svg>,
)
expect(documentMarkup).toContain('data-floorplan-dimension-leader=""')
expect(documentMarkup).toContain('visibility="hidden"')
})
test('aligns stepped feature origins to an explicit exterior baseline', () => {
const layout = computeArchitecturalDimensionLayout(
{
...dimension,
start: [0, 0],
end: [4, 1],
dimensionStart: [0, 2],
dimensionEnd: [4, 2],
offsetDistance: 2,
},
0,
)
expect(layout?.dimensionStart).toEqual([0, 2])
expect(layout?.dimensionEnd).toEqual([4, 2])
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 1.075])
expect(layout?.extensionStartTip).toEqual([0, 2.12])
expect(layout?.extensionEndTip).toEqual([4, 2.12])
})
test('renders one uninterrupted line with the label above it', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} />
</svg>,
)
expect(markup.match(/<line/g)).toHaveLength(5)
expect(markup).not.toContain('stroke-dasharray')
expect(markup).toContain('y="-0.12"')
expect(markup).toContain('13&#x27;-1 1/2&quot;')
expect(markup).toContain('paint-order="stroke"')
expect(markup).toContain('stroke="#ffffff"')
expect(markup).toContain('data-floorplan-annotation-priority="145"')
})
test('keeps farther-out architectural strings fixed before inner strings', () => {
expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan(
floorplanDimensionAnnotationPriority(0.55),
)
})
test('keeps labels readable after the scene rotates', () => {
expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180)
})
test('resolves document annotation sizes from paper points', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer annotationUnitsPerPoint={0.01} geometry={dimension} />
</svg>,
)
expect(layout?.extensionStart[1]).toBeCloseTo(0.03)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('y="-0.05"')
})
test('uses PDF-safe label plates and hairline measurement strokes for export', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('stroke-width="0.5"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('stroke="#ffffff"')
})
test('renders a dimension string with shared witness extension lines and ticks', () => {
const stringGeometry = {
kind: 'dimension-string',
segments: [
{
start: [0, 0],
end: [2, 0],
dimensionStart: [0, 1],
dimensionEnd: [2, 1],
text: '2m',
},
{
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
text: '3m',
},
],
offsetNormal: [0, 1],
offsetDistance: 1,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={stringGeometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-string=""')
expect(markup.match(/<line/g)).toHaveLength(8)
expect(markup).toContain('2m')
expect(markup).toContain('3m')
})
test('offsets automatic dimension-string lines when no explicit baseline is supplied', () => {
const automaticString = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [2, 0], text: '2m' }],
offsetNormal: [0, 1],
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={automaticString} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
})
})
@@ -0,0 +1,608 @@
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const EXTENSION_START_GAP = 0.075
const TICK_HALF_LENGTH = 0.09
const LABEL_FONT_SIZE = 0.15
const LABEL_BASELINE_OFFSET = 0.12
const LABEL_CHARACTER_WIDTH_RATIO = 0.62
const LABEL_END_GAP = 0.075
const DOCUMENT_EXTENSION_START_GAP_PT = 3
const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
const DOCUMENT_LABEL_FONT_SIZE_PT = 8
const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
const DOCUMENT_LABEL_END_GAP_PT = 3
const LINE_STROKE_WIDTH_PX = 0.9
const TICK_STROKE_WIDTH_PX = 1.35
const PDF_LINE_STROKE_WIDTH_PT = 0.5
const PDF_TICK_STROKE_WIDTH_PT = 0.75
const SQRT_ONE_HALF = Math.SQRT1_2
type FloorplanDimensionRenderMode = 'screen' | 'pdf'
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
export type ArchitecturalDimensionLayout = {
dimensionStart: FloorplanPoint
dimensionEnd: FloorplanPoint
dimensionLineStart: FloorplanPoint
dimensionLineEnd: FloorplanPoint
extensionStart: FloorplanPoint
extensionEnd: FloorplanPoint
extensionStartTip: FloorplanPoint
extensionEndTip: FloorplanPoint
tickHalfVector: FloorplanPoint
labelPoint: FloorplanPoint
labelAngleDeg: number
labelPlacement: 'inside' | 'outside-end'
outsideStartLabelPoint?: FloorplanPoint
outsideStartDimensionLineStart?: FloorplanPoint
}
export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
return 100 + Math.round(Math.abs(offsetDistance) * 100)
}
export function computeArchitecturalDimensionLayout(
geometry: DimensionGeometry,
sceneRotationDeg: number,
annotationUnitsPerPoint?: number,
): ArchitecturalDimensionLayout | null {
const extensionStartGap = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
: (geometry.extensionStartGap ?? EXTENSION_START_GAP)
const extensionOvershoot = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
: geometry.extensionOvershoot
const tickHalfLength = annotationUnitsPerPoint
? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
: TICK_HALF_LENGTH
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelEndGap = annotationUnitsPerPoint
? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
: LABEL_END_GAP
const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
geometry.start[0] + offsetX,
geometry.start[1] + offsetY,
]
const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
geometry.end[0] + offsetX,
geometry.end[1] + offsetY,
]
const dx = dimensionEnd[0] - dimensionStart[0]
const dy = dimensionEnd[1] - dimensionStart[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return null
const dirX = dx / length
const dirY = dy / length
const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
// A 45-degree architectural slash. Every terminator within one string uses
// this same vector instead of rotating independently around its endpoint.
const tickHalfVector: FloorplanPoint = [
(dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
(dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
]
const labelWidth = Math.max(
labelFontSize,
geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
)
const labelPlacement =
length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
const direction: FloorplanPoint = [dirX, dirY]
const labelPoint =
labelPlacement === 'inside'
? ([
(dimensionStart[0] + dimensionEnd[0]) / 2,
(dimensionStart[1] + dimensionEnd[1]) / 2,
] as FloorplanPoint)
: addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
const dimensionLineEnd =
labelPlacement === 'inside'
? dimensionEnd
: addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
const outsideStartLabelPoint =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
: undefined
const outsideStartDimensionLineStart =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
: undefined
return {
dimensionStart,
dimensionEnd,
dimensionLineStart: dimensionStart,
dimensionLineEnd,
extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
tickHalfVector,
labelPoint,
labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
labelPlacement,
outsideStartLabelPoint,
outsideStartDimensionLineStart,
}
}
function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
return [left[0] - right[0], left[1] - right[1]]
}
function dot(left: FloorplanPoint, right: FloorplanPoint): number {
return left[0] * right[0] + left[1] * right[1]
}
function addScaled(
point: FloorplanPoint,
direction: FloorplanPoint,
distance: number,
): FloorplanPoint {
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
}
export function FloorplanDimensionRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const layout = computeArchitecturalDimensionLayout(
geometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
if (!layout) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const terminator = geometry.terminator ?? 'architectural-tick'
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g data-floorplan-dimension="" pointerEvents="none">
<line
{...lineProps}
x1={layout.extensionStart[0]}
x2={layout.extensionStartTip[0]}
y1={layout.extensionStart[1]}
y2={layout.extensionStartTip[1]}
/>
<line
{...lineProps}
x1={layout.extensionEnd[0]}
x2={layout.extensionEndTip[0]}
y1={layout.extensionEnd[1]}
y2={layout.extensionEndTip[1]}
/>
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
{renderTerminator(
terminator,
layout.dimensionStart,
layout.dimensionEnd,
layout,
lineProps,
tickStrokeWidth,
)}
{renderTerminator(
terminator,
layout.dimensionEnd,
layout.dimensionStart,
layout,
lineProps,
tickStrokeWidth,
)}
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={geometry.text}
y={labelY}
/>
</g>
</g>
)
}
export function FloorplanDimensionStringRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionStringGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const segmentLayouts = geometry.segments.flatMap((segment, index) => {
const segmentGeometry: DimensionGeometry = {
kind: 'dimension',
start: segment.start,
end: segment.end,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
offsetNormal: geometry.offsetNormal,
offsetDistance: geometry.offsetDistance,
extensionOvershoot: geometry.extensionOvershoot,
extensionStartGap: geometry.extensionStartGap,
terminator: geometry.terminator,
textPosition: geometry.textPosition,
text: segment.text,
stroke: geometry.stroke,
}
const layout = computeArchitecturalDimensionLayout(
segmentGeometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
return layout ? [{ index, layout, segment: segmentGeometry }] : []
})
if (segmentLayouts.length === 0) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const extensionLines = new Map<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
const ticks = new Map<
string,
{ point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
>()
for (const { layout } of segmentLayouts) {
extensionLines.set(pointKey(layout.dimensionStart), {
start: layout.extensionStart,
tip: layout.extensionStartTip,
})
extensionLines.set(pointKey(layout.dimensionEnd), {
start: layout.extensionEnd,
tip: layout.extensionEndTip,
})
ticks.set(pointKey(layout.dimensionStart), {
point: layout.dimensionStart,
toward: layout.dimensionEnd,
tickHalfVector: layout.tickHalfVector,
})
ticks.set(pointKey(layout.dimensionEnd), {
point: layout.dimensionEnd,
toward: layout.dimensionStart,
tickHalfVector: layout.tickHalfVector,
})
}
const terminator = geometry.terminator ?? 'architectural-tick'
return (
<g data-floorplan-dimension-string="" pointerEvents="none">
{[...extensionLines.values()].map((line, index) => (
<line
{...lineProps}
key={`extension-${index}`}
x1={line.start[0]}
x2={line.tip[0]}
y1={line.start[1]}
y2={line.tip[1]}
/>
))}
{segmentLayouts.map(({ index, layout }) => (
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
key={`dimension-line-${index}`}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
))}
{[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
renderTerminator(
terminator,
point,
toward,
{ tickHalfVector },
lineProps,
tickStrokeWidth,
`tick-${index}`,
),
)}
{segmentLayouts.map(({ index, layout, segment }) => {
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g key={`label-${index}`}>
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={segment.text}
y={labelY}
/>
</g>
</g>
)
})}
</g>
)
}
function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
const cosine = Math.cos(radians)
const sine = Math.sin(radians)
return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
}
function DimensionLabel({
text,
y,
fontSize,
stroke,
renderMode,
}: {
text: string
y: number
fontSize: number
stroke: string
renderMode: FloorplanDimensionRenderMode
}) {
const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
const plateWidth = width + fontSize * 0.5
const plateHeight = fontSize * 1.2
const plateY = y - fontSize * 0.82
return (
<>
{renderMode === 'pdf' ? (
<rect
data-floorplan-dimension-label-plate=""
fill="#ffffff"
height={plateHeight}
rx={fontSize * 0.12}
ry={fontSize * 0.12}
width={plateWidth}
x={-plateWidth / 2}
y={plateY}
/>
) : null}
<text
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={500}
paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
strokeWidth={renderMode === 'pdf' ? undefined : 3}
textAnchor="middle"
vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
x={0}
y={y}
>
{text}
</text>
</>
)
}
function renderTerminator(
terminator: DimensionTerminator,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
lineProps: {
stroke: string
strokeLinecap: 'butt'
strokeWidth: number
vectorEffect: 'non-scaling-stroke'
},
tickStrokeWidth: number,
key?: string,
): React.ReactElement | null {
const direction = normalized(point, toward)
if (!direction) return null
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
if (terminator === 'dot') {
return (
<circle
fill={lineProps.stroke}
key={key}
r={tickHalfLength * 0.45}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[1]}
/>
)
}
if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
const base = addScaled(point, direction, tickHalfLength * 1.7)
const normal: FloorplanPoint = [-direction[1], direction[0]]
const wing = tickHalfLength * 0.65
const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
if (terminator === 'filled-arrow') {
return (
<polygon
fill={lineProps.stroke}
key={key}
points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
vectorEffect="non-scaling-stroke"
/>
)
}
return (
<g key={key}>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={left[0]}
y1={point[1]}
y2={left[1]}
/>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={right[0]}
y1={point[1]}
y2={right[1]}
/>
</g>
)
}
const [tickX, tickY] = layout.tickHalfVector
return (
<line
{...lineProps}
key={key}
strokeWidth={tickStrokeWidth}
x1={point[0] - tickX}
x2={point[0] + tickX}
y1={point[1] - tickY}
y2={point[1] + tickY}
/>
)
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
}
function pointKey(point: FloorplanPoint): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
}
@@ -0,0 +1,369 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
describe('FloorplanGeometryRenderer static labels', () => {
test('renders a measurement value together with its geometry', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' },
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 0,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('<line')
expect(markup).toContain('2.00m')
expect(markup).toContain('translate(0 -0.14)')
})
test('keeps screen-upright measurement labels readable in rotated exports', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: 'A 6.0m²',
angle: Math.PI / 3,
screenUpright: true,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} sceneRotationDeg={90} />
</svg>,
)
expect(markup).toContain('A 6.0m²')
expect(markup).toContain('rotate(-90)')
})
test('uses paper-point sizing when document scale is provided', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
})
test('uses live screen sizing without enabling document annotation styles', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'LIVE TEXT',
fontSize: 0.16,
upright: true,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} screenUnitsPerPixel={0.02} />
</svg>,
)
expect(markup).toContain('font-size="0.16"')
expect(markup).toContain('font-size="0.24"')
expect(markup).toContain('translate(0 -0.28)')
})
test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={geometry}
renderMode="pdf"
screenUnitsPerPixel={0.02}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('fill="#111827"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('fill="#ffffff" font-family=')
})
test('caps PDF annotation linework without changing live stroke widths', () => {
const geometry = {
kind: 'line',
x1: 0,
y1: 0,
x2: 2,
y2: 0,
stroke: '#334155',
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const pdfMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(liveMarkup).toContain('stroke-width="2"')
expect(pdfMarkup).toContain('stroke-width="0.5"')
})
test('removes unsupported paint-order outlines from generic PDF text', () => {
const geometry = {
kind: 'text',
x: 1,
y: 2,
text: '101',
fontSize: 0.15,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.04,
paintOrder: 'stroke',
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('fill="#334155"')
expect(markup).not.toContain('paint-order')
expect(markup).not.toContain('stroke=')
})
test('resolves generic annotation text from paper points only in document mode', () => {
const geometry = {
kind: 'text',
x: 2,
y: 3,
text: 'VERIFY DIMENSIONS',
fontSize: 0.16,
upright: true,
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(liveMarkup).toContain('font-size="0.16"')
expect(documentMarkup).toContain('font-size="0.08"')
})
test('uses a room-label paper profile while preserving room label hierarchy', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.2,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.18,
text: '101',
fontSize: 0.16,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.36,
text: 'CH: 2700',
fontSize: 0.11,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('font-size="0.07"')
expect(markup).toContain('font-size="0.055"')
expect(markup).toMatch(/translate\(0 0\.08625/)
expect(markup).toMatch(/translate\(0 0\.18625/)
expect(markup).toMatch(/translate\(0 0\.27375/)
})
test('uses paper stroke profiles for leaders and opening marks in document mode', () => {
const geometry = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{
kind: 'polyline',
points: [
[0, 0],
[1, 0],
[1.4, 0],
],
stroke: '#334155',
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'rect',
x: 2,
y: 2,
width: 0.42,
height: 0.32,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.02,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('stroke-width="0.9"')
expect(markup).toContain('vector-effect="non-scaling-stroke"')
expect(markup).toContain('height="0.14"')
expect(markup).toContain('rx="0.07"')
})
test('registers fixed mark pills as annotation obstacles', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle=""')
})
test('registers semantic plan primitives as annotation obstacles', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
],
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
}}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
test('registers fixed annotation categories as obstacles', () => {
const roomLabel = {
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.18,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
const stairArrow = {
kind: 'polyline',
points: [
[0, 0],
[0.5, 0.5],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={roomLabel} />
<FloorplanGeometryRenderer geometry={stairArrow} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
})
@@ -2,6 +2,29 @@
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react'
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
} from './floorplan-annotation-layout'
import {
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
} from './floorplan-dimension-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
const DOCUMENT_MARK_HEIGHT_PT = 14
const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
type FloorplanRenderMode = 'screen' | 'pdf'
/**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
pointerEventsOverride,
sceneRotationDeg = 0,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode = 'screen',
}: {
geometry: FloorplanGeometry
pointerEventsOverride?: string
sceneRotationDeg?: number
annotationUnitsPerPoint?: number
screenUnitsPerPixel?: number
renderMode?: FloorplanRenderMode
}) {
return renderNode(geometry, 0, pointerEventsOverride)
return renderNode(
geometry,
0,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
)
})
function styleAttrs(
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
pointerEventsOverride?: string,
annotationUnitsPerPoint?: number,
renderMode: FloorplanRenderMode = 'screen',
) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
pointerEvents?: string
cursor?: string
}
const annotationMetadata = readFloorplanGeometryMetadata(g)
const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
const strokeWidth =
renderMode === 'pdf' &&
vectorEffect === 'non-scaling-stroke' &&
resolvedStrokeWidth !== undefined
? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
: resolvedStrokeWidth
return {
fill: s.fill ?? 'none',
'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
'data-floorplan-annotation-role': annotationMetadata.annotationRole,
fill: documentStyle.fill ?? s.fill ?? 'none',
fillOpacity: s.fillOpacity,
stroke: s.stroke,
strokeWidth: s.strokeWidth,
stroke: documentStyle.stroke ?? s.stroke,
strokeWidth,
strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity,
opacity: s.opacity,
vectorEffect: s.vectorEffect,
vectorEffect,
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined,
}
}
export function resolveDocumentFloorplanAnnotationStyle(
geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
annotationUnitsPerPoint?: number,
): {
fill?: string
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
} {
if (annotationUnitsPerPoint === undefined) return {}
const styled = geometry as FloorplanGeometry & {
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
}
if (!styled.stroke && geometry.kind !== 'text') return {}
if (geometry.kind === 'text') {
const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
const sourceStrokeWidth = geometry.strokeWidth ?? 0
const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
return {
strokeWidth:
geometry.stroke && outlineRatio > 0
? Math.max(
DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
fontSize * outlineRatio,
)
: undefined,
}
}
return {
strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
vectorEffect: 'non-scaling-stroke',
}
}
function documentTextFontSize(
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
annotationUnitsPerPoint: number,
): number {
return documentTextSizePt(geometry) * annotationUnitsPerPoint
}
function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
case 'room-label':
if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
case 'column-center':
case 'stair-annotation':
return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
default:
return DOCUMENT_DEFAULT_TEXT_SIZE_PT
}
}
function documentStrokeWidth(
geometry: { strokeWidth?: number },
annotationUnitsPerPoint: number,
): number {
return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
}
export function documentRectGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'rect' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
return {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
rx: geometry.rx,
ry: geometry.ry,
}
}
const centerX = geometry.x + geometry.width / 2
const centerY = geometry.y + geometry.height / 2
const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
return {
x: centerX - width / 2,
y: centerY - height / 2,
width,
height,
rx: height / 2,
ry: height / 2,
}
}
function isAnnotationMarkRect(geometry: Extract<FloorplanGeometry, { kind: 'rect' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
}
export function documentCircleGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
return { r: geometry.r }
}
return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
}
function isAnnotationMarkCircle(geometry: Extract<FloorplanGeometry, { kind: 'circle' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
}
export function resolveDocumentAnnotationGroupChildren(
children: FloorplanGeometry[],
annotationUnitsPerPoint?: number,
): FloorplanGeometry[] {
if (annotationUnitsPerPoint === undefined) return children
const next = [...children]
let start = 0
while (start < next.length) {
const first = next[start]
if (!isDocumentTextLine(first)) {
start++
continue
}
let end = start + 1
while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
if (end - start > 1) {
const run = next.slice(start, end) as Extract<FloorplanGeometry, { kind: 'text' }>[]
const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
for (let index = 0; index < adjusted.length; index++) {
const line = adjusted[index]
if (line) next[start + index] = line
}
}
start = end
}
return next
}
function isDocumentTextLine(
geometry: FloorplanGeometry | undefined,
): geometry is Extract<FloorplanGeometry, { kind: 'text' }> {
return geometry?.kind === 'text' && geometry.upright === true
}
function isSameDocumentTextRun(
first: Extract<FloorplanGeometry, { kind: 'text' }>,
candidate: FloorplanGeometry | undefined,
): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
return (
isDocumentTextLine(candidate) &&
Math.abs(candidate.x - first.x) < 1e-6 &&
candidate.textAnchor === first.textAnchor &&
readFloorplanGeometryMetadata(candidate).annotationRole ===
readFloorplanGeometryMetadata(first).annotationRole
)
}
function positionDocumentTextRun(
run: Extract<FloorplanGeometry, { kind: 'text' }>[],
annotationUnitsPerPoint: number,
): Extract<FloorplanGeometry, { kind: 'text' }>[] {
const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
const steps = run.slice(0, -1).map((line, index) => {
const next = run[index + 1] ?? line
const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
return largerFontPt * 1.25 * annotationUnitsPerPoint
})
const totalHeight = steps.reduce((sum, step) => sum + step, 0)
let y = centerY - totalHeight / 2
return run.map((line, index) => {
if (index > 0) y += steps[index - 1] ?? 0
return { ...line, y }
})
}
function renderNode(
g: FloorplanGeometry,
keyHint: number,
pointerEventsOverride?: string,
sceneRotationDeg = 0,
annotationUnitsPerPoint?: number,
screenUnitsPerPixel?: number,
renderMode: FloorplanRenderMode = 'screen',
): React.ReactElement | null {
switch (g.kind) {
case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
return (
<path
d={g.d}
key={keyHint}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'polygon':
return (
<polygon
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
@@ -93,34 +338,38 @@ function renderNode(
<polyline
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'rect':
case 'rect': {
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
return (
<rect
height={g.height}
height={attrs.height}
key={keyHint}
rx={g.rx}
ry={g.ry}
width={g.width}
x={g.x}
y={g.y}
{...styleAttrs(g, pointerEventsOverride)}
rx={attrs.rx}
ry={attrs.ry}
width={attrs.width}
x={attrs.x}
y={attrs.y}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
}
case 'circle':
case 'circle': {
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
return (
<circle
cx={g.cx}
cy={g.cy}
key={keyHint}
r={g.r}
{...styleAttrs(g, pointerEventsOverride)}
r={attrs.r}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
}
case 'line':
return (
@@ -130,25 +379,65 @@ function renderNode(
x2={g.x2}
y1={g.y1}
y2={g.y2}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'text':
case 'text': {
const fontSize =
annotationUnitsPerPoint !== undefined
? documentTextFontSize(g, annotationUnitsPerPoint)
: g.fontSize
const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
const fill =
pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
? g.stroke
: (g.fill ?? '#171717')
if (g.upright) {
return (
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={fontSize}
fontWeight={g.fontWeight}
opacity={g.opacity}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
pointerEvents={pointerEventsOverride}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
return (
<text
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={g.fontSize}
fontSize={fontSize}
fontWeight={g.fontWeight}
key={keyHint}
opacity={g.opacity}
paintOrder={g.paintOrder}
stroke={g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride}
x={g.x}
@@ -157,6 +446,93 @@ function renderNode(
{g.text}
</text>
)
}
case 'dimension':
return (
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
annotationUnitsPerPoint={annotationUnitsPerPoint}
renderMode={renderMode}
/>
)
case 'dimension-string':
return (
<FloorplanDimensionStringRenderer
annotationUnitsPerPoint={annotationUnitsPerPoint}
geometry={g}
key={keyHint}
renderMode={renderMode}
sceneRotationDeg={sceneRotationDeg}
/>
)
case 'dimension-label': {
const unitsPerPixel =
annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
const documentMode = annotationUnitsPerPoint !== undefined
const outlined = g.appearance === 'outlined'
const pdfOutlined = outlined && renderMode === 'pdf'
const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3
const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
const textWidth = g.text.length * unitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
return (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={labelTransform}
>
{outlined && !pdfOutlined ? null : (
<rect
data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
fill="#ffffff"
height={plateH}
opacity={0.92}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
stroke={pdfOutlined ? undefined : '#334155'}
strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
)}
<text
dominantBaseline="middle"
fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
fontFamily={
outlined && !pdfOutlined
? 'system-ui, -apple-system, sans-serif'
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
}
fontSize={fontSize}
fontWeight={outlined ? 500 : 600}
paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
stroke={outlined && !pdfOutlined ? '#334155' : undefined}
strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
textAnchor="middle"
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
case 'image':
return (
@@ -174,15 +550,32 @@ function renderNode(
case 'group': {
const transform = formatTransform(g.transform)
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
return (
<g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
<g
data-floorplan-annotation-obstacle={
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
}
key={keyHint}
transform={transform}
>
{children.map((child, i) =>
renderNode(
child,
i,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
),
)}
</g>
)
}
// The interactive primitives (hatch / hit-line / endpoint-handle /
// dimension-label) need the SVG context + theme palette + units-per-
// The remaining interactive primitives (hatch / hit-line / endpoint-handle)
// need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it
@@ -3,15 +3,25 @@ import type {
AnyNode,
AnyNodeId,
FloorplanAffordanceSession,
FloorplanGeometry,
LiveNodeOverrides,
} from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { z } from 'zod'
import {
FLOORPLAN_NODE_EXTENSION_KEY,
floorplanGeometryMetadata,
} from '../../../lib/floorplan/floorplan-extension'
import {
cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes,
collectFloorplanLinkedLevelNodes,
computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer'
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
})
})
describe('floorplan annotation overlay routing', () => {
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
const noop = () => {}
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
renderToStaticMarkup(
createElement(
'svg',
null,
createElement(InteractiveGeometry, {
activeDragId: null,
activeRotateNodeId: null,
geometry,
hatchPatternId: undefined,
hoveredHandleId: null,
isMarqueeSelectionActive: false,
nodeId: 'wall_test' as AnyNodeId,
onHandleDoubleClick: noop,
onHandleHoverChange: noop,
onHandlePointerDown: noop,
onMoveHandlePointerDown: noop,
palette: undefined,
sceneRotationDeg: 180,
unitsPerPixel: 0.01,
}),
),
)
const dimensionString = (
end: readonly [number, number],
offsetNormal: readonly [number, number],
): FloorplanGeometry => ({
kind: 'dimension-string',
segments: [{ start: [0, 0], end, text: '2m' }],
offsetNormal,
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
})
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
})
test('keeps a fixed mark pill together in the overlay pass', () => {
const mark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
})
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
const columnCenter = {
kind: 'line',
x1: 0,
y1: 0,
x2: 1,
y2: 0,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
})
})
describe('computeAffectedSiblingIds', () => {
beforeEach(() => {
nodeRegistry._reset()
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
])
})
})
describe('collectFloorplanLinkedLevelNodes', () => {
test('projects a node onto a linked destination level with its real children', () => {
nodeRegistry._reset()
registerNode({
kind: 'linked-floorplan-test',
schemaVersion: 1,
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
category: 'structure',
defaults: () => ({}) as never,
floorplan: () => null,
extensions: {
[FLOORPLAN_NODE_EXTENSION_KEY]: {
linkedLevelIds: () => ['level_upper' as AnyNodeId],
},
},
} as unknown as AnyNodeDefinition)
const child = {
id: 'linked_child',
type: 'linked-child',
parentId: 'linked_parent',
} as unknown as AnyNode
const parent = {
id: 'linked_parent',
type: 'linked-floorplan-test',
parentId: 'level_lower',
children: [child.id],
} as unknown as AnyNode
const nodes = { [parent.id]: parent, [child.id]: child }
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
{ id: parent.id, node: parent, children: [child] },
])
expect(
collectFloorplanLinkedLevelNodes(
nodes,
'level_upper' as AnyNodeId,
new Set([parent.id as AnyNodeId]),
),
).toEqual([])
})
})
@@ -33,6 +33,7 @@ import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -47,6 +48,16 @@ import {
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
import {
type FloorplanAnnotationVisibility,
filterFloorplanAnnotationGeometry,
} from '../../../lib/floorplan/annotation-visibility'
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
import {
createFloorplanContextExtensions,
type FloorplanWallDimensionReference,
getFloorplanNodeExtension,
} from '../../../lib/floorplan/floorplan-extension'
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import {
type ActiveInteractionScope,
@@ -60,7 +71,10 @@ import {
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useDrawingView from '../../../store/use-drawing-view'
import useEditor from '../../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useInteractionScope, {
useEndpointReshape,
useMovingNode,
@@ -74,6 +88,14 @@ import {
startFloorplanGroupRotate,
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
observeSvgAnnotationLayoutChanges,
resolveSvgAnnotationCollisions,
svgAnnotationLabelId,
} from './floorplan-annotation-layout'
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
@@ -276,6 +298,8 @@ type NodeDeps = {
node: AnyNode
live: LiveTransform | undefined
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
selected: boolean
highlighted: boolean
hovered: boolean
@@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit)
const showMeasurements = useViewer((s) => s.showMeasurements)
const metricNotation = useViewer((s) => s.metricNotation)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
@@ -423,6 +447,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// selectors freeze to `undefined` so drag publishes do not re-render the
// hidden floor-plan tree.
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
const drawingType = useDrawingView((s) => s.drawingType)
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
// Elevator builders read runtime state imperatively, so entries include this
// rare-changing ref in their cache deps.
const interactiveElevators = useInteractive((s) => s.elevators)
@@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
if (!isNodeKindEnabled(node.type, installedPlugins)) return
const def = nodeRegistry.get(node.type)
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
if (!drawingNode) return
const def = nodeRegistry.get(drawingNode.type)
if (!def?.floorplan) return
if (node.type === 'measurement' && !showMeasurements) return
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
out.push(descriptor)
}
@@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
visit(levelId as AnyNodeId)
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
if (activeLevelNode) {
const collectedIds = new Set(out.map((entry) => entry.id))
for (const linked of collectFloorplanLinkedLevelNodes(
nodes,
levelId as AnyNodeId,
collectedIds,
)) {
pushEntry(linked.id, linked.node, {
children: linked.children,
siblings: [],
parent: activeLevelNode,
})
}
}
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
// as siblings of the level, not under it — the `visit(levelId)` DFS
// above doesn't reach them. Walk every node of those kinds whose
@@ -871,7 +915,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// builders that gate on the current floor — e.g. elevator service
// range — keep working). Pure registry-driven dispatch: no kind
// name appears in this file.
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
const activeBuildingId = activeLevelNode
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
: null
@@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
}
return { entries: out, levelNodeIdsByType }
}, [installedPlugins, levelId, nodes, showMeasurements])
}, [drawingType, installedPlugins, levelId, nodes])
// ── Generic 2D affordance dispatch ─────────────────────────────────
//
@@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
@@ -1323,6 +1367,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
@@ -1341,6 +1387,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
@@ -1374,12 +1421,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
/>
))}
</g>
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
{/* Dashed group bbox — shows what a group drag carries along while a
multi-selection exists, rides the live delta mid-drag, and doubles
as the group's whole-area drag handle. */}
@@ -1403,9 +1453,136 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
)
})
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const markerRef = useRef<SVGGElement>(null)
const [layoutEpoch, setLayoutEpoch] = useState(0)
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
useLayoutEffect(() => {
if (!active) return
const svg = markerRef.current?.ownerSVGElement
if (!svg) return
return observeSvgAnnotationLayoutChanges(svg, () => {
setLayoutEpoch((epoch) => epoch + 1)
})
}, [active])
useLayoutEffect(() => {
// The epoch is only a trigger; collision inputs are measured from the live SVG below.
void layoutEpoch
if (!active) {
resetPreflightIssues()
return
}
const svg = markerRef.current?.ownerSVGElement
if (!svg) return
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
layoutOverrides: annotationLayoutOverrides,
})
setPreflightIssues(preflightIssues)
const labels = Array.from(
svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
const cleanup: Array<() => void> = []
for (const [index, label] of labels.entries()) {
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
label.style.pointerEvents = 'all'
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
label.style.cursor = 'grabbing'
const matrix = label.getScreenCTM()
if (!matrix) return
const start = { x: event.clientX, y: event.clientY }
const existing = annotationLayoutOverrides[id] ?? {
...readFloorplanAnnotationLayoutOffset(label),
pinned: true,
}
let latest = existing
let moved = false
const onPointerMove = (moveEvent: PointerEvent) => {
moved = true
const local = screenVectorToFloorplanAnnotationLocal(
matrix,
moveEvent.clientX - start.x,
moveEvent.clientY - start.y,
)
latest = {
dx: existing.dx + local.x,
dy: existing.dy + local.y,
pinned: true,
}
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
label.setAttribute(
'transform',
`${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
)
}
const onPointerUp = () => {
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
if (moved) setAnnotationLayoutOverride(id, latest)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
const onDoubleClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setAnnotationLayoutOverride(id, null)
}
label.addEventListener('pointerdown', onPointerDown)
label.addEventListener('dblclick', onDoubleClick)
cleanup.push(() => {
label.removeEventListener('pointerdown', onPointerDown)
label.removeEventListener('dblclick', onDoubleClick)
label.style.pointerEvents = ''
label.style.cursor = ''
})
}
return () => {
for (const fn of cleanup) fn()
}
}, [
active,
annotationLayoutOverrides,
layoutEpoch,
resetPreflightIssues,
setAnnotationLayoutOverride,
setPreflightIssues,
])
return <g pointerEvents="none" ref={markerRef} />
}
function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
return {
x: (matrix.d * dx - matrix.c * dy) / determinant,
y: (-matrix.b * dx + matrix.a * dy) / determinant,
}
}
function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
return {
dx: Number.isFinite(dx) ? dx : 0,
dy: Number.isFinite(dy) ? dy : 0,
}
}
type FloorplanRegistryEntryProps = {
activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
annotationVisibility: FloorplanAnnotationVisibility
ctxOverrides: FloorplanContextOverrides | undefined
floorplanVisible: boolean
geometryCacheRef: { current: Map<string, CacheEntry> }
@@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = {
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
siblingEpoch: number
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
unitsPerPixel: number
visibilityRootId: AnyNodeId | undefined
}
@@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = {
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
activeDragId,
activeRotateNodeId,
annotationVisibility,
ctxOverrides,
floorplanVisible,
geometryCacheRef,
@@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
setMovingNodeOrigin,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
unitsPerPixel,
visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null {
@@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
selected,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
visibilityRootId,
})
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
const visibleGeometry = rawGeometry
? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
: null
// Multi-selection shows highlight only: strip this member's edit handles /
// dimension chrome (all of which live in the overlay pass) while keeping
// its highlighted body geometry.
const geometry =
rawGeometry && suppressHandles && pass === 'overlay'
? stripHandleChrome(rawGeometry)
: rawGeometry
visibleGeometry && suppressHandles && pass === 'overlay'
? stripHandleChrome(visibleGeometry)
: visibleGeometry
if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = {
selected: boolean
siblingEpoch: number
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
visibilityRootId: AnyNodeId | undefined
}
@@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({
selected,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
visibilityRootId,
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
const def = nodeRegistry.get(node.type)
@@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({
node,
live,
unit,
metricNotation,
wallDimensionReference,
selected,
highlighted,
hovered,
@@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({
const viewState = {
selected,
unit,
metricNotation,
wallDimensionReference,
highlighted,
hovered,
moving,
@@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({
siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent,
levelData,
extensions: createFloorplanContextExtensions({
metricNotation,
purpose: 'edit',
wallDimensionReference,
}),
viewState: palette
? {
selected,
@@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = {
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
}
const InteractiveGeometry = memo(function InteractiveGeometry({
export const InteractiveGeometry = memo(function InteractiveGeometry({
geometry,
unitsPerPixel,
palette,
@@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
case 'group': {
const transform = formatGroupTransform(g.transform)
return (
<g key={keyHint} transform={transform}>
<g
data-floorplan-annotation-obstacle={
floorplanAnnotationObstacleMode(g) ??
(isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
}
key={keyHint}
transform={transform}
>
{g.children.map((child, i) => renderInteractive(child, i))}
</g>
)
@@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
return (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
transform={labelTransform}
>
{outlined ? null : (
<rect
@@ -2598,147 +2809,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
}
case 'dimension': {
if (!palette) return <></>
const stroke = g.stroke ?? palette.measurementStroke
// Offset endpoints along the outward normal — this is where the
// dimension line sits, parallel to the edge.
const ox = g.offsetNormal[0] * g.offsetDistance
const oy = g.offsetNormal[1] * g.offsetDistance
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
// Extension line endpoints — extend past the dimension line by
// `extensionOvershoot` so the tip clears the dimension stroke.
const eOvershoot = g.extensionOvershoot
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
const dx = dEnd[0] - dStart[0]
const dy = dEnd[1] - dStart[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return <></>
const dirX = dx / length
const dirY = dy / length
// Plan-unit constants matching the legacy `floorplan-
// measurements-layer.tsx`. `strokeWidth` is intentionally a
// raw value (not multiplied by `unitsPerPixel`) because every
// stroke here uses `vectorEffect: non-scaling-stroke` — the
// browser interprets it as screen-pixel-stable. Multiplying
// by `unitsPerPixel` would shrink the strokes by ~100× and
// make them invisible. Tick length, dash pattern, font size,
// and the label gap stay in plan units (they're geometry,
// not stroke width).
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
const perpX = -dirY * tickHalf
const perpY = dirX * tickHalf
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
const labelGap = 0.5 // plan units — gap in the dimension line for the label
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
const midX = (dStart[0] + dEnd[0]) / 2
const midY = (dStart[1] + dEnd[1]) / 2
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
// Keep the label parallel to the dimension line, but decide the
// 180° flip from the on-SCREEN angle, not the local one. The parent
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
// plan), so a label kept upright in local coords still renders
// upside down for half of the wall orientations. Same fix as the
// `dimension-label` case above.
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
let screenDeg = labelDeg + sceneRotationDeg
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
if (screenDeg > 90) labelDeg -= 180
else if (screenDeg <= -90) labelDeg += 180
return (
<g key={keyHint} pointerEvents="none">
{/* Extension lines (dashed). */}
<line
stroke={stroke}
strokeDasharray="0.08 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.start[0]}
x2={eStartTip[0]}
y1={g.start[1]}
y2={eStartTip[1]}
/>
<line
stroke={stroke}
strokeDasharray="0.08 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.end[0]}
x2={eEndTip[0]}
y1={g.end[1]}
y2={eEndTip[1]}
/>
{/* Dimension line: two halves with the label in between. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0]}
x2={gapStart[0]}
y1={dStart[1]}
y2={gapStart[1]}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={gapEnd[0]}
x2={dEnd[0]}
y1={gapEnd[1]}
y2={dEnd[1]}
/>
{/* End ticks. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0] - perpX}
x2={dStart[0] + perpX}
y1={dStart[1] - perpY}
y2={dStart[1] + perpY}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dEnd[0] - perpX}
x2={dEnd[0] + perpX}
y1={dEnd[1] - perpY}
y2={dEnd[1] + perpY}
/>
{/* Rotated label centered in the gap. */}
<text
dominantBaseline="central"
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={600}
textAnchor="middle"
transform={`rotate(${labelDeg} ${midX} ${midY})`}
x={midX}
y={midY}
>
{g.text}
</text>
</g>
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
stroke={g.stroke ?? palette.measurementStroke}
/>
)
}
case 'text': {
@@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
// horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°).
return (
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
@@ -2775,6 +2856,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
geometry={g}
key={keyHint}
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
sceneRotationDeg={sceneRotationDeg}
/>
)
}
@@ -2853,6 +2935,9 @@ export function buildContext(
viewState: {
selected: boolean
unit: 'metric' | 'imperial'
metricNotation?: 'meters' | 'millimeters'
purpose?: 'edit' | 'document'
wallDimensionReference?: FloorplanWallDimensionReference
highlighted: boolean
hovered: boolean
moving: boolean
@@ -2892,6 +2977,11 @@ export function buildContext(
siblings,
parent,
levelData,
extensions: createFloorplanContextExtensions({
metricNotation: viewState.metricNotation ?? 'meters',
purpose: viewState.purpose ?? 'edit',
wallDimensionReference: viewState.wallDimensionReference,
}),
viewState: viewState.palette
? {
selected: viewState.selected,
@@ -2905,6 +2995,29 @@ export function buildContext(
}
}
export function collectFloorplanLinkedLevelNodes(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
excludedIds: ReadonlySet<AnyNodeId> = new Set(),
): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
for (const [rawId, node] of Object.entries(nodes)) {
if (!node) continue
const definition = nodeRegistry.get(node.type)
const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
if (!definition?.floorplan || !linkedLevelIds) continue
const id = rawId as AnyNodeId
if (excludedIds.has(id)) continue
if (!linkedLevelIds(node).includes(levelId)) continue
const childIds = (node as { children?: AnyNodeId[] }).children
const children = Array.isArray(childIds)
? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
: []
linked.push({ id, node, children })
}
return linked
}
/**
* Stable id for a handle on a node, derived from the node id + opaque
* payload. Used to track hover / active visual state when multiple
@@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'move-arrow',
'rotate-arrow',
'dimension',
'dimension-string',
'dimension-label',
'equal-spacing-badge',
])
@@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
} {
if (isFloorplanAnnotationObstacleGeometry(g)) {
return { base: null, overlay: g }
}
if (OVERLAY_KINDS.has(g.kind)) {
return { base: null, overlay: g }
}
@@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'node',
'live',
'unit',
'metricNotation',
'wallDimensionReference',
'selected',
'highlighted',
'hovered',
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
}
function getFloorplanStairBreakStep(stepCount: number) {
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
}
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
return 0
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
const dashedFromIndex = Math.floor(stepCount * 0.68)
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return (
<line
key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none"
stroke={index === stepCount ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return (
<line
@@ -397,14 +405,16 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
strokeWidth={isSelectionActive ? '2' : '1.35'}
vectorEffect="non-scaling-stroke"
/>
{treadBars.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
{treadBars
.slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
</g>
))}
{arrow?.polyline && arrow.polyline.length >= 2 ? (
@@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver
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 { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import {
@@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start,
end,
frontSide: 'unknown',
@@ -2719,9 +2721,10 @@ function formatMeasurement(
value: number,
unit: 'metric' | 'imperial',
metersPerUnit: number | null = null,
metricNotation: 'meters' | 'millimeters' = 'meters',
) {
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
return formatLinearMeasurement(measuredValue, unit)
return formatLinearMeasurement(measuredValue, unit, metricNotation)
}
function formatNumber(value: number, fractionDigits = 2) {
@@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({
unitsPerPixel: number
}) {
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
const metricNotation = useViewer((state) => state.metricNotation)
if (!cursor) {
return null
}
@@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({
label={`Ref ${formatMeasurement(
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
unit,
null,
metricNotation,
)}`}
palette={palette}
start={start}
@@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
unit: 'metric' | 'imperial'
unitsPerPixel: number
}) {
const metricNotation = useViewer((state) => state.metricNotation)
if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
return null
}
@@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
labelAngleDeg += 180
}
const label = formatMeasurement(length, unit)
const label = formatMeasurement(length, unit, null, metricNotation)
const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
const height = fontSize + padY * 2
@@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({
unitsPerPixel: number
sceneRotationDeg: number
}) {
const metricNotation = useViewer((state) => state.metricNotation)
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
@@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({
}
return {
lengthLabel: formatMeasurement(length, unit),
lengthLabel: formatMeasurement(length, unit, null, metricNotation),
midpoint: [
(wallDraftStart[0] + wallDraftEnd[0]) / 2,
(wallDraftStart[1] + wallDraftEnd[1]) / 2,
@@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({
direction: [dx / length, dy / length] as WallPlanPoint,
angleLabels,
}
}, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
}, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
return (
<>
@@ -5244,6 +5252,7 @@ export function FloorplanPanel({
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
const showGrid = useViewer((state) => state.showGrid)
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
@@ -11169,7 +11178,12 @@ export function FloorplanPanel({
Drawn line
</div>
<div className="mt-1 font-medium text-sm">
{formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)}
{formatMeasurement(
pendingReferenceScale.measuredLengthUnits,
unit,
null,
metricNotation,
)}
</div>
</div>
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({
<FloorplanWallMoveGhostLayer />
</g>
<FloorplanMeasurementToolLayer />
<FloorplanRegisteredToolLayer />
{floorplanSceneSlot}
</FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the
@@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react'
// Canonical in-world dimension formatter — metric metres or imperial
// feet/inches. Shared by every measurement readout so they read the same.
export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string {
export function formatMeasurement(
value: number,
unit: 'metric' | 'imperial',
metricNotation: 'meters' | 'millimeters' = 'meters',
): string {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
@@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
return `${Number.parseFloat(value.toFixed(2))}m`
}
@@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react'
import { memo } from 'react'
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
function formatMetric(
metric: QuickMeasurementMetric,
unit: 'metric' | 'imperial',
metricNotation: 'meters' | 'millimeters',
): string {
if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
return formatLinearMeasurement(metric.value, unit)
return formatLinearMeasurement(metric.value, unit, metricNotation)
}
export const QuickMeasurementCard = memo(function QuickMeasurementCard({
report,
unit,
metricNotation,
lensState,
}: {
report: QuickMeasurementReport
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
lensState: 'live' | 'pinned'
}) {
const pinned = lensState === 'pinned'
@@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
<span className="truncate">{metric.label}</span>
</div>
<div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
{formatMetric(metric, unit)}
{formatMetric(metric, unit, metricNotation)}
</div>
</div>
))}
@@ -12,6 +12,7 @@ export function QuickMeasurementHud() {
const viewMode = useEditor((state) => state.viewMode)
const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
if (!entry) return null
@@ -21,7 +22,12 @@ export function QuickMeasurementHud() {
data-quick-measure-hud
>
<div className="w-full max-w-[34rem]">
<QuickMeasurementCard lensState={entry.lensState} report={entry.report} unit={unit} />
<QuickMeasurementCard
lensState={entry.lensState}
metricNotation={metricNotation}
report={entry.report}
unit={unit}
/>
</div>
</div>
)
@@ -1,12 +1,17 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import type {
ConstructionDimensionChainMode,
ConstructionDimensionMode,
} from '@pascal-app/core'
import {
Box,
Check,
ChevronDown,
Eye,
EyeOff,
CircleIcon,
Crosshair,
Grid2X2,
Minus,
Ruler,
ScanSearch,
Square,
@@ -37,27 +42,64 @@ const measurementMenuOptions = [
...measurementOptions,
] as const
const constructionDimensionOptions = [
{ mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler },
{ mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints },
{ mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon },
{ mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon },
{ mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair },
{ mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus },
{ mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon },
{ mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle },
{ mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 },
] as const satisfies readonly {
mode: ConstructionDimensionMode
chainMode: ConstructionDimensionChainMode
label: string
icon: typeof Ruler
}[]
export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
const constructionDimensionChainMode = useEditor(
(state) => state.toolDefaults['construction-dimension']?.chainMode,
)
const constructionDimensionMode = useEditor(
(state) => state.toolDefaults['construction-dimension']?.mode,
)
const setMode = useEditor((state) => state.setMode)
const setPhase = useEditor((state) => state.setPhase)
const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setTool = useEditor((state) => state.setTool)
const setToolDefaults = useEditor((state) => state.setToolDefaults)
const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const setViewMode = useEditor((state) => state.setViewMode)
const selectedOption =
measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
const isActive = mode === 'build' && tool === 'measurement'
const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension'
const activeConstructionDimensionOption = constructionDimensionOptions.find(
(option) =>
option.mode === (constructionDimensionMode ?? 'linear') &&
option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'),
)
const isControlActive = isActive || isConstructionDimensionActive
const isSmartActive = isActive && activeToolKind === 'smart'
const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon
const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label
const SelectedIcon = isConstructionDimensionActive
? (activeConstructionDimensionOption?.icon ?? Ruler)
: isSmartActive
? ScanSearch
: selectedOption.icon
const selectedLabel = isConstructionDimensionActive
? (activeConstructionDimensionOption?.label ?? 'Linear dimension')
: isSmartActive
? 'Smart'
: selectedOption.label
const activateMeasurement = (kind: CreatableMeasurementKind) => {
setPhase('structure')
@@ -69,7 +111,7 @@ export function MeasurementControl() {
}
const handlePrimaryClick = () => {
if (isActive) {
if (isControlActive) {
setMode('select')
return
}
@@ -84,15 +126,27 @@ export function MeasurementControl() {
setTool('measurement')
}
const activateConstructionDimension = (
dimensionMode: ConstructionDimensionMode,
chainMode: ConstructionDimensionChainMode,
) => {
setPhase('structure')
setStructureLayer('elements')
setViewMode('2d')
setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode })
setMode('build')
setTool('construction-dimension')
}
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
<ActionButton
aria-label={`Measure: ${selectedLabel}`}
aria-pressed={isActive}
aria-pressed={isControlActive}
className={cn(
'rounded-r-none p-0 text-muted-foreground',
isActive
isControlActive
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
: 'hover:bg-cyan-500/15 hover:text-cyan-400',
)}
@@ -128,7 +182,7 @@ export function MeasurementControl() {
<PopoverContent
align="center"
className="w-56 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
className="max-h-[70vh] w-64 overflow-y-auto rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
@@ -138,7 +192,7 @@ export function MeasurementControl() {
const isSmart = option.kind === 'smart'
const isSelected = isSmart
? isSmartActive
: !isSmartActive && option.kind === selectedKind
: !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind
return (
<button
aria-checked={isSelected}
@@ -165,22 +219,37 @@ export function MeasurementControl() {
})}
<div className="my-1.5 h-px bg-border/60" />
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Floor plan
</div>
<button
aria-checked={showMeasurements}
className="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-muted-foreground text-sm transition-colors hover:bg-white/8 hover:text-foreground"
onClick={() => setShowMeasurements(!showMeasurements)}
role="menuitemcheckbox"
type="button"
>
{showMeasurements ? (
<Eye aria-hidden="true" className="h-4 w-4" />
) : (
<EyeOff aria-hidden="true" className="h-4 w-4" />
)}
<span>Show measurements</span>
<span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
</button>
{constructionDimensionOptions.map((option) => {
const OptionIcon = option.icon
const isSelected =
isConstructionDimensionActive && activeConstructionDimensionOption === option
return (
<button
aria-checked={isSelected}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={`${option.mode}-${option.chainMode}`}
onClick={() => {
activateConstructionDimension(option.mode, option.chainMode)
setIsOpen(false)
}}
role="menuitemradio"
type="button"
>
<OptionIcon aria-hidden="true" className="h-4 w-4" />
<span>{option.label}</span>
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
@@ -1,4 +1,9 @@
import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core'
import {
clearSceneHistory,
emitter,
useScene,
validateBuildJson,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { TreeView, VisualJson } from '@visual-json/react'
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
@@ -394,14 +399,14 @@ export function SettingsPanel({
</div>
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">Floorplan</div>
<div className="font-medium text-muted-foreground text-xs">Floor plan</div>
<Button
className="w-full justify-start gap-2"
onClick={() => exportFloorplanPdf('full')}
variant="outline"
>
<MapIcon className="size-4" />
Full floorplan
Full floor plan
</Button>
<Button
className="w-full justify-start gap-2"
+35
View File
@@ -81,6 +81,9 @@ export {
type SnapshotCameraData,
ThumbnailGenerator,
} from './components/editor/thumbnail-generator'
export { useFloorplanRender } from './components/editor-2d/floorplan-render-context'
export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer'
export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer'
export {
FloorplanNodePreview,
type FloorplanNodePreviewProps,
@@ -332,6 +335,28 @@ export {
type FloorplanStairSegmentEntry,
getFloorplanWallThickness,
} from './lib/floorplan'
export type {
FloorplanAnnotationCategory,
FloorplanAnnotationVisibility,
} from './lib/floorplan/annotation-visibility'
export {
createFloorplanContextExtensions,
FLOORPLAN_CONTEXT_EXTENSION_KEY,
FLOORPLAN_GEOMETRY_METADATA_KEY,
FLOORPLAN_NODE_EXTENSION_KEY,
type FloorplanAnnotationRole,
type FloorplanMetricNotation,
type FloorplanNodeExtension,
type FloorplanRenderPurpose,
type FloorplanSchedule,
type FloorplanToolContext,
floorplanGeometryMetadata,
getFloorplanNodeExtension,
readFloorplanContext,
readFloorplanGeometryMetadata,
readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata,
} from './lib/floorplan/floorplan-extension'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export'
export {
@@ -462,6 +487,10 @@ export {
export { default as useAlignmentGuides } from './store/use-alignment-guides'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export {
DRAWING_TYPE_OPTIONS,
default as useDrawingView,
} from './store/use-drawing-view'
export type {
CaptureMode,
FloorplanSelectionTool,
@@ -485,7 +514,13 @@ export {
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export {
default as useFloorplanPreflight,
type FloorplanPreflightIssue,
type FloorplanPreflightIssueKind,
} from './store/use-floorplan-preflight'
export {
default as useInteractionScope,
getEditingHole,
@@ -0,0 +1,249 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import {
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
filterFloorplanAnnotationGeometry,
normalizeFloorplanAnnotationVisibility,
} from './annotation-visibility'
import { floorplanGeometryMetadata } from './floorplan-extension'
describe('floor-plan annotation visibility', () => {
test('fills missing persisted categories with visible defaults', () => {
expect(normalizeFloorplanAnnotationVisibility({ measurements: false })).toEqual({
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
measurements: false,
})
})
test('removes automatic dimension primitives without removing plan geometry', () => {
const line = { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0 } satisfies FloorplanGeometry
const geometry = {
kind: 'group',
children: [
line,
{
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [2, 0], text: '2.00m' }],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.1,
},
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
}),
).toEqual({ kind: 'group', children: [line] })
})
test('removes a complete curved automatic dimension group', () => {
const curvedDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 2 },
{ kind: 'dimension-label', cx: 1, cy: 1, text: 'R 2m', angle: 0 },
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(curvedDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
}),
).toBeNull()
})
test('removes only the opening mark from door geometry', () => {
const body = {
kind: 'polygon',
points: [
[0, 0],
[1, 0],
[1, 0.1],
],
} satisfies FloorplanGeometry
const mark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{ kind: 'line', x1: 0.5, y1: 0, x2: 0.5, y2: 0.5 },
{ kind: 'rect', x: 0.3, y: 0.5, width: 0.4, height: 0.3 },
{ kind: 'text', x: 0.5, y: 0.65, text: '101', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
const geometry = { kind: 'group', children: [body, mark] } satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
openingMarks: false,
}),
).toEqual({ kind: 'group', children: [body] })
})
test('hides manual dimensions and measurements independently', () => {
const manualDimension = {
kind: 'text',
x: 0,
y: 0,
text: 'Annotation',
fontSize: 0.15,
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
} satisfies FloorplanGeometry
const measurement = {
...manualDimension,
metadata: floorplanGeometryMetadata({ annotationRole: 'measurement' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(manualDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
manualDimensions: false,
}),
).toBeNull()
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [1, 0],
offsetNormal: [0, 1],
offsetDistance: 0.5,
extensionOvershoot: 0.1,
text: '1m',
},
],
},
{
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
},
),
).not.toBeNull()
expect(
filterFloorplanAnnotationGeometry(measurement, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
measurements: false,
}),
).toBeNull()
})
test('hides structural grids and only the center marks within column geometry', () => {
const centerMark = {
kind: 'line',
x1: -0.1,
y1: -0.1,
x2: 0.1,
y2: 0.1,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
const gridReference = {
kind: 'text',
x: 0,
y: 0.3,
text: 'B-2',
fontSize: 0.13,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
const footprint = {
kind: 'rect',
x: -0.2,
y: -0.2,
width: 0.4,
height: 0.4,
} satisfies FloorplanGeometry
const visibility = {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
structuralGrids: false,
}
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
children: [footprint, centerMark, gridReference],
},
visibility,
),
).toEqual({ kind: 'group', children: [footprint] })
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'structural-grid' }),
children: [footprint],
},
visibility,
),
).toBeNull()
})
test('hides room labels without removing the room footprint', () => {
const footprint = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 3],
],
} satisfies FloorplanGeometry
const roomName = {
kind: 'text',
x: 2,
y: 1.5,
text: 'Office',
fontSize: 0.2,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(
{ kind: 'group', children: [footprint, roomName] },
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, roomLabels: false },
),
).toEqual({ kind: 'group', children: [footprint] })
})
test('hides stair notes and break lines without removing stair geometry', () => {
const footprint = {
kind: 'polygon',
points: [
[0, 0],
[1, 0],
[1, 3],
[0, 3],
],
} satisfies FloorplanGeometry
const direction = {
kind: 'text',
x: 0.5,
y: 0.5,
text: 'UP',
fontSize: 0.16,
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
const breakLine = {
kind: 'polyline',
points: [
[0, 2],
[1, 2],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(
{ kind: 'group', children: [footprint, direction, breakLine] },
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, stairAnnotations: false },
),
).toEqual({ kind: 'group', children: [footprint] })
})
})
@@ -0,0 +1,110 @@
import type { FloorplanGeometry } from '@pascal-app/core'
import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './floorplan-extension'
export type FloorplanAnnotationCategory =
| 'automaticDimensions'
| 'manualDimensions'
| 'measurements'
| 'openingMarks'
| 'structuralGrids'
| 'roomLabels'
| 'stairAnnotations'
export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory, boolean>
export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
automaticDimensions: true,
manualDimensions: true,
measurements: true,
openingMarks: true,
structuralGrids: true,
roomLabels: true,
stairAnnotations: true,
}
export function normalizeFloorplanAnnotationVisibility(
value: unknown,
): FloorplanAnnotationVisibility {
if (!value || typeof value !== 'object') return { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY }
const persisted = value as Partial<Record<FloorplanAnnotationCategory, unknown>>
return {
automaticDimensions:
typeof persisted.automaticDimensions === 'boolean'
? persisted.automaticDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
manualDimensions:
typeof persisted.manualDimensions === 'boolean'
? persisted.manualDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.manualDimensions,
measurements:
typeof persisted.measurements === 'boolean'
? persisted.measurements
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.measurements,
openingMarks:
typeof persisted.openingMarks === 'boolean'
? persisted.openingMarks
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.openingMarks,
structuralGrids:
typeof persisted.structuralGrids === 'boolean'
? persisted.structuralGrids
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.structuralGrids,
roomLabels:
typeof persisted.roomLabels === 'boolean'
? persisted.roomLabels
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.roomLabels,
stairAnnotations:
typeof persisted.stairAnnotations === 'boolean'
? persisted.stairAnnotations
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.stairAnnotations,
}
}
export function filterFloorplanAnnotationGeometry(
geometry: FloorplanGeometry,
visibility: FloorplanAnnotationVisibility,
inheritedRole?: FloorplanAnnotationRole,
): FloorplanGeometry | null {
const role = readFloorplanGeometryMetadata(geometry).annotationRole ?? inheritedRole
if (role && !isAnnotationRoleVisible(role, visibility)) return null
if (
!visibility.automaticDimensions &&
role !== 'manual-dimension' &&
(geometry.kind === 'dimension' ||
geometry.kind === 'dimension-string' ||
geometry.kind === 'dimension-label' ||
geometry.kind === 'equal-spacing-badge')
) {
return null
}
if (geometry.kind !== 'group') return geometry
const children = geometry.children
.map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
.filter((child): child is FloorplanGeometry => child !== null)
if (children.length === 0) return null
if (children.length === geometry.children.length) return geometry
return { ...geometry, children }
}
function isAnnotationRoleVisible(
role: FloorplanAnnotationRole,
visibility: FloorplanAnnotationVisibility,
): boolean {
switch (role) {
case 'automatic-dimension':
return visibility.automaticDimensions
case 'manual-dimension':
return visibility.manualDimensions
case 'measurement':
return visibility.measurements
case 'opening-mark':
return visibility.openingMarks
case 'structural-grid':
case 'column-center':
return visibility.structuralGrids
case 'room-label':
return visibility.roomLabels
case 'stair-annotation':
return visibility.stairAnnotations
}
}
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type ConstructionDrawingType,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { z } from 'zod'
import { resolveNodeForDrawingType } from './drawing-coordination'
import { FLOORPLAN_NODE_EXTENSION_KEY } from './floorplan-extension'
describe('resolveNodeForDrawingType', () => {
afterEach(() => nodeRegistry._reset())
test('dispatches drawing coordination through the registered extension', () => {
const node = {
id: 'drawing-test_main',
type: 'drawing-test',
object: 'node',
parentId: null,
visible: true,
metadata: {},
} as unknown as AnyNode
registerNode({
kind: 'drawing-test',
schemaVersion: 1,
schema: z.object({ type: z.literal('drawing-test') }) as never,
category: 'utility',
defaults: () => ({}) as never,
extensions: {
[FLOORPLAN_NODE_EXTENSION_KEY]: {
resolveForDrawing: ({ drawingType }: { drawingType: ConstructionDrawingType }) =>
drawingType === 'floor-plan' ? null : node,
},
},
} as never)
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBeNull()
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'foundation-plan')).toBe(node)
})
test('leaves nodes without a drawing extension unchanged', () => {
const node = { id: 'unknown', type: 'unknown' } as unknown as AnyNode
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBe(node)
})
})
@@ -0,0 +1,13 @@
import { type AnyNode, type ConstructionDrawingType, nodeRegistry } from '@pascal-app/core'
import { getFloorplanNodeExtension } from './floorplan-extension'
export function resolveNodeForDrawingType(
node: AnyNode,
nodes: Record<string, AnyNode>,
drawingType: ConstructionDrawingType,
): AnyNode | null {
const extension = getFloorplanNodeExtension(nodeRegistry.get(node.type))
return extension?.resolveForDrawing
? extension.resolveForDrawing({ node, nodes, drawingType })
: node
}
@@ -0,0 +1,591 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import {
DrawingSheetNode,
type FloorplanGeometry,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
import {
filterFloorplanExportOverlay,
fitPlanToBox,
isFloorplanExportAnnotationGeometry,
partitionFloorplanExportOverlay,
pointsPerMeterForDrawingScale,
resolveDrawingSheetDocumentMarkers,
resolveDrawingSheetGeneralNotes,
resolveDrawingSheetKeyedNotes,
resolveFloorplanExportAnnotationVisibility,
resolveFloorplanExportNodeGeometry,
resolveFloorplanExportPlacement,
resolveFloorplanExportRotationDeg,
resolveFloorplanExportViewport,
resolveFloorplanExportViewState,
resolveFloorplanMeasurementSize,
resolveFloorplanPageLayout,
resolveFloorplanScreenUnitsPerPixel,
resolveGraphicScaleLength,
resolveSheetComposition,
resolveSheetExportLayout,
resolveSheetPageSetup,
rotateFloorplanExportBounds,
} from './floorplan-export'
import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension'
const drawingSheetExtension: FloorplanNodeExtension<DrawingSheetNode> = {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
}
beforeAll(() => {
if (nodeRegistry.has('drawing-sheet')) return
registerNode({
kind: 'drawing-sheet',
schemaVersion: 1,
schema: DrawingSheetNode,
category: 'analysis',
defaults: () => ({}) as never,
capabilities: {},
extensions: {
'pascal:editor/floorplan': drawingSheetExtension,
},
})
})
describe('filterFloorplanExportOverlay', () => {
test('preserves value labels and removes editing handles', () => {
const label = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 0,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const overlay = {
kind: 'group',
children: [
label,
{
kind: 'endpoint-handle',
point: [0, 0],
state: 'idle',
affordance: 'move-measurement-vertex',
payload: { vertexIndex: 0 },
},
],
} satisfies FloorplanGeometry
expect(filterFloorplanExportOverlay(overlay)).toEqual({
kind: 'group',
children: [label],
})
})
test('preserves wall, door, and window shapes used as annotation obstacles', () => {
const fixedGeometry = {
kind: 'group',
children: [
{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
stroke: '#1f2937',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
},
{
kind: 'path',
d: 'M 1 0 A 1 1 0 0 1 2 1',
fill: 'none',
stroke: '#64748b',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
{
kind: 'line',
x1: 2.5,
y1: 0,
x2: 3.5,
y2: 0,
stroke: '#1f2937',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
{ kind: 'move-handle', point: [2, 0.1] },
],
} satisfies FloorplanGeometry
const { overlay } = splitFloorplanOverlay(fixedGeometry)
expect(overlay).not.toBeNull()
expect(filterFloorplanExportOverlay(overlay!)).toEqual({
kind: 'group',
children: fixedGeometry.children.slice(0, 3),
transform: undefined,
})
})
test('keeps structural obstacles in model bounds while leaving marks as annotations', () => {
const wall = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
} satisfies FloorplanGeometry
const openingMark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{
kind: 'rect',
x: 1,
y: 1,
width: 0.4,
height: 0.2,
fill: '#ffffff',
stroke: '#334155',
},
{ kind: 'text', x: 1.2, y: 1.1, text: 'W01', fontSize: 0.1, upright: true },
],
} satisfies FloorplanGeometry
expect(
partitionFloorplanExportOverlay({ kind: 'group', children: [wall, openingMark] }),
).toEqual({
model: { kind: 'group', children: [wall], transform: undefined },
annotations: { kind: 'group', children: [openingMark], transform: undefined },
})
})
test('moves automatic dimensions embedded in base wall geometry into the PDF annotation layer', () => {
const wall = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
} satisfies FloorplanGeometry
const dimensions = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [4, 0], text: '4m' }],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.12,
} satisfies FloorplanGeometry
expect(
resolveFloorplanExportNodeGeometry(
{ kind: 'group', children: [wall, dimensions] },
null,
false,
),
).toEqual({
model: { kind: 'group', children: [wall], transform: undefined },
annotations: { kind: 'group', children: [dimensions], transform: undefined },
})
})
})
describe('fitPlanToBox', () => {
test('preserves aspect ratio and centers the plan', () => {
expect(fitPlanToBox(20, 10, 10, 20, 400, 300)).toEqual({
x: 10,
y: 70,
width: 400,
height: 200,
})
})
})
describe('floor plan export policy', () => {
test('uses the live floor-plan formatting profile for metric and imperial dimensions', () => {
expect(resolveFloorplanExportViewState('metric', 'millimeters')).toMatchObject({
purpose: 'edit',
unit: 'metric',
metricNotation: 'millimeters',
})
expect(resolveFloorplanExportViewState('imperial', 'meters')).toMatchObject({
purpose: 'edit',
unit: 'imperial',
metricNotation: 'meters',
})
})
test('fits an oversized plan inside the complete export viewport', () => {
const placement = resolveFloorplanExportPlacement(30, 20, 10, 20, 400, 300)
expect(placement.x).toBe(10)
expect(placement.y).toBeCloseTo(36.67, 2)
expect(placement.width).toBe(400)
expect(placement.height).toBeCloseTo(266.67, 2)
expect(placement.x).toBeGreaterThanOrEqual(10)
expect(placement.y).toBeGreaterThanOrEqual(20)
expect(placement.x + placement.width).toBeLessThanOrEqual(410)
expect(placement.y + placement.height).toBeLessThanOrEqual(320)
})
test('exports the same annotation categories that are visible in the live view', () => {
const liveVisibility = {
automaticDimensions: true,
manualDimensions: false,
measurements: true,
openingMarks: true,
structuralGrids: false,
roomLabels: false,
stairAnnotations: true,
}
expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility)
})
test('matches live screen sizing to the fitted export viewport', () => {
expect(resolveFloorplanScreenUnitsPerPixel(7, 4.5, 572, 463)).toBeCloseTo(0.012_237_762, 8)
})
test('keeps the export viewport anchored to the structural drawing bounds', () => {
expect(resolveFloorplanExportViewport({ x: -5, y: -6, width: 13, height: 13.5 })).toEqual({
x: -7.7,
y: -8.7,
width: 18.4,
height: 18.9,
})
})
test('fits the viewport around the rotated plan instead of clipping its corners', () => {
const bounds = rotateFloorplanExportBounds({ x: 0, y: 0, width: 10, height: 5 }, 90)
expect(bounds.x).toBeCloseTo(-5, 8)
expect(bounds.y).toBeCloseTo(0, 8)
expect(bounds.width).toBeCloseTo(5, 8)
expect(bounds.height).toBeCloseTo(10, 8)
})
test('keeps annotation-only nodes out of primary model bounds', () => {
expect(
isFloorplanExportAnnotationGeometry({
kind: 'group',
children: [],
metadata: { 'pascal:editor/floorplan': { annotationRole: 'measurement' } },
}),
).toBe(true)
expect(
isFloorplanExportAnnotationGeometry({
kind: 'group',
children: [],
metadata: { 'pascal:editor/floorplan': { annotationRole: 'manual-dimension' } },
}),
).toBe(true)
expect(isFloorplanExportAnnotationGeometry({ kind: 'polygon', points: [] })).toBe(false)
})
test('matches the current floor-plan rotation instead of forcing north-up', () => {
expect(resolveFloorplanExportRotationDeg(Math.PI / 6, Math.PI / 2)).toBeCloseTo(60, 8)
})
})
describe('pointsPerMeterForDrawingScale', () => {
test('converts metric ratios to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4)
})
test('converts imperial architectural scales to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4)
})
})
describe('resolveFloorplanMeasurementSize', () => {
test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
expect(
resolveFloorplanMeasurementSize({ x: -2, y: -3, width: 18.4, height: 18.9 }, 0.024),
).toEqual({ width: 18.4 / 0.024, height: 18.9 / 0.024 })
})
})
describe('resolveSheetExportLayout', () => {
test('reserves a plan viewport, side panel, and title block on one sheet page', () => {
expect(resolveSheetExportLayout(842, 595)).toEqual({
planBox: { x: 36, y: 36, width: 572, height: 463 },
sidePanel: { x: 626, y: 36, width: 180, height: 463 },
titleBlock: { x: 36, y: 517, width: 770, height: 42 },
})
})
})
describe('resolveFloorplanPageLayout', () => {
test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => {
expect(resolveFloorplanPageLayout(842, 595)).toEqual({
planBox: { x: 36, y: 64, width: 770, height: 495 },
})
})
})
describe('resolveGraphicScaleLength', () => {
test('chooses a model length that fits the available paper width', () => {
const scale = resolveGraphicScaleLength('1:50', 150)
expect(scale.modelMeters).toBe(2)
expect(scale.widthPt).toBeCloseTo(113.39, 2)
expect(scale.label).toBe('2 m')
})
})
describe('resolveSheetComposition', () => {
test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
placedViews: [
{
id: 'drawing-view_main',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
scale: '1:50',
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
})
expect(
resolveSheetComposition(
{ [sheet.id]: sheet },
'level_main',
'Main Level',
'floor-plan',
'Floor plan',
'1/4"=1\'-0"',
),
).toMatchObject({
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
paperSize: 'arch-b',
orientation: 'landscape',
drawingNumber: '2',
viewTitle: 'Main Floor Plan',
scale: '1:50',
generalNotes: [{ number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
keyedNoteInstances: [],
})
})
test('resolves reusable general note sets before sheet-local notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([
{ number: 1, text: 'Coordinate with structural.' },
{ number: 2, text: 'Verify dimensions.' },
])
})
test('reports duplicate reusable and sheet-local general notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([
{
severity: 'warning',
message:
'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.',
},
])
})
test('derives keyed-note legends from repeated stable instances', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' },
{ id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-1',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'keyed-note-instance_patch-2',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [4, 3],
},
],
keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }],
})
expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({
legend: [{ key: 'A', text: 'Patch existing slab.' }],
instances: [
{ id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 },
{ id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 },
],
warnings: [],
})
})
test('reports keyed-note instances with missing definitions', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
keyedNoteInstances: [
{
id: 'keyed-note-instance_missing',
definitionId: 'keyed-note_missing',
position: [2, 3],
},
],
})
expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([
{
severity: 'warning',
message:
'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.',
},
])
})
test('resolves scoped drawing sheet document markers', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
{
id: 'sheet-marker_other-view',
kind: 'detail-reference',
label: '3',
placedViewId: 'drawing-view_other',
position: [5, 5],
},
],
})
expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: '',
x: 2,
y: 3,
endX: null,
endY: null,
points: [],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: 'A',
x: 0.5,
y: 0.5,
endX: null,
endY: null,
points: [
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 1, y: 2 },
],
},
])
})
})
describe('resolveSheetPageSetup', () => {
test('resolves supported paper sizes and orientation to page points', () => {
expect(
resolveSheetPageSetup({
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
}),
).toEqual({ width: 1296, height: 864, orientation: 'landscape' })
const a3 = resolveSheetPageSetup({
paperSize: 'a3',
orientation: 'portrait',
customPaperWidth: null,
customPaperHeight: null,
})
expect(a3.width).toBeCloseTo(841.89, 2)
expect(a3.height).toBeCloseTo(1190.55, 2)
})
test('uses custom paper dimensions in inches', () => {
expect(
resolveSheetPageSetup({
paperSize: 'custom',
orientation: 'portrait',
customPaperWidth: 24,
customPaperHeight: 36,
}),
).toEqual({ width: 1728, height: 2592, orientation: 'portrait' })
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import type { GeometryContext } from '@pascal-app/core'
import {
createFloorplanContextExtensions,
normalizeFloorplanWallDimensionReference,
readFloorplanContext,
} from './floorplan-extension'
function context(extensions?: Readonly<Record<string, unknown>>): GeometryContext {
return {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
extensions,
}
}
describe('floor-plan context extensions', () => {
test('defaults wall dimensions to finished faces', () => {
expect(readFloorplanContext(context()).wallDimensionReference).toBe('finished-faces')
})
test('carries the selected centerline or stud-face reference', () => {
for (const wallDimensionReference of ['centerline', 'stud-faces'] as const) {
const extensions = createFloorplanContextExtensions({ wallDimensionReference })
expect(readFloorplanContext(context(extensions)).wallDimensionReference).toBe(
wallDimensionReference,
)
}
})
test('normalizes stale persisted references to the finished-face default', () => {
expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
})
})
@@ -0,0 +1,177 @@
import type {
AnyNode,
AnyNodeId,
ConstructionDrawingType,
DrawingSheetNode,
FloorplanGeometry,
GeometryContext,
NodeDefinition,
SceneApi,
} from '@pascal-app/core'
import type { ComponentType } from 'react'
export const FLOORPLAN_NODE_EXTENSION_KEY = 'pascal:editor/floorplan'
export const FLOORPLAN_GEOMETRY_METADATA_KEY = 'pascal:editor/floorplan'
export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
export type FloorplanRenderPurpose = 'edit' | 'document'
export type FloorplanMetricNotation = 'meters' | 'millimeters'
export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
export type FloorplanAnnotationRole =
| 'automatic-dimension'
| 'manual-dimension'
| 'measurement'
| 'opening-mark'
| 'structural-grid'
| 'column-center'
| 'room-label'
| 'stair-annotation'
export type FloorplanSchedule = {
id: string
title: string
columns: ReadonlyArray<{
key: string
label: string
weight?: number
}>
rows: ReadonlyArray<{
id: string
cells: Readonly<Record<string, string>>
}>
issues?: readonly string[]
}
export type FloorplanToolContext = {
sceneApi: SceneApi
activeLevelId: AnyNodeId | null
unit: 'metric' | 'imperial'
metricNotation: FloorplanMetricNotation
gridSnapStep: number
toolDefaults: Readonly<Record<string, unknown>> | null
selectNode: (id: AnyNodeId) => void
finishTool: () => void
}
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
preferredView?: '2d' | '3d'
actionMenu?: {
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
}
resolveDrawingSheet?: (args: {
node: N
levelId: AnyNodeId
drawingType: ConstructionDrawingType
}) => DrawingSheetNode | null
schedule?: (args: {
siblings: ReadonlyArray<N>
nodes: Readonly<Record<string, AnyNode>>
levelId: AnyNodeId
unit: 'metric' | 'imperial'
}) => FloorplanSchedule | null
linkedLevelIds?: (node: N) => readonly AnyNodeId[]
resolveForDrawing?: (args: {
node: N
nodes: Record<string, AnyNode>
drawingType: ConstructionDrawingType
}) => AnyNode | null
}
type FloorplanGeometryMetadata = {
annotationRole?: FloorplanAnnotationRole
annotationObstacle?: 'bounds' | 'outline'
}
type FloorplanContextExtension = {
purpose: FloorplanRenderPurpose
metricNotation: FloorplanMetricNotation
wallDimensionReference: FloorplanWallDimensionReference
}
export function normalizeFloorplanWallDimensionReference(
value: unknown,
): FloorplanWallDimensionReference {
return value === 'centerline' || value === 'stud-faces'
? value
: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE
}
export function getFloorplanNodeExtension(
definition: NodeDefinition<any> | undefined,
): FloorplanNodeExtension | undefined {
return definition?.extensions?.[FLOORPLAN_NODE_EXTENSION_KEY] as
| FloorplanNodeExtension
| undefined
}
export function floorplanGeometryMetadata(
values: FloorplanGeometryMetadata,
): Readonly<Record<string, unknown>> {
return { [FLOORPLAN_GEOMETRY_METADATA_KEY]: values }
}
export function withFloorplanGeometryMetadata<T extends FloorplanGeometry | null>(
geometry: T,
values: FloorplanGeometryMetadata,
): T {
if (!geometry) return geometry
const existing = readFloorplanGeometryMetadata(geometry)
return {
...geometry,
metadata: floorplanGeometryMetadata({ ...existing, ...values }),
} as T
}
export function readFloorplanGeometryMetadata(geometry: unknown): FloorplanGeometryMetadata {
const metadata = (geometry as { metadata?: Readonly<Record<string, unknown>> } | null)?.metadata
const value = metadata?.[FLOORPLAN_GEOMETRY_METADATA_KEY]
return value && typeof value === 'object' && !Array.isArray(value)
? (value as FloorplanGeometryMetadata)
: {}
}
export function createFloorplanContextExtensions(
values: Partial<FloorplanContextExtension>,
): Readonly<Record<string, unknown>> {
return {
[FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
purpose: values.purpose === 'document' ? 'document' : 'edit',
metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
values.wallDimensionReference,
),
} satisfies FloorplanContextExtension,
}
}
export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExtension {
const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
if (value && typeof value === 'object' && !Array.isArray(value)) {
const extension = value as Partial<FloorplanContextExtension>
return {
purpose: extension.purpose === 'document' ? 'document' : 'edit',
metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
extension.wallDimensionReference,
),
}
}
return {
purpose: 'edit',
metricNotation: 'meters',
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
}
}
export function readFloorplanMetricNotationOverride(
ctx: GeometryContext,
): FloorplanMetricNotation | undefined {
const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const metricNotation = (value as { metricNotation?: unknown }).metricNotation
return metricNotation === 'meters' || metricNotation === 'millimeters'
? metricNotation
: undefined
}
@@ -0,0 +1,194 @@
import type PdfKitDocument from 'pdfkit'
type PdfKitDocumentInstance = InstanceType<typeof PdfKitDocument>
type PdfTextOptions = {
align?: 'left' | 'center' | 'right'
maxWidth?: number
}
type PdfShapeStyle = 'F' | 'S'
export class FloorplanPdfDocument {
readonly raw: PdfKitDocumentInstance
readonly internal: {
pageSize: {
getWidth: () => number
getHeight: () => number
}
}
private currentFontSize = 12
private readonly defaultPageSize: readonly [number, number]
constructor(raw: PdfKitDocumentInstance, defaultPageSize: readonly [number, number]) {
this.raw = raw
this.defaultPageSize = defaultPageSize
this.internal = {
pageSize: {
getWidth: () => this.raw.page?.width ?? this.defaultPageSize[0],
getHeight: () => this.raw.page?.height ?? this.defaultPageSize[1],
},
}
}
addPage(
size: readonly [number, number] = this.defaultPageSize,
_orientation?: 'portrait' | 'landscape',
): this {
this.raw.addPage({ size: [size[0], size[1]], margin: 0 })
return this
}
setTextColor(color: string): this {
this.raw.fillColor(color)
return this
}
setDrawColor(color: string): this {
this.raw.strokeColor(color)
return this
}
setFillColor(color: string): this {
this.raw.fillColor(color)
return this
}
setLineWidth(width: number): this {
this.raw.lineWidth(width)
return this
}
setFont(family: string, weight: string = 'normal'): this {
const normalizedFamily = family.toLocaleLowerCase()
const normalizedWeight = weight.toLocaleLowerCase()
const bold = normalizedWeight === 'bold' || Number.parseInt(normalizedWeight, 10) >= 500
const base = normalizedFamily.includes('courier') ? 'Courier' : 'Helvetica'
this.raw.font(bold ? `${base}-Bold` : base)
return this
}
setFontSize(size: number): this {
this.currentFontSize = size
this.raw.fontSize(size)
return this
}
getTextWidth(value: string): number {
return this.raw.widthOfString(value, { lineBreak: false })
}
splitTextToSize(value: string, maxWidth: number): string[] {
if (maxWidth <= 0 || this.getTextWidth(value) <= maxWidth) return [value]
const words = value.trim().split(/\s+/)
const lines: string[] = []
let line = ''
for (const word of words) {
const candidate = line ? `${line} ${word}` : word
if (!line || this.getTextWidth(candidate) <= maxWidth) {
line = candidate
continue
}
lines.push(line)
line = word
}
if (line) lines.push(line)
return lines.length > 0 ? lines : ['']
}
text(
value: string | readonly string[],
x: number,
baselineY: number,
options: PdfTextOptions = {},
) {
const lines = typeof value === 'string' ? value.split('\n') : value
const lineHeight = this.currentFontSize * 1.2
lines.forEach((line, index) => {
const width = this.getTextWidth(line)
const drawX =
options.align === 'center' ? x - width / 2 : options.align === 'right' ? x - width : x
this.raw.text(line, drawX, baselineY - this.currentFontSize * 0.78 + index * lineHeight, {
lineBreak: false,
width: options.maxWidth,
})
})
return this
}
line(x1: number, y1: number, x2: number, y2: number): this {
this.raw.moveTo(x1, y1).lineTo(x2, y2).stroke()
return this
}
rect(x: number, y: number, width: number, height: number, style: PdfShapeStyle = 'S'): this {
this.raw.rect(x, y, width, height)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
roundedRect(
x: number,
y: number,
width: number,
height: number,
radiusX: number,
_radiusY: number,
style: PdfShapeStyle = 'S',
): this {
this.raw.roundedRect(x, y, width, height, radiusX)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
circle(x: number, y: number, radius: number, style: PdfShapeStyle = 'S'): this {
this.raw.circle(x, y, radius)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
triangle(
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
style: PdfShapeStyle = 'S',
): this {
this.raw.polygon([x1, y1], [x2, y2], [x3, y3])
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
}
export async function createFloorplanPdfDocument(defaultPageSize: readonly [number, number]) {
const [{ default: PDFDocument }, { default: blobStream }] = await Promise.all([
import('pdfkit/js/pdfkit.standalone'),
import('blob-stream'),
])
const raw = new PDFDocument({ autoFirstPage: false, compress: true, margin: 0 })
const stream = raw.pipe(blobStream())
return {
doc: new FloorplanPdfDocument(raw, defaultPageSize),
save: async (filename: string) => {
const blob = await new Promise<Blob>((resolve, reject) => {
stream.on('finish', () => resolve(stream.toBlob('application/pdf')))
stream.on('error', reject)
raw.on('error', reject)
raw.end()
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
},
}
}
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import PDFDocument from 'pdfkit'
import { floorplanGeometryMetadata } from './floorplan-extension'
import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
describe('renderFloorplanGeometryToPdfKit', () => {
test('writes dimension values as native PDF text with fixed point line weights', async () => {
const geometry = {
kind: 'dimension',
start: [0, 0],
end: [13, 0],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.1,
text: '13m',
} satisfies FloorplanGeometry
const pdf = await renderTestPdf(geometry)
expect(pdf).toContain('BT')
expect(pdf).toContain(`[<${Buffer.from('13m').toString('hex')}> 0] TJ`)
expect(pdf).toMatch(/0\.1 w/)
expect(pdf).toMatch(/0\.15 w/)
expect(pdf).not.toMatch(/ c\n/)
})
test('writes rotated annotation labels through PDF text operators', async () => {
const geometry = {
kind: 'text',
x: 4,
y: 3,
text: 'ROOM 101',
fontSize: 0.15,
fontWeight: 600,
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
const pdf = await renderTestPdf(geometry, 45)
expect(pdf).toContain(`<${Buffer.from('OOM 101').toString('hex')}>`)
expect(pdf).toContain('BT')
})
test('uses one font face, weight, and point size for every dimension value path', async () => {
const geometries = [
{
kind: 'dimension',
start: [0, 0],
end: [4, 0],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.1,
text: '4m',
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 2,
cy: 2,
text: '2m',
angle: 0,
},
{
kind: 'text',
x: 1,
y: 3,
text: '1m',
fontSize: 0.22,
fontWeight: 700,
metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
},
] satisfies FloorplanGeometry[]
const pdfs = await Promise.all(geometries.map((geometry) => renderTestPdf(geometry)))
const baseFonts = pdfs.flatMap((pdf) =>
[...pdf.matchAll(/\/BaseFont \/([^\n]+)/g)].map((match) => match[1]),
)
const fontSizes = pdfs.flatMap((pdf) =>
[...pdf.matchAll(/\/F\d+ ([\d.]+) Tf/g)].map((match) => match[1]),
)
expect([...new Set(baseFonts)]).toEqual(['Courier'])
expect([...new Set(fontSizes)]).toEqual(['1.6'])
})
})
async function renderTestPdf(geometry: FloorplanGeometry, rotationDeg = 0): Promise<string> {
const raw = new PDFDocument({ autoFirstPage: false, compress: false })
const chunks: Buffer[] = []
raw.on('data', (chunk: Buffer) => chunks.push(chunk))
const completed = new Promise<string>((resolve) => {
raw.on('end', () => resolve(Buffer.concat(chunks).toString('latin1')))
})
const doc = new FloorplanPdfDocument(raw, [200, 200])
doc.addPage()
await renderFloorplanGeometryToPdfKit(doc, geometry, {
annotationLayer: true,
placement: { x: 20, y: 20, width: 100, height: 100 },
rotationDeg,
viewport: { x: 0, y: -2, width: 20, height: 20 },
})
raw.end()
return completed
}
@@ -0,0 +1,680 @@
import { type FloorplanGeometry, type FloorplanPoint, loadAssetUrl } from '@pascal-app/core'
import {
type ArchitecturalDimensionLayout,
computeArchitecturalDimensionLayout,
} from '../../components/editor-2d/renderers/floorplan-dimension-renderer'
import {
documentCircleGeometryAttrs,
documentRectGeometryAttrs,
resolveDocumentAnnotationGroupChildren,
} from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from '../../components/editor-2d/renderers/floorplan-label-angle'
import type { FloorplanExportBounds } from './floorplan-export'
import { readFloorplanGeometryMetadata } from './floorplan-extension'
import type { FloorplanPdfDocument } from './floorplan-pdfkit-document'
const DIMENSION_LINE_WIDTH_PT = 0.5
const DIMENSION_TICK_WIDTH_PT = 0.75
const DIMENSION_TEXT_FONT_FAMILY = 'Courier'
const DIMENSION_TEXT_FONT_SIZE_PT = 8
const DIMENSION_TEXT_FONT_WEIGHT = 400
const DIMENSION_BASELINE_OFFSET_PT = 5
const DEFAULT_ANNOTATION_FONT_SIZE_PT = 8
const ROOM_NUMBER_FONT_SIZE_PT = 7
const ROOM_DETAIL_FONT_SIZE_PT = 5.5
const MARK_FONT_SIZE_PT = 7
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type StyledGeometry = Extract<
FloorplanGeometry,
{ kind: 'path' | 'polygon' | 'polyline' | 'rect' | 'circle' | 'line' }
>
type RenderContext = {
annotationLabelShiftIndex: number
annotationLabelShifts: readonly FloorplanPoint[]
annotationLayer: boolean
sceneRotationDeg: number
unitsPerPoint: number
}
export type FloorplanPdfKitPlacement = {
x: number
y: number
width: number
height: number
}
export async function renderFloorplanGeometryToPdfKit(
doc: FloorplanPdfDocument,
geometry: FloorplanGeometry,
options: {
annotationLabelShifts?: readonly FloorplanPoint[]
annotationLayer: boolean
placement: FloorplanPdfKitPlacement
rotationDeg: number
viewport: FloorplanExportBounds
},
): Promise<void> {
const pointsPerUnit = options.placement.width / options.viewport.width
if (!Number.isFinite(pointsPerUnit) || pointsPerUnit <= 0) return
const raw = doc.raw
raw.save()
raw.translate(options.placement.x, options.placement.y)
raw.scale(pointsPerUnit)
raw.translate(-options.viewport.x, -options.viewport.y)
raw.rotate(options.rotationDeg, { origin: [0, 0] })
await renderGeometry(doc, geometry, {
annotationLabelShiftIndex: 0,
annotationLabelShifts: options.annotationLabelShifts ?? [],
annotationLayer: options.annotationLayer,
sceneRotationDeg: options.rotationDeg,
unitsPerPoint: 1 / pointsPerUnit,
})
raw.restore()
}
async function renderGeometry(
doc: FloorplanPdfDocument,
geometry: FloorplanGeometry,
context: RenderContext,
): Promise<void> {
const raw = doc.raw
switch (geometry.kind) {
case 'path':
raw.save().path(geometry.d)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'polygon':
if (geometry.points.length < 2) return
raw.save().polygon(...geometry.points.map(([x, y]) => [x, y] as [number, number]))
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'polyline':
if (geometry.points.length < 2) return
raw.save().moveTo(geometry.points[0]![0], geometry.points[0]![1])
for (const [x, y] of geometry.points.slice(1)) raw.lineTo(x, y)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'rect':
raw.save()
{
const attrs = documentRectGeometryAttrs(
geometry,
context.annotationLayer ? context.unitsPerPoint : undefined,
)
if ((attrs.rx ?? 0) > 0 || (attrs.ry ?? 0) > 0) {
raw.roundedRect(
attrs.x,
attrs.y,
attrs.width,
attrs.height,
Math.max(attrs.rx ?? 0, attrs.ry ?? 0),
)
} else {
raw.rect(attrs.x, attrs.y, attrs.width, attrs.height)
}
}
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'circle':
raw
.save()
.circle(
geometry.cx,
geometry.cy,
documentCircleGeometryAttrs(
geometry,
context.annotationLayer ? context.unitsPerPoint : undefined,
).r,
)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'line':
raw.save().moveTo(geometry.x1, geometry.y1).lineTo(geometry.x2, geometry.y2)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'text':
drawGeometryText(doc, geometry, context)
return
case 'dimension':
drawDimension(doc, geometry, context)
return
case 'dimension-string':
drawDimensionString(doc, geometry, context)
return
case 'dimension-label':
drawDimensionLabel(doc, geometry, context)
return
case 'equal-spacing-badge':
drawEqualSpacingBadge(doc, geometry, context)
return
case 'image':
await drawImage(doc, geometry)
return
case 'group':
raw.save()
if (geometry.transform?.translate) {
raw.translate(geometry.transform.translate[0], geometry.transform.translate[1])
}
if (geometry.transform?.rotate !== undefined) {
raw.rotate((geometry.transform.rotate * 180) / Math.PI, { origin: [0, 0] })
}
for (const child of resolveDocumentAnnotationGroupChildren(
geometry.children,
context.annotationLayer ? context.unitsPerPoint : undefined,
)) {
await renderGeometry(doc, child, context)
}
raw.restore()
return
default:
return
}
}
function paintStyledGeometry(
raw: FloorplanPdfDocument['raw'],
geometry: StyledGeometry,
context: RenderContext,
): void {
const fill = geometry.fill && geometry.fill !== 'none' ? geometry.fill : null
const stroke = geometry.stroke && geometry.stroke !== 'none' ? geometry.stroke : null
const opacity = geometry.opacity ?? 1
const fillOpacity = (geometry.fillOpacity ?? 1) * opacity
const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity
if (fill) raw.fillColor(fill).fillOpacity(fillOpacity)
if (stroke) {
raw.strokeColor(stroke).strokeOpacity(strokeOpacity)
raw.lineWidth(resolveStrokeWidth(geometry, context))
if (geometry.strokeLinecap) raw.lineCap(geometry.strokeLinecap)
if (geometry.strokeLinejoin) raw.lineJoin(geometry.strokeLinejoin)
applyDash(
raw,
geometry.strokeDasharray,
geometry.vectorEffect === 'non-scaling-stroke',
context,
)
}
if (fill && stroke) raw.fillAndStroke(fill, stroke)
else if (fill) raw.fill(fill)
else if (stroke) raw.stroke(stroke)
}
function resolveStrokeWidth(geometry: StyledGeometry, context: RenderContext): number {
if (context.annotationLayer) return DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
if (geometry.vectorEffect === 'non-scaling-stroke') {
return (geometry.strokeWidth ?? 1) * context.unitsPerPoint
}
return geometry.strokeWidth ?? DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
}
function applyDash(
raw: FloorplanPdfDocument['raw'],
dasharray: string | undefined,
nonScaling: boolean,
context: RenderContext,
): void {
if (!dasharray) {
raw.undash()
return
}
const values = dasharray
.split(/[\s,]+/)
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0)
if (values.length === 0) return
const scale = nonScaling ? context.unitsPerPoint : 1
raw.dash(values[0]! * scale, { space: (values[1] ?? values[0]!) * scale })
}
function drawGeometryText(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
context: RenderContext,
): void {
const dimensionValue =
readFloorplanGeometryMetadata(geometry).annotationRole === 'automatic-dimension'
const fontSize = context.annotationLayer
? (dimensionValue ? DIMENSION_TEXT_FONT_SIZE_PT : annotationTextSizePt(geometry)) *
context.unitsPerPoint
: geometry.fontSize
const outlinedForScreen = geometry.paintOrder === 'stroke' && !!geometry.stroke
const fill =
outlinedForScreen && geometry.fill?.toLocaleLowerCase() === '#ffffff'
? (geometry.stroke ?? '#111827')
: (geometry.fill ?? '#171717')
drawNativeText(doc, {
angleDeg: geometry.upright ? -context.sceneRotationDeg : 0,
anchor: geometry.textAnchor ?? 'start',
fill,
fontFamily: dimensionValue ? DIMENSION_TEXT_FONT_FAMILY : geometry.fontFamily,
fontSize,
fontWeight: dimensionValue ? DIMENSION_TEXT_FONT_WEIGHT : geometry.fontWeight,
opacity: geometry.opacity,
text: geometry.text,
x: geometry.x,
y: geometry.y,
})
}
function annotationTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
case 'room-label':
if (geometry.fontSize >= 0.18) return DEFAULT_ANNOTATION_FONT_SIZE_PT
if (geometry.fontSize >= 0.145) return ROOM_NUMBER_FONT_SIZE_PT
return ROOM_DETAIL_FONT_SIZE_PT
case 'column-center':
case 'stair-annotation':
return MARK_FONT_SIZE_PT
default:
return DEFAULT_ANNOTATION_FONT_SIZE_PT
}
}
function drawDimension(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
context: RenderContext,
): void {
const layout = computeArchitecturalDimensionLayout(
geometry,
context.sceneRotationDeg,
context.unitsPerPoint,
)
if (!layout) return
drawDimensionLayout(doc, geometry, layout, context)
}
function drawDimensionString(
doc: FloorplanPdfDocument,
geometry: DimensionStringGeometry,
context: RenderContext,
): void {
const entries = geometry.segments.flatMap((segment) => {
const dimension: DimensionGeometry = {
kind: 'dimension',
start: segment.start,
end: segment.end,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
offsetNormal: geometry.offsetNormal,
offsetDistance: geometry.offsetDistance,
extensionOvershoot: geometry.extensionOvershoot,
extensionStartGap: geometry.extensionStartGap,
terminator: geometry.terminator,
textPosition: geometry.textPosition,
text: segment.text,
stroke: geometry.stroke,
}
const layout = computeArchitecturalDimensionLayout(
dimension,
context.sceneRotationDeg,
context.unitsPerPoint,
)
return layout ? [{ dimension, layout }] : []
})
if (entries.length === 0) return
const extensionLines = new Map<string, readonly [FloorplanPoint, FloorplanPoint]>()
const terminators = new Map<
string,
{ point: FloorplanPoint; toward: FloorplanPoint; layout: ArchitecturalDimensionLayout }
>()
for (const { layout } of entries) {
extensionLines.set(pointKey(layout.dimensionStart), [
layout.extensionStart,
layout.extensionStartTip,
])
extensionLines.set(pointKey(layout.dimensionEnd), [layout.extensionEnd, layout.extensionEndTip])
terminators.set(pointKey(layout.dimensionStart), {
point: layout.dimensionStart,
toward: layout.dimensionEnd,
layout,
})
terminators.set(pointKey(layout.dimensionEnd), {
point: layout.dimensionEnd,
toward: layout.dimensionStart,
layout,
})
}
const stroke = geometry.stroke ?? '#334155'
for (const [start, end] of extensionLines.values())
drawDimensionLine(doc, start, end, stroke, context)
for (const { layout } of entries) {
drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
}
for (const terminator of terminators.values()) {
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
terminator.point,
terminator.toward,
terminator.layout,
stroke,
context,
)
}
for (const { dimension, layout } of entries)
drawDimensionText(doc, dimension, layout, stroke, context)
}
function drawDimensionLayout(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
layout: ArchitecturalDimensionLayout,
context: RenderContext,
): void {
const stroke = geometry.stroke ?? '#334155'
drawDimensionLine(doc, layout.extensionStart, layout.extensionStartTip, stroke, context)
drawDimensionLine(doc, layout.extensionEnd, layout.extensionEndTip, stroke, context)
drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
layout.dimensionStart,
layout.dimensionEnd,
layout,
stroke,
context,
)
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
layout.dimensionEnd,
layout.dimensionStart,
layout,
stroke,
context,
)
drawDimensionText(doc, geometry, layout, stroke, context)
}
function drawDimensionLine(
doc: FloorplanPdfDocument,
start: FloorplanPoint,
end: FloorplanPoint,
stroke: string,
context: RenderContext,
widthPt = DIMENSION_LINE_WIDTH_PT,
): void {
doc.raw
.save()
.strokeColor(stroke)
.strokeOpacity(1)
.lineCap('butt')
.lineWidth(widthPt * context.unitsPerPoint)
.moveTo(start[0], start[1])
.lineTo(end[0], end[1])
.stroke()
.restore()
}
function drawDimensionTerminator(
doc: FloorplanPdfDocument,
terminator: NonNullable<DimensionGeometry['terminator']>,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: ArchitecturalDimensionLayout,
stroke: string,
context: RenderContext,
): void {
const direction = normalized(point, toward)
if (!direction) return
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
if (terminator === 'dot') {
doc.raw
.save()
.fillColor(stroke)
.circle(point[0], point[1], tickHalfLength * 0.45)
.fill()
.restore()
return
}
if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
const base = addScaled(point, direction, tickHalfLength * 1.7)
const normal: FloorplanPoint = [-direction[1], direction[0]]
const wing = tickHalfLength * 0.65
const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
if (terminator === 'filled-arrow') {
doc.raw
.save()
.fillColor(stroke)
.polygon([point[0], point[1]], [left[0], left[1]], [right[0], right[1]])
.fill()
.restore()
return
}
drawDimensionLine(doc, point, left, stroke, context, DIMENSION_TICK_WIDTH_PT)
drawDimensionLine(doc, point, right, stroke, context, DIMENSION_TICK_WIDTH_PT)
return
}
const [tickX, tickY] = layout.tickHalfVector
drawDimensionLine(
doc,
[point[0] - tickX, point[1] - tickY],
[point[0] + tickX, point[1] + tickY],
stroke,
context,
DIMENSION_TICK_WIDTH_PT,
)
}
function drawDimensionText(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
layout: ArchitecturalDimensionLayout,
stroke: string,
context: RenderContext,
): void {
const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * context.unitsPerPoint
const y =
geometry.textPosition === 'centered'
? fontSize * 0.35
: -DIMENSION_BASELINE_OFFSET_PT * context.unitsPerPoint
const raw = doc.raw
const shift = nextAnnotationLabelShift(context)
raw
.save()
.translate(layout.labelPoint[0], layout.labelPoint[1])
.rotate(layout.labelAngleDeg)
.translate(shift[0], shift[1])
drawNativeText(doc, {
anchor: 'middle',
fill: stroke,
fontFamily: DIMENSION_TEXT_FONT_FAMILY,
fontSize,
fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
text: geometry.text,
x: 0,
y,
})
raw.restore()
}
function drawDimensionLabel(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'dimension-label' }>,
context: RenderContext,
): void {
const unitsPerPoint = context.unitsPerPoint
const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * unitsPerPoint
const padX = 6 * unitsPerPoint
const padY = 3 * unitsPerPoint
const textWidth = geometry.text.length * 6.2 * unitsPerPoint
const plateWidth = textWidth + padX * 2
const plateHeight = fontSize + padY * 2
const angle = resolveFloorplanLabelAngle(
geometry.angle,
context.sceneRotationDeg,
geometry.screenUpright,
)
const offset = -(geometry.offsetPx ?? 0) * unitsPerPoint
const shift = nextAnnotationLabelShift(context)
const raw = doc.raw
raw
.save()
.translate(geometry.cx, geometry.cy)
.rotate(angle)
.translate(shift[0], shift[1] + offset)
raw
.fillColor('#ffffff')
.fillOpacity(0.92)
.roundedRect(-plateWidth / 2, -plateHeight / 2, plateWidth, plateHeight, 3 * unitsPerPoint)
.fill()
drawNativeText(doc, {
anchor: 'middle',
fill: '#111827',
fontFamily: DIMENSION_TEXT_FONT_FAMILY,
fontSize,
fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
text: geometry.text,
x: 0,
y: 0,
})
raw.restore()
}
function drawEqualSpacingBadge(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'equal-spacing-badge' }>,
context: RenderContext,
): void {
const fontSize = 7 * context.unitsPerPoint
const width = Math.max(
14 * context.unitsPerPoint,
geometry.text.length * fontSize * 0.62 + 6 * context.unitsPerPoint,
)
const height = 12 * context.unitsPerPoint
const angle = resolveFloorplanLabelAngle(geometry.angle, context.sceneRotationDeg)
const raw = doc.raw
raw.save().translate(geometry.point[0], geometry.point[1]).rotate(angle)
raw
.fillColor('#ffffff')
.roundedRect(-width / 2, -height / 2, width, height, height / 2)
.fill()
drawNativeText(doc, {
anchor: 'middle',
fill: '#334155',
fontFamily: 'Courier',
fontSize,
fontWeight: 600,
text: geometry.text,
x: 0,
y: 0,
})
raw.restore()
}
function drawNativeText(
doc: FloorplanPdfDocument,
options: {
angleDeg?: number
anchor: 'start' | 'middle' | 'end'
fill: string
fontFamily?: string
fontSize: number
fontWeight?: number | string
opacity?: number
text: string
x: number
y: number
},
): void {
const raw = doc.raw
const normalizedFamily = options.fontFamily?.toLocaleLowerCase() ?? ''
const family =
normalizedFamily.includes('mono') || normalizedFamily.includes('courier')
? 'Courier'
: 'Helvetica'
const numericWeight = Number.parseInt(String(options.fontWeight ?? 400), 10)
const bold =
options.fontWeight === 'bold' || (Number.isFinite(numericWeight) && numericWeight >= 500)
raw.save().translate(options.x, options.y)
if (options.angleDeg) raw.rotate(options.angleDeg)
raw
.font(bold ? `${family}-Bold` : family)
.fontSize(options.fontSize)
.fillColor(options.fill)
raw.fillOpacity(options.opacity ?? 1)
const width = raw.widthOfString(options.text, { lineBreak: false })
const x = options.anchor === 'middle' ? -width / 2 : options.anchor === 'end' ? -width : 0
raw.text(options.text, x, -options.fontSize * 0.42, { lineBreak: false })
raw.restore()
}
async function drawImage(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'image' }>,
): Promise<void> {
try {
const url = await loadAssetUrl(geometry.url)
if (!url) return
const response = await fetch(url)
if (!response.ok) return
const dataUrl = await blobToDataUrl(await response.blob())
const raw = doc.raw
raw.save().translate(geometry.center[0], geometry.center[1])
if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI)
raw.opacity(geometry.opacity ?? 1)
const options =
geometry.preserveAspectRatio === 'none'
? { width: geometry.width, height: geometry.height }
: {
fit: [geometry.width, geometry.height] as [number, number],
align: 'center' as const,
valign: 'center' as const,
}
raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options)
raw.restore()
} catch {
return
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(String(reader.result))
reader.readAsDataURL(blob)
})
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
}
function addScaled(
point: FloorplanPoint,
direction: FloorplanPoint,
distance: number,
): FloorplanPoint {
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
}
function pointKey(point: FloorplanPoint): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
}
function nextAnnotationLabelShift(context: RenderContext): FloorplanPoint {
const shift = context.annotationLabelShifts[context.annotationLabelShiftIndex] ?? [0, 0]
context.annotationLabelShiftIndex += 1
return shift
}
@@ -3,9 +3,8 @@ import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
// Baseline rotation (deg) that orients the plan-local scene "north up" on
// screen. The on-screen floor-plan scene `<g>` is rotated by
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
// export mirrors the aligned-to-north case (user offset 0) so an export points
// the same way as the app's north-aligned view.
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export
// uses the same user rotation so its orientation matches the live plan.
//
// North is world Z: with a 0 baseline a rotation-0 reference image (top =
// Z) reads upright in the north-aligned view, and "align north" maps to a
@@ -72,6 +72,11 @@ describe('linear measurements', () => {
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
})
test('formats metric measurements in whole millimeters', () => {
expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
})
test('formats imperial measurements as feet and inches', () => {
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
+12 -1
View File
@@ -1,6 +1,7 @@
import type { MeasurementPoint } from '@pascal-app/core'
export type LinearUnit = 'metric' | 'imperial'
export type MetricNotation = 'meters' | 'millimeters'
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
@@ -173,7 +174,11 @@ export function formatVolumeLabel(
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
}
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
export function formatLinearMeasurement(
meters: number,
unit: LinearUnit,
metricNotation: MetricNotation = 'meters',
): string {
if (!Number.isFinite(meters)) return '--'
const absoluteMeters = Math.abs(meters)
@@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin
return `${sign}${wholeFeet}'${inches}"`
}
if (metricNotation === 'millimeters') {
const roundedMillimeters = Math.round(absoluteMeters * 1000)
const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
return `${sign}${roundedMillimeters}mm`
}
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import { normalizeAnnotationLayoutOverrides, normalizeDrawingType } from './use-drawing-view'
describe('normalizeDrawingType', () => {
test('restores every persistent construction drawing type', () => {
expect(normalizeDrawingType('floor-plan')).toBe('floor-plan')
expect(normalizeDrawingType('foundation-plan')).toBe('foundation-plan')
expect(normalizeDrawingType('reflected-ceiling-plan')).toBe('reflected-ceiling-plan')
expect(normalizeDrawingType('roof-plan')).toBe('roof-plan')
expect(normalizeDrawingType('site-plan')).toBe('site-plan')
})
test('falls back to the floor plan for stale persisted values', () => {
expect(normalizeDrawingType('unknown')).toBe('floor-plan')
expect(normalizeDrawingType(null)).toBe('floor-plan')
})
})
describe('normalizeAnnotationLayoutOverrides', () => {
test('keeps finite pinned drawing-view annotation offsets', () => {
expect(
normalizeAnnotationLayoutOverrides({
a: { dx: 1.25, dy: -0.5, pinned: true },
stale: { dx: Number.NaN, dy: 0, pinned: true },
unpinned: { dx: 1, dy: 2, pinned: false },
}),
).toEqual({
a: { dx: 1.25, dy: -0.5, pinned: true },
})
})
})
@@ -0,0 +1,130 @@
'use client'
import {
CONSTRUCTION_DRAWING_TYPES,
type ConstructionDrawingType,
type DrawingSheetScale,
} from '@pascal-app/core'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const DRAWING_TYPE_OPTIONS = [
{ id: 'floor-plan', label: 'Floor plan' },
{ id: 'foundation-plan', label: 'Foundation plan' },
{ id: 'reflected-ceiling-plan', label: 'Reflected ceiling plan' },
{ id: 'roof-plan', label: 'Roof plan' },
{ id: 'site-plan', label: 'Site plan' },
] as const satisfies readonly { id: ConstructionDrawingType; label: string }[]
export const DRAWING_SCALE_OPTIONS = [
{ id: '1:20', label: '1:20' },
{ id: '1:25', label: '1:25' },
{ id: '1:50', label: '1:50' },
{ id: '1:75', label: '1:75' },
{ id: '1:100', label: '1:100' },
{ id: '1/8"=1\'-0"', label: '1/8" = 1\'-0"' },
{ id: '1/4"=1\'-0"', label: '1/4" = 1\'-0"' },
{ id: '1/2"=1\'-0"', label: '1/2" = 1\'-0"' },
{ id: '1"=1\'-0"', label: '1" = 1\'-0"' },
] as const satisfies readonly { id: DrawingSheetScale; label: string }[]
export type DrawingAnnotationLayoutOverride = {
dx: number
dy: number
pinned: true
}
export type DrawingAnnotationLayoutOverrides = Record<string, DrawingAnnotationLayoutOverride>
type DrawingViewState = {
drawingType: ConstructionDrawingType
drawingScale: DrawingSheetScale
annotationLayoutOverrides: DrawingAnnotationLayoutOverrides
setDrawingType: (drawingType: ConstructionDrawingType) => void
setDrawingScale: (drawingScale: DrawingSheetScale) => void
setAnnotationLayoutOverride: (
id: string,
override: DrawingAnnotationLayoutOverride | null,
) => void
}
export function normalizeDrawingType(value: unknown): ConstructionDrawingType {
if (typeof value !== 'string') return 'floor-plan'
for (const drawingType of CONSTRUCTION_DRAWING_TYPES) {
if (drawingType === value) return drawingType
}
return 'floor-plan'
}
export function normalizeDrawingScale(value: unknown): DrawingSheetScale {
if (typeof value !== 'string') return '1/4"=1\'-0"'
for (const option of DRAWING_SCALE_OPTIONS) {
if (option.id === value) return option.id
}
return '1/4"=1\'-0"'
}
export function normalizeAnnotationLayoutOverrides(
value: unknown,
): DrawingAnnotationLayoutOverrides {
if (!value || typeof value !== 'object') return {}
const out: DrawingAnnotationLayoutOverrides = {}
for (const [id, raw] of Object.entries(value)) {
if (!id || !raw || typeof raw !== 'object') continue
const dx = (raw as { dx?: unknown }).dx
const dy = (raw as { dy?: unknown }).dy
const pinned = (raw as { pinned?: unknown }).pinned
if (
typeof dx === 'number' &&
Number.isFinite(dx) &&
typeof dy === 'number' &&
Number.isFinite(dy) &&
pinned === true
) {
out[id] = { dx, dy, pinned: true }
}
}
return out
}
const useDrawingView = create<DrawingViewState>()(
persist(
(set) => ({
drawingType: 'floor-plan',
drawingScale: '1/4"=1\'-0"',
annotationLayoutOverrides: {},
setDrawingType: (drawingType) => set({ drawingType }),
setDrawingScale: (drawingScale) => set({ drawingScale }),
setAnnotationLayoutOverride: (id, override) =>
set((state) => {
const next = { ...state.annotationLayoutOverrides }
if (override) next[id] = override
else delete next[id]
return { annotationLayoutOverrides: next }
}),
}),
{
name: 'pascal-floorplan-drawing-view',
merge: (persistedState, currentState) => ({
...currentState,
drawingType: normalizeDrawingType(
(persistedState as { drawingType?: unknown } | undefined)?.drawingType,
),
drawingScale: normalizeDrawingScale(
(persistedState as { drawingScale?: unknown } | undefined)?.drawingScale,
),
annotationLayoutOverrides: normalizeAnnotationLayoutOverrides(
(persistedState as { annotationLayoutOverrides?: unknown } | undefined)
?.annotationLayoutOverrides,
),
}),
partialize: (state) => ({
drawingType: state.drawingType,
drawingScale: state.drawingScale,
annotationLayoutOverrides: state.annotationLayoutOverrides,
}),
},
),
)
export default useDrawingView
+5 -1
View File
@@ -140,7 +140,7 @@ export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
// Structure mode tools (building elements)
export type StructureTool =
type BuiltInStructureTool =
| 'wall'
| 'fence'
| 'room'
@@ -149,6 +149,7 @@ export type StructureTool =
| 'ceiling'
| 'roof'
| 'column'
| 'structural-grid'
| 'elevator'
| 'stair'
| 'item'
@@ -178,6 +179,9 @@ export type StructureTool =
| 'pipe-fitting'
| 'pipe-trap'
/** Registry node kinds are valid build tools without central union edits. */
export type StructureTool = BuiltInStructureTool | (string & {})
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item' | 'cabinet'
@@ -0,0 +1,60 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
type FloorplanAnnotationCategory,
type FloorplanAnnotationVisibility,
normalizeFloorplanAnnotationVisibility,
} from '../lib/floorplan/annotation-visibility'
import {
DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
type FloorplanWallDimensionReference,
normalizeFloorplanWallDimensionReference,
} from '../lib/floorplan/floorplan-extension'
type FloorplanAnnotationVisibilityState = {
visibility: FloorplanAnnotationVisibility
wallDimensionReference: FloorplanWallDimensionReference
setCategory: (category: FloorplanAnnotationCategory, visible: boolean) => void
setWallDimensionReference: (reference: FloorplanWallDimensionReference) => void
reset: () => void
}
const useFloorplanAnnotationVisibility = create<FloorplanAnnotationVisibilityState>()(
persist(
(set) => ({
visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY },
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
setCategory: (category, visible) =>
set((state) => ({ visibility: { ...state.visibility, [category]: visible } })),
setWallDimensionReference: (wallDimensionReference) => set({ wallDimensionReference }),
reset: () =>
set({
visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY },
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
}),
}),
{
name: 'pascal-floorplan-annotation-visibility',
merge: (persistedState, currentState) => ({
...currentState,
visibility: normalizeFloorplanAnnotationVisibility(
(persistedState as { visibility?: unknown } | undefined)?.visibility,
),
wallDimensionReference: normalizeFloorplanWallDimensionReference(
(persistedState as { wallDimensionReference?: unknown } | undefined)
?.wallDimensionReference,
),
}),
partialize: (state) =>
({
visibility: state.visibility,
wallDimensionReference: state.wallDimensionReference,
}) as FloorplanAnnotationVisibilityState,
},
),
)
export default useFloorplanAnnotationVisibility
@@ -0,0 +1,54 @@
'use client'
import { create } from 'zustand'
export type FloorplanPreflightIssueKind =
| 'unresolved-collision'
| 'short-unreadable-segment'
| 'plan-geometry-conflict'
| 'dimension-completeness'
| 'clearance-advisory'
| 'module-advisory'
| 'sheet-content'
export type FloorplanPreflightIssue = {
id: string
kind: FloorplanPreflightIssueKind
severity: 'info' | 'warning'
message: string
}
type FloorplanPreflightState = {
issues: FloorplanPreflightIssue[]
layoutIssues: FloorplanPreflightIssue[]
auditIssues: FloorplanPreflightIssue[]
clearanceChecksEnabled: boolean
moduleChecksEnabled: boolean
setIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setAuditIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setClearanceChecksEnabled: (enabled: boolean) => void
setModuleChecksEnabled: (enabled: boolean) => void
reset: () => void
}
export const useFloorplanPreflight = create<FloorplanPreflightState>((set) => ({
issues: [],
layoutIssues: [],
auditIssues: [],
clearanceChecksEnabled: false,
moduleChecksEnabled: false,
setIssues: (issues) =>
set((state) => ({ layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] })),
setAuditIssues: (issues) =>
set((state) => ({ auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] })),
setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }),
setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }),
reset: () =>
set((state) =>
state.layoutIssues.length === 0
? state
: { layoutIssues: [], issues: [...state.auditIssues] },
),
}))
export default useFloorplanPreflight
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { buildingDefinition } from './definition'
describe('buildingDefinition', () => {
test('tracks drawing-sheet child support in the schema version', () => {
expect(buildingDefinition.kind).toBe('building')
expect(buildingDefinition.schemaVersion).toBe(2)
expect(
buildingDefinition.schema.safeParse({
id: 'building_default',
type: 'building',
...buildingDefinition.defaults(),
children: ['level_main', 'drawing-sheet_a101'],
}).success,
).toBe(true)
})
})
+1 -1
View File
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
*/
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building',
schemaVersion: 1,
schemaVersion: 2,
schema: BuildingNode,
category: 'site',
+25 -2
View File
@@ -1,10 +1,15 @@
import {
ColumnNode as ColumnNodeSchema,
type ColumnNode as ColumnNodeType,
type GroupMoveSnapArgs,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan'
import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnFloorplanMoveTarget } from './floorplan-move'
import { columnPaint } from './paint'
@@ -295,6 +300,18 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
return handles
}
function resolveColumnStructuralGridMoveSnap({
candidatePosition,
nodes,
levelId,
}: GroupMoveSnapArgs): [number, number, number] | null {
const snap = resolveStructuralGridSnap(
[candidatePosition[0], candidatePosition[2]],
collectStructuralGridAxes(nodes, levelId),
)
return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null
}
/**
* Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in
@@ -334,7 +351,11 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
// mode-driven snapping the overhaul standardised). 2D move keeps using
// `floorplanMoveTarget`, which wins the 2D move dispatch.
movable: { axes: ['x', 'z'], gridSnap: true },
movable: {
axes: ['x', 'z'],
gridSnap: true,
groupMoveSnap: resolveColumnStructuralGridMoveSnap,
},
slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint,
// Slab elevation lift via the generic `<FloorElevationSystem>` + the
@@ -374,6 +395,8 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
{ key: 'Left click', label: 'Place column' },
{ key: 'Esc', label: 'Cancel' },
],
computeFloorplanLevelData: computeColumnFloorplanLevelData,
floorplanDependsOnSiblings: true,
floorplan: buildColumnFloorplan,
// 2D body move routes through this kind-specific target so the column
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
+11 -2
View File
@@ -20,6 +20,10 @@ import {
type WallPlanPoint,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
/**
* 2D floor-plan move handler for column. Columns need the same footprint-edge
@@ -65,10 +69,15 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
candidates,
{ applySnap: isMagneticSnapActive() },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(snapped, collectStructuralGridAxes(nodes, node.parentId))
: null
const coordinated = structuralSnap?.point ?? snapped
const next: [number, number, number] = [coordinated[0], originalPosition[1], coordinated[1]]
lastPosition = next
const snapKey = `${snapped[0]},${snapped[1]}`
const snapKey = `${coordinated[0]},${coordinated[1]}`
if (snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import { ColumnNode, type GeometryContext, StructuralGridNode } from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
const context = {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
} satisfies GeometryContext
describe('buildColumnFloorplan', () => {
test('marks the structural center of the column footprint', () => {
const column = ColumnNode.parse({
id: 'column_main',
parentId: 'level_main',
position: [2, 0, 3],
crossSection: 'square',
width: 0.4,
depth: 0.4,
})
const geometry = buildColumnFloorplan(column, context)
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
expect(geometry.children[0]?.kind).toBe('polygon')
expect(readFloorplanGeometryMetadata(geometry.children[0]!)).toMatchObject({
annotationObstacle: 'bounds',
})
expect(geometry.children.filter((child) => child.kind === 'line')).toEqual([
expect.objectContaining({
x1: 1.91,
y1: 2.91,
x2: 2.09,
y2: 3.09,
pointerEvents: 'none',
}),
expect.objectContaining({
x1: 1.91,
y1: 3.09,
x2: 2.09,
y2: 2.91,
pointerEvents: 'none',
}),
])
expect(
geometry.children
.filter((child) => child.kind === 'line')
.every((child) => readFloorplanGeometryMetadata(child).annotationRole === 'column-center'),
).toBe(true)
})
test('labels a column with its associative structural-grid reference', () => {
const column = ColumnNode.parse({
id: 'column_main',
parentId: 'level_main',
position: [2, 0, 3],
crossSection: 'square',
width: 0.4,
depth: 0.4,
})
const vertical = StructuralGridNode.parse({
id: 'structural-grid_2',
parentId: 'level_main',
start: [2, 0],
end: [2, 6],
label: '2',
})
const horizontal = StructuralGridNode.parse({
id: 'structural-grid_b',
parentId: 'level_main',
start: [0, 3],
end: [6, 3],
label: 'B',
})
const levelData = computeColumnFloorplanLevelData({
siblings: [column],
nodes: {
[column.id]: column,
[vertical.id]: vertical,
[horizontal.id]: horizontal,
},
})
const geometry = buildColumnFloorplan(column, { ...context, levelData })
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
const label = geometry.children.find((child) => child.kind === 'text' && child.text === 'B-2')
expect(label).toMatchObject({ kind: 'text', text: 'B-2', upright: true })
expect(label && readFloorplanGeometryMetadata(label).annotationRole).toBe('column-center')
})
})
+83 -5
View File
@@ -1,9 +1,16 @@
import type {
AnyNode,
ColumnNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
StructuralGridNode,
} from '@pascal-app/core'
import { floorplanGeometryMetadata } from '@pascal-app/editor'
import {
collectStructuralGridAxes,
resolveStructuralGridReference,
} from '../structural-grid/coordination'
import type { ColumnResizePayload } from './floorplan-affordances'
// Offsets for the floor-plan selection arrows. Resize chevrons hug the
@@ -11,6 +18,8 @@ import type { ColumnResizePayload } from './floorplan-affordances'
// further out so it doesn't crowd the resize arrows.
const RESIZE_ARROW_OFFSET = 0.12
const ROTATE_ARROW_CORNER_OFFSET = 0.22
const GRID_REFERENCE_OFFSET = 0.16
const GRID_REFERENCE_FONT_SIZE = 0.13
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
'round',
@@ -18,6 +27,22 @@ const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
'sixteen-sided',
])
export type ColumnFloorplanLevelData = {
structuralGrids: StructuralGridNode[]
}
export function computeColumnFloorplanLevelData({
siblings,
nodes,
}: {
siblings: readonly ColumnNode[]
nodes: Record<string, AnyNode>
}): ColumnFloorplanLevelData {
return {
structuralGrids: collectStructuralGridAxes(nodes, siblings[0]?.parentId),
}
}
/**
* Stage C floor-plan builder for column. Inlined from the legacy
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
@@ -34,8 +59,8 @@ export function buildColumnFloorplan(
node: ColumnNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) return null
const points = getColumnFloorplanFootprint(node)
if (points.length < 3) return null
const view = ctx.viewState
const palette = view?.palette
@@ -46,8 +71,6 @@ export function buildColumnFloorplan(
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
@@ -56,8 +79,60 @@ export function buildColumnFloorplan(
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92,
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
]
const { halfX, halfZ } = columnPlanHalfExtents(node)
const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45))
const centerX = node.position[0]
const centerZ = node.position[2]
children.push(
{
kind: 'line',
x1: centerX - centerMarkHalf,
y1: centerZ - centerMarkHalf,
x2: centerX + centerMarkHalf,
y2: centerZ + centerMarkHalf,
stroke,
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
},
{
kind: 'line',
x1: centerX - centerMarkHalf,
y1: centerZ + centerMarkHalf,
x2: centerX + centerMarkHalf,
y2: centerZ - centerMarkHalf,
stroke,
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
},
)
const levelData = ctx.levelData as ColumnFloorplanLevelData | undefined
const gridReference = resolveStructuralGridReference(
[centerX, centerZ],
levelData?.structuralGrids ?? [],
)
if (gridReference) {
children.push({
kind: 'text',
x: centerX,
y: centerZ + halfZ + GRID_REFERENCE_OFFSET,
text: gridReference,
fontSize: GRID_REFERENCE_FONT_SIZE,
fill: stroke,
fontWeight: 700,
textAnchor: 'middle',
dominantBaseline: 'middle',
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
})
}
// Hatch overlay on selected — same `<defs>` pattern as the wall.
if (isSelected && palette) {
@@ -146,7 +221,6 @@ export function buildColumnFloorplan(
// Rotate-arrow at the +X / +Z corner — matches the 3D
// `columnRotateHandle` corner placement so users see the rotation
// affordance in the same quadrant across views.
const { halfX, halfZ } = columnPlanHalfExtents(node)
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
@@ -163,6 +237,10 @@ export function buildColumnFloorplan(
return { kind: 'group', children }
}
export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] {
return getColumnPlanFootprint(node).map((point) => [point.x, point.y])
}
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
type PlanPoint = { x: number; y: number }
+28 -3
View File
@@ -32,6 +32,10 @@ import {
stopPlacementCommitPropagation,
subscribeFloorPlacementClicks,
} from '../shared/floor-placement'
import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
import { ColumnPreview } from './renderer'
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
@@ -87,7 +91,7 @@ const ColumnTool = () => {
setCursorVisible(true)
}
const { position, guides } = resolveAlignedFloorPlacement({
const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({
node: previewNode,
rawX: event.localPosition[0],
rawZ: event.localPosition[2],
@@ -97,7 +101,18 @@ const ColumnTool = () => {
applyAlignmentSnap: isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(),
})
useAlignmentGuides.getState().set(guides)
const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(
[alignedPosition[0], alignedPosition[2]],
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
)
: null
const position: [number, number, number] = structuralSnap
? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]]
: alignedPosition
if (structuralSnap) useAlignmentGuides.getState().clear()
else useAlignmentGuides.getState().set(guides)
const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
@@ -134,7 +149,7 @@ const ColumnTool = () => {
}
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
const position =
const fallbackPosition =
lastCursorRef.current ??
getLevelLocalSnappedPosition(
activeLevelId,
@@ -142,6 +157,16 @@ const ColumnTool = () => {
useEditor.getState().gridSnapStep,
!isGridSnapActive(),
)
const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(
[fallbackPosition[0], fallbackPosition[2]],
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
)
: null
const position: [number, number, number] = structuralSnap
? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]]
: fallbackPosition
const column = ColumnNode.parse({
...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position),
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { constructionDimensionDefinition } from './definition'
describe('constructionDimensionDefinition', () => {
test('registers a selectable floor-plan construction annotation', () => {
expect(constructionDimensionDefinition.kind).toBe('construction-dimension')
expect(constructionDimensionDefinition.category).toBe('analysis')
expect(constructionDimensionDefinition.bake).toBe('strip')
expect(constructionDimensionDefinition.schemaVersion).toBe(7)
expect(constructionDimensionDefinition.dirtyTracking).toBe(false)
expect(constructionDimensionDefinition.capabilities).toMatchObject({
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
})
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
'move-construction-dimension-baseline',
)
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
'move-construction-dimension-witness',
)
})
test('produces schema-valid defaults', () => {
expect(
constructionDimensionDefinition.schema.safeParse({
id: 'construction-dimension_default',
type: 'construction-dimension',
...constructionDimensionDefinition.defaults(),
}).success,
).toBe(true)
})
})
@@ -0,0 +1,91 @@
import { measurementAnchorReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
import { buildConstructionDimensionFloorplan } from './floorplan'
import {
moveConstructionDimensionBaselineAffordance,
moveConstructionDimensionWitnessAffordance,
} from './floorplan-affordances'
import { constructionDimensionParametrics } from './parametrics'
import { ConstructionDimensionNode } from './schema'
export const constructionDimensionDefinition: NodeDefinition<typeof ConstructionDimensionNode> = {
kind: 'construction-dimension',
bake: 'strip',
schemaVersion: 7,
schema: ConstructionDimensionNode,
category: 'analysis',
extensions: {
'pascal:editor/floorplan': {
tool: () => import('./floorplan-tool'),
resolveForDrawing: resolveConstructionDimensionForDrawing,
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
},
snapProfile: 'item',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 0.6], direction: [1, 0] },
chainMode: 'point-to-point',
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
},
dirtyTracking: false,
parametrics: constructionDimensionParametrics,
floorplan: buildConstructionDimensionFloorplan,
floorplanDependencies: (node) => [
...measurementAnchorReferenceNodeIds(node.anchors),
...(node.controllingDimensionId ? [node.controllingDimensionId] : []),
],
floorplanAffordances: {
'move-construction-dimension-baseline': moveConstructionDimensionBaselineAffordance,
'move-construction-dimension-witness': moveConstructionDimensionWitnessAffordance,
},
toolHints: [
{ key: 'Left click', label: 'Pick witness point' },
{ key: 'Enter', label: 'Finish multi-point witnesses' },
{ key: 'Left click', label: 'Place dimension line when needed' },
{ key: 'Backspace', label: 'Remove last witness' },
{ key: 'Esc', label: 'Step back or cancel' },
],
presentation: {
label: 'Construction Dimension',
description: 'Associative linear, curved, circular, angular, or coordinate plan dimension.',
icon: { kind: 'iconify', name: 'lucide:ruler-dimension-line' },
hidden: true,
actionMenu: false,
},
mcp: {
description:
'An associative construction dimension with linear, curved, circular, angular, and coordinate modes, semantic witness anchors, document notation overrides, and coordinated plan-view presentation.',
},
}
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, ConstructionDimensionNode } from '@pascal-app/core'
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
const foundation = ConstructionDimensionNode.parse({
id: 'construction-dimension_foundation',
drawingType: 'foundation-plan',
anchors: [
[0, 0, 0],
[6, 0, 0],
],
baseline: { origin: [0, 2], direction: [1, 0] },
})
const resolve = (
node: ConstructionDimensionNode,
nodes: Record<string, AnyNode>,
drawingType: 'floor-plan' | 'foundation-plan',
) => resolveConstructionDimensionForDrawing({ node, nodes, drawingType })
describe('resolveConstructionDimensionForDrawing', () => {
test('omits a dimension outside its primary drawing by default', () => {
expect(resolve(foundation, { [foundation.id]: foundation }, 'floor-plan')).toBeNull()
expect(resolve(foundation, { [foundation.id]: foundation }, 'foundation-plan')).toBe(foundation)
})
test('applies view-specific suppressed segments without changing physical anchors', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
drawingOverrides: [
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1],
},
],
})
const resolved = resolve(node, { [node.id]: node }, 'floor-plan')
expect(resolved).toMatchObject({
id: node.id,
anchors: node.anchors,
metadata: { suppressedDimensionSegmentIndexes: [1] },
})
expect(node.metadata).toEqual({})
})
test('derives linked floor-plan geometry from a controlling foundation dimension', () => {
const floor = ConstructionDimensionNode.parse({
id: 'construction-dimension_floor',
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
controllingDimensionId: foundation.id,
anchors: [
[1, 0, 1],
[2, 0, 1],
],
})
const nodes = { [floor.id]: floor, [foundation.id]: foundation } as Record<string, AnyNode>
const resolved = resolve(floor, nodes, 'floor-plan')
expect(resolved).toMatchObject({
id: floor.id,
anchors: foundation.anchors,
baseline: foundation.baseline,
metadata: { drawingCoordinationLocked: true },
})
})
test('marks a missing foundation controller as unlinked', () => {
const floor = ConstructionDimensionNode.parse({
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
controllingDimensionId: 'construction-dimension_missing',
prefix: 'TYP · ',
})
expect(resolve(floor, { [floor.id]: floor }, 'floor-plan')).toMatchObject({
prefix: 'UNLINKED CONTROL · TYP · ',
})
})
})
@@ -0,0 +1,76 @@
import {
type AnyNode,
type ConstructionDimensionNode,
type ConstructionDrawingType,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
} from '@pascal-app/core'
export function resolveConstructionDimensionForDrawing(args: {
node: ConstructionDimensionNode
nodes: Record<string, AnyNode>
drawingType: ConstructionDrawingType
}): ConstructionDimensionNode | null {
const { node, nodes, drawingType } = args
const presentation = resolveConstructionDimensionDrawingPresentation(node, drawingType)
if (presentation === 'omit') return null
if (presentation === 'shown') return applyDrawingOverride(node, drawingType)
const controller = node.controllingDimensionId ? nodes[node.controllingDimensionId] : undefined
if (
controller?.type !== 'construction-dimension' ||
controller.id === node.id ||
controller.drawingType !== 'foundation-plan'
) {
return {
...node,
metadata: lockedMetadata(node),
prefix: `UNLINKED CONTROL · ${node.prefix}`,
}
}
return resolveControlledDimension(node, controller)
}
function resolveControlledDimension(
node: ConstructionDimensionNode,
controller: ConstructionDimensionNode,
): ConstructionDimensionNode {
const overridden = applyDrawingOverride(node, 'floor-plan')
return {
...overridden,
metadata: lockedMetadata(overridden),
anchors: controller.anchors,
baseline: controller.baseline,
chainMode: controller.chainMode,
mode: controller.mode,
showCenterMark: controller.showCenterMark,
}
}
function applyDrawingOverride(
node: ConstructionDimensionNode,
drawingType: ConstructionDrawingType,
): ConstructionDimensionNode {
const override = resolveConstructionDimensionDrawingOverride(node, drawingType)
if (!override || override.suppressedSegmentIndexes.length === 0) return node
return {
...node,
metadata: {
...(typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata)
? node.metadata
: {}),
suppressedDimensionSegmentIndexes: override.suppressedSegmentIndexes,
},
}
}
function lockedMetadata(node: ConstructionDimensionNode): ConstructionDimensionNode['metadata'] {
const metadata =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? node.metadata
: {}
return { ...metadata, drawingCoordinationLocked: true }
}
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
ConstructionDimensionNode,
nodeRegistry,
registerNode,
useLiveNodeOverrides,
useScene,
WallNode,
} from '@pascal-app/core'
import { wallDefinition } from '../wall/definition'
import { moveConstructionDimensionWitnessAffordance } from './floorplan-affordances'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
function seedScene() {
const levelId = 'level_construction-dimension-affordance' as AnyNodeId
const wall = WallNode.parse({
id: 'wall_dimension-target',
start: [0, 0],
end: [4, 0],
parentId: levelId,
})
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_drag-witness',
parentId: levelId,
anchors: [
{
kind: 'feature',
reference: {
nodeId: wall.id,
featureId: 'wall:centerline',
parameters: { t: 0.25 },
},
fallback: [1, 0, 0],
},
[4, 0, 0],
],
})
const level = {
id: levelId,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
level: 0,
parentId: null,
children: [wall.id, dimension.id],
} as unknown as AnyNode
const nodes = { [levelId]: level, [wall.id]: wall, [dimension.id]: dimension } as Record<
AnyNodeId,
AnyNode
>
useScene.setState({ nodes: nodes as never })
return { dimension, nodes, wall }
}
describe('moveConstructionDimensionWitnessAffordance', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(wallDefinition)
useLiveNodeOverrides.getState().clearAll()
})
afterEach(() => {
useLiveNodeOverrides.getState().clearAll()
nodeRegistry._reset()
})
test('reassociates a dragged witness to a nearby semantic wall feature', () => {
const { dimension, nodes, wall } = seedScene()
const session = moveConstructionDimensionWitnessAffordance.start({
node: dimension,
payload: { witnessIndex: 0 },
nodes,
initialPlanPoint: [1, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [3, 0.04], modifiers: MODIFIERS })
expect(session.canCommit()).toBe(true)
session.commit?.()
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
const anchor = updated.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(wall.id)
expect(anchor.reference.featureId).toMatch(/^wall:/)
expect(anchor.fallback[0]).toBeCloseTo(3)
}
})
test('detaches a dragged witness as an explicit free point when Alt bypasses association', () => {
const { dimension, nodes } = seedScene()
const session = moveConstructionDimensionWitnessAffordance.start({
node: dimension,
payload: { witnessIndex: 0 },
nodes,
initialPlanPoint: [1, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [3, 2], modifiers: { ...MODIFIERS, altKey: true } })
expect(session.canCommit()).toBe(true)
session.commit?.()
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
expect(updated.anchors[0]).toEqual([3, 0, 2])
})
})
@@ -0,0 +1,154 @@
import {
ConstructionDimensionNode,
type ConstructionDimensionNode as ConstructionDimensionNodeType,
type FloorplanAffordance,
type FloorplanAffordanceSession,
type MeasurementAnchor,
type MeasurementPoint,
resolveLevelId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
isGridSnapActive,
isMagneticSnapActive,
resolveSurfacePlanPointSnap,
useEditor,
} from '@pascal-app/editor'
import { matchMeasurementFeatureForNode, resolveMeasurementAnchor } from '../measurement/resolve'
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
function semanticWitnessAnchor(
point: MeasurementPoint,
wallIds: readonly string[],
nodes: Parameters<typeof resolveLevelId>[1],
maxDistance: number,
): MeasurementAnchor {
const matches = wallIds.flatMap((id) => {
const node = nodes[id]
if (!node) return []
const match = matchMeasurementFeatureForNode(
node,
(nodeId) => nodes[nodeId],
point,
maxDistance,
)
return match ? [{ match, node }] : []
})
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
if (!closest) return point
return {
kind: 'feature',
reference: {
nodeId: closest.node.id,
featureId: closest.match.feature.id,
parameters: closest.match.parameters,
},
fallback: closest.match.point,
}
}
function withRefreshedFallbacks(
node: ConstructionDimensionNodeType,
nodes: Parameters<typeof resolveLevelId>[1],
): ConstructionDimensionNodeType['anchors'] {
return node.anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const resolved = resolveMeasurementAnchor(anchor, (id) => nodes[id])
return { ...anchor, fallback: resolved.point }
})
}
export const moveConstructionDimensionWitnessAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
{
start({ node, nodes, payload }): FloorplanAffordanceSession {
const witnessIndex = (payload as { witnessIndex?: unknown }).witnessIndex
const originalAnchors = withRefreshedFallbacks(node, nodes)
const levelId = resolveLevelId(node, nodes)
let latest: ConstructionDimensionNodeType['anchors'] | null = null
if (!Number.isInteger(witnessIndex)) {
return {
affectedIds: [node.id],
apply() {},
canCommit: () => false,
}
}
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const forceFree = modifiers.altKey === true
const gridStep = !forceFree && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const fallbackPoint: [number, number] =
gridStep > 0
? [
Math.round(planPoint[0] / gridStep) * gridStep,
Math.round(planPoint[1] / gridStep) * gridStep,
]
: [planPoint[0], planPoint[1]]
const magnetic = !forceFree && isMagneticSnapActive()
const snapped = resolveSurfacePlanPointSnap({
rawPoint: [planPoint[0], planPoint[1]],
fallbackPoint,
excludeId: node.id,
levelId,
movingId: node.id,
nodes,
magnetic,
})
const point: MeasurementPoint = [snapped.point[0], 0, snapped.point[1]]
const nextAnchor = semanticWitnessAnchor(
point,
snapped.wallIds,
nodes,
magnetic ? SEMANTIC_FEATURE_SNAP_DISTANCE : SEMANTIC_FEATURE_BYPASS_DISTANCE,
)
const anchors = originalAnchors.map((anchor, index) =>
index === witnessIndex ? nextAnchor : anchor,
)
if (!ConstructionDimensionNode.safeParse({ ...node, anchors }).success) {
latest = null
useLiveNodeOverrides.getState().clear(node.id)
return
}
latest = anchors
useLiveNodeOverrides.getState().set(node.id, { anchors })
},
canCommit: () => latest !== null,
commit() {
const anchors = latest
useLiveNodeOverrides.getState().clear(node.id)
if (anchors) useScene.getState().updateNode(node.id, { anchors })
},
}
},
}
export const moveConstructionDimensionBaselineAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
{
start({ node }) {
let latest: [number, number] | null = null
return {
affectedIds: [node.id],
apply({ planPoint }) {
const origin: [number, number] = [planPoint[0], planPoint[1]]
const baseline = { ...node.baseline, origin }
if (!ConstructionDimensionNode.safeParse({ ...node, baseline }).success) return
latest = origin
useLiveNodeOverrides.getState().set(node.id, { baseline })
},
canCommit: () => latest !== null,
commit() {
useLiveNodeOverrides.getState().clear(node.id)
if (latest) {
useScene.getState().updateNode(node.id, {
baseline: { ...node.baseline, origin: latest },
})
}
},
}
},
}
@@ -0,0 +1,180 @@
import { describe, expect, test } from 'bun:test'
import { type MeasurementPoint, WallNode } from '@pascal-app/core'
import {
buildConstructionDimensionPreviewGeometries,
buildCurvedWallConstructionDimensionDraft,
constructionDimensionUsesBaseline,
normalizeConstructionDimensionChainMode,
normalizeConstructionDimensionMode,
resolveConstructionDimensionDraftDirection,
} from './floorplan-tool'
describe('continuous construction-dimension drafting', () => {
test('derives a stable baseline direction from the first witness pair', () => {
expect(
resolveConstructionDimensionDraftDirection([
[1, 0, 2],
[4, 0, 6],
[8, 0, 7],
]),
).toEqual([0.6, 0.8])
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
})
test('previews one adjacent dimension for every witness interval', () => {
const geometry = buildConstructionDimensionPreviewGeometries(
[
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
[0, 0, 2],
'metric',
)
expect(geometry).toHaveLength(3)
expect(geometry.map((segment) => segment.text)).toEqual(['2m', '3m', '4m'])
expect(geometry[1]).toMatchObject({
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 2],
dimensionEnd: [5, 2],
})
})
test('normalizes unknown tool defaults to the point-to-point workflow', () => {
expect(normalizeConstructionDimensionChainMode('continuous')).toBe('continuous')
expect(normalizeConstructionDimensionChainMode('unknown')).toBe('point-to-point')
})
test('normalizes curved and circular construction-dimension modes', () => {
expect(normalizeConstructionDimensionMode('radius')).toBe('radius')
expect(normalizeConstructionDimensionMode('arc-length')).toBe('arc-length')
expect(normalizeConstructionDimensionMode('unknown')).toBe('linear')
})
test('previews radius and diameter notation before commit', () => {
const points: MeasurementPoint[] = [
[0, 0, 0],
[2, 0, 0],
]
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
).toMatchObject({ text: 'R 2m' })
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
).toMatchObject({ text: 'Ø 2m' })
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'angular'),
).toEqual([])
})
test('previews the arc value leader while placing the fourth point', () => {
const preview = buildConstructionDimensionPreviewGeometries(
[
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
[3, 0, 3],
'metric',
'arc-length',
)
expect(preview).toHaveLength(1)
expect(preview[0]).toMatchObject({
kind: 'group',
children: expect.arrayContaining([
expect.objectContaining({ kind: 'path' }),
expect.objectContaining({ kind: 'line', x2: 3, y2: 3 }),
expect.objectContaining({ kind: 'dimension-label', text: 'ARC 3.14m' }),
]),
})
})
test('previews the angular arc and value while placing the fourth point', () => {
const preview = buildConstructionDimensionPreviewGeometries(
[
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
[1.5, 0, 0.5],
'metric',
'angular',
)
expect(preview).toHaveLength(1)
expect(preview[0]).toMatchObject({
kind: 'group',
children: expect.arrayContaining([
expect.objectContaining({ kind: 'path' }),
expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }),
expect.objectContaining({
kind: 'dimension-label',
cx: 1.5,
cy: 0.5,
text: '∠ 90°',
}),
]),
})
})
test('only requests a label baseline for modes that use one', () => {
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
})
test('derives associative radius, chord, and center drafts from one curved wall', () => {
const wall = WallNode.parse({
id: 'wall_curve',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
anchors: [
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
],
points: [
[2, 0, 1.5],
[2, 0, -1],
],
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
{ reference: { featureId: 'wall:start' } },
{ reference: { featureId: 'wall:end' } },
])
expect(buildCurvedWallConstructionDimensionDraft(wall, 'center-mark')?.anchors).toHaveLength(2)
})
test('keeps arc length in the manual start-center-end and baseline workflow', () => {
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(curved, 'arc-length')).toBeNull()
expect(constructionDimensionUsesBaseline('arc-length')).toBe(true)
})
test('keeps angular dimensions in the manual ray-center-ray and baseline workflow', () => {
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(curved, 'angular')).toBeNull()
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
})
test('keeps manual point drafting for straight walls and unsupported modes', () => {
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(straight, 'radius')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
})
})
@@ -0,0 +1,726 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ConstructionDimensionChainMode,
type ConstructionDimensionMode,
ConstructionDimensionNode,
closestMeasurementFeatureBinding,
constructionDimensionRequiredAnchorCount,
type FloorplanGeometry,
type GeometryContext,
getWallArcData,
getWallCurveFrameAt,
type MeasurementAnchor,
type MeasurementFeatureAnchor,
type MeasurementPoint,
nodeRegistry,
type WallNode,
} from '@pascal-app/core'
import {
buildSvgArcPath,
clearSurfacePlanSnapFeedback,
FloorplanGeometryRenderer,
type FloorplanToolContext,
formatLinearMeasurement,
getArcPlanPoint,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
resolveSurfacePlanPointSnap,
triggerSFX,
useDrawingView,
useFloorplanRender,
useInteractionScope,
} from '@pascal-app/editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { resolveCircularConstructionDimensionLayout } from './geometry'
const SEMANTIC_SNAP_DISTANCE = 0.2
const SEMANTIC_BYPASS_DISTANCE = 0.012
const MIN_DIMENSION_LENGTH = 0.001
const MIN_ARC_SWEEP = 1e-6
type Draft = {
anchors: MeasurementAnchor[]
points: MeasurementPoint[]
stage: 'witnesses' | 'baseline'
}
type AssociatedPoint = {
anchor: MeasurementAnchor
point: MeasurementPoint
semantic: boolean
targetNodeId: string | null
}
const emptyDraft = (): Draft => ({ anchors: [], points: [], stage: 'witnesses' })
function geometryContext(node: AnyNode, nodes: Record<AnyNodeId, AnyNode>): GeometryContext {
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
nodes[id] as N | undefined
const childIds =
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
const children = childIds
.map((id) => nodes[id])
.filter((child): child is AnyNode => child !== undefined)
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
const siblings =
parent && 'children' in parent && Array.isArray(parent.children)
? (parent.children as AnyNodeId[])
.map((id) => nodes[id])
.filter(
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
)
: []
return { resolve, children, parent, siblings }
}
function associatePoint(
point: MeasurementPoint,
targetNodeId: string | null,
maxDistance: number,
nodes: Record<AnyNodeId, AnyNode>,
): AssociatedPoint {
if (!targetNodeId) return { anchor: point, point, semantic: false, targetNodeId: null }
const node = nodes[targetNodeId as AnyNodeId]
const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
if (!(node && contribution)) return { anchor: point, point, semantic: false, targetNodeId }
const context = geometryContext(node, nodes)
const features = contribution.features(node, context)
const match =
contribution.match?.(node, context, point, maxDistance) ??
closestMeasurementFeatureBinding(features, point, maxDistance)
if (!match) return { anchor: point, point, semantic: false, targetNodeId }
const reference = {
nodeId: node.id,
featureId: match.featureId,
parameters: match.parameters,
}
const anchor: MeasurementFeatureAnchor = {
kind: 'feature',
reference,
fallback: match.point,
}
return { anchor, point: match.point, semantic: true, targetNodeId }
}
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number) {
const matrix = group.getScreenCTM()
if (!matrix) return null
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
return [local.x, 0, local.y] satisfies MeasurementPoint
}
function registryTargetNodeId(target: EventTarget | null): string | null {
if (!(target instanceof Element)) return null
return (
target.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')?.dataset.nodeId ?? null
)
}
export function resolveConstructionDimensionDraftDirection(
points: readonly MeasurementPoint[],
): [number, number] | null {
if (points.length < 2) return null
const dx = points[1]![0] - points[0]![0]
const dz = points[1]![2] - points[0]![2]
const magnitude = Math.hypot(dx, dz)
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
}
export function buildConstructionDimensionPreviewGeometries(
points: readonly MeasurementPoint[],
baselinePoint: MeasurementPoint,
unit: 'metric' | 'imperial',
mode: ConstructionDimensionMode = 'linear',
metricNotation: 'meters' | 'millimeters' = 'meters',
): FloorplanGeometry[] {
if (mode === 'arc-length' || mode === 'angular') {
const layout = resolveCircularConstructionDimensionLayout(mode, points)
if (!(layout?.end && Math.abs(layout.sweep) > MIN_ARC_SWEEP)) return []
const center = { x: layout.center[0], y: layout.center[1] }
const end = getArcPlanPoint(center, layout.radius, layout.endAngle)
const arcMid = getArcPlanPoint(center, layout.radius, layout.startAngle + layout.sweep / 2)
const stroke = '#06b6d4'
const lineStyle = {
fill: 'none',
pointerEvents: 'none' as const,
stroke,
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke' as const,
}
if (mode === 'angular') {
const endRadius = Math.hypot(layout.end[0] - center.x, layout.end[1] - center.y)
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
const requestedRadius = Math.hypot(baselinePoint[0] - center.x, baselinePoint[2] - center.y)
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = getArcPlanPoint(center, arcRadius, midAngle)
const startRayEnd = getArcPlanPoint(
center,
Math.max(layout.radius, arcRadius + 0.12),
layout.startAngle,
)
const endRayEnd = getArcPlanPoint(
center,
Math.max(endRadius, arcRadius + 0.12),
layout.endAngle,
)
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
const formattedDegrees = Number.parseFloat(degrees.toFixed(degrees < 10 ? 1 : 0))
return [
{
kind: 'group',
children: [
{
kind: 'line',
x1: center.x,
y1: center.y,
x2: startRayEnd.x,
y2: startRayEnd.y,
...lineStyle,
},
{
kind: 'line',
x1: center.x,
y1: center.y,
x2: endRayEnd.x,
y2: endRayEnd.y,
...lineStyle,
},
{
kind: 'path',
d: buildSvgArcPath(
center,
arcRadius,
layout.startAngle,
layout.startAngle + layout.sweep,
),
...lineStyle,
},
{
kind: 'line',
x1: arcMid.x,
y1: arcMid.y,
x2: baselinePoint[0],
y2: baselinePoint[2],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'dimension-label',
cx: baselinePoint[0],
cy: baselinePoint[2],
text: `${formattedDegrees}°`,
angle: 0,
screenUpright: true,
appearance: 'outlined',
},
],
},
]
}
return [
{
kind: 'group',
children: [
{
kind: 'path',
d: buildSvgArcPath(
center,
layout.radius,
layout.startAngle,
layout.startAngle + layout.sweep,
),
...lineStyle,
},
{
kind: 'line',
x1: layout.center[0],
y1: layout.center[1],
x2: layout.start[0],
y2: layout.start[1],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'line',
x1: layout.center[0],
y1: layout.center[1],
x2: end.x,
y2: end.y,
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'line',
x1: arcMid.x,
y1: arcMid.y,
x2: baselinePoint[0],
y2: baselinePoint[2],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'dimension-label',
cx: baselinePoint[0],
cy: baselinePoint[2],
text: `ARC ${formatLinearMeasurement(layout.arcLength, unit, metricNotation)}`,
angle: 0,
screenUpright: true,
appearance: 'outlined',
},
],
},
]
}
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
const direction = resolveConstructionDimensionDraftDirection(points)
if (!direction) return []
const normal: [number, number] = [-direction[1], direction[0]]
const project = (point: MeasurementPoint): [number, number] => {
const along =
(point[0] - baselinePoint[0]) * direction[0] + (point[2] - baselinePoint[2]) * direction[1]
return [baselinePoint[0] + along * direction[0], baselinePoint[2] + along * direction[1]]
}
const dimensionPoints = points.map(project)
return points.slice(0, -1).map((start, index) => {
const end = points[index + 1]!
const dx = end[0] - start[0]
const dz = end[2] - start[2]
const value = Math.abs(dx * direction[0] + dz * direction[1])
const rawText = formatLinearMeasurement(value, unit, metricNotation)
const text =
mode === 'radius'
? `R ${rawText}`
: mode === 'diameter'
? `Ø ${rawText}`
: mode === 'chord'
? `CH ${rawText}`
: rawText
return {
kind: 'dimension',
start: [start[0], start[2]],
end: [end[0], end[2]],
dimensionStart: dimensionPoints[index]!,
dimensionEnd: dimensionPoints[index + 1]!,
offsetNormal: normal,
offsetDistance: 0,
extensionOvershoot: 0.12,
text,
stroke: '#06b6d4',
}
})
}
export function normalizeConstructionDimensionChainMode(
value: unknown,
): ConstructionDimensionChainMode {
return value === 'continuous' ? 'continuous' : 'point-to-point'
}
export function normalizeConstructionDimensionMode(value: unknown): ConstructionDimensionMode {
return [
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
].includes(value as string)
? (value as ConstructionDimensionMode)
: 'linear'
}
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
}
function wallFeatureAnchor(
wall: WallNode,
featureId: string,
fallback: MeasurementPoint,
): MeasurementFeatureAnchor {
return {
kind: 'feature',
reference: { nodeId: wall.id, featureId },
fallback,
}
}
export function buildCurvedWallConstructionDimensionDraft(
wall: WallNode,
mode: ConstructionDimensionMode,
): Pick<Draft, 'anchors' | 'points'> | null {
const arc = getWallArcData(wall)
if (!arc) return null
const center: MeasurementPoint = [arc.center.x, 0, arc.center.y]
const start: MeasurementPoint = [wall.start[0], 0, wall.start[1]]
const end: MeasurementPoint = [wall.end[0], 0, wall.end[1]]
const midpointFrame = getWallCurveFrameAt(wall, 0.5)
const midpoint: MeasurementPoint = [midpointFrame.point.x, 0, midpointFrame.point.y]
const feature = (featureId: string, fallback: MeasurementPoint) =>
wallFeatureAnchor(wall, featureId, fallback)
switch (mode) {
case 'radius':
case 'center-mark':
return {
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
points: [center, midpoint],
}
case 'chord':
return {
anchors: [feature('wall:start', start), feature('wall:end', end)],
points: [start, end],
}
case 'arc-length':
case 'angular':
return null
default:
return null
}
}
export function FloorplanConstructionDimensionToolLayer({
activeLevelId,
finishTool,
gridSnapStep,
metricNotation,
sceneApi,
selectNode,
toolDefaults,
unit,
}: FloorplanToolContext) {
const groupRef = useRef<SVGGElement>(null)
const draftRef = useRef<Draft>(emptyDraft())
const [draft, setDraft] = useState<Draft>(draftRef.current)
const [hover, setHover] = useState<AssociatedPoint | null>(null)
const chainMode = normalizeConstructionDimensionChainMode(toolDefaults?.chainMode)
const dimensionMode = normalizeConstructionDimensionMode(toolDefaults?.mode)
const collectsMany =
dimensionMode === 'coordinate' || (dimensionMode === 'linear' && chainMode === 'continuous')
const usesBaseline = constructionDimensionUsesBaseline(dimensionMode)
const renderContext = useFloorplanRender()
const drawingType = useDrawingView((state) => state.drawingType)
useEffect(() => {
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'construction-dimension' })
return () =>
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'construction-dimension')
}, [])
const updateDraft = useCallback((next: Draft) => {
draftRef.current = next
setDraft(next)
}, [])
useEffect(() => {
updateDraft(emptyDraft())
setHover(null)
const group = groupRef.current
const svg = group?.ownerSVGElement
if (!(activeLevelId && group && svg)) return
const consume = (event: Event) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
const resolveEvent = (event: MouseEvent | PointerEvent): AssociatedPoint | null => {
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
if (!raw) return null
const forceFree = event.altKey
const gridStep = !forceFree && isGridSnapActive() ? gridSnapStep : 0
const fallbackPoint: [number, number] =
gridStep > 0
? [Math.round(raw[0] / gridStep) * gridStep, Math.round(raw[2] / gridStep) * gridStep]
: [raw[0], raw[2]]
const magnetic = !forceFree && isMagneticSnapActive()
const surface = resolveSurfacePlanPointSnap({
rawPoint: [raw[0], raw[2]],
fallbackPoint,
levelId: activeLevelId,
align: false,
magnetic,
})
const point: MeasurementPoint = [surface.point[0], 0, surface.point[1]]
const targetNodeId = surface.wallIds[0] ?? registryTargetNodeId(event.target)
return associatePoint(
point,
targetNodeId,
magnetic ? SEMANTIC_SNAP_DISTANCE : SEMANTIC_BYPASS_DISTANCE,
sceneApi.nodes(),
)
}
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
const direction = resolveConstructionDimensionDraftDirection(current.points)
const originPoint = baselinePoint ?? current.points.at(-1)
if (!(direction && originPoint)) return false
const node = ConstructionDimensionNode.parse({
name:
dimensionMode === 'linear' && chainMode === 'continuous'
? 'Continuous Dimension'
: `${dimensionMode.replaceAll('-', ' ')} Dimension`,
anchors: current.anchors,
baseline: {
origin: [originPoint[0], originPoint[2]],
direction,
},
chainMode,
mode: dimensionMode,
drawingType,
})
sceneApi.upsert(node, activeLevelId)
selectNode(node.id)
triggerSFX('sfx:structure-build')
finishTool()
updateDraft(emptyDraft())
setHover(null)
return true
}
const finishWitnesses = () => {
const current = draftRef.current
const required = constructionDimensionRequiredAnchorCount(dimensionMode)
if (current.stage !== 'witnesses' || current.points.length < required) return false
if (!usesBaseline) return commitDraft(current)
updateDraft({ ...current, stage: 'baseline' })
triggerSFX('sfx:grid-snap')
return true
}
const removeLastWitness = () => {
const current = draftRef.current
if (current.points.length === 0) return false
updateDraft({
anchors: current.anchors.slice(0, -1),
points: current.points.slice(0, -1),
stage: 'witnesses',
})
return true
}
const commitAt = (associated: AssociatedPoint) => {
const current = draftRef.current
if (current.stage === 'baseline') commitDraft(current, associated.point)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button === 0) consume(event)
}
const onPointerMove = (event: PointerEvent) => {
consume(event)
setHover(resolveEvent(event))
}
const onPointerLeave = () => {
clearSurfacePlanSnapFeedback()
setHover(null)
}
const onClick = (event: MouseEvent) => {
if (event.button !== 0) return
consume(event)
if (event.detail > 1) return
const associated = resolveEvent(event)
if (!associated) return
const current = draftRef.current
if (current.stage === 'baseline') {
commitAt(associated)
return
}
const targetNode = associated.targetNodeId
? sceneApi.get(associated.targetNodeId as AnyNodeId)
: undefined
const curvedWallDraft =
current.points.length === 0 && targetNode?.type === 'wall'
? buildCurvedWallConstructionDimensionDraft(targetNode, dimensionMode)
: null
if (curvedWallDraft) {
const next: Draft = { ...curvedWallDraft, stage: 'witnesses' }
updateDraft(next)
triggerSFX('sfx:grid-snap')
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
else commitDraft(next)
return
}
const previous = current.points.at(-1)
if (
previous &&
Math.hypot(associated.point[0] - previous[0], associated.point[2] - previous[2]) <=
MIN_DIMENSION_LENGTH
) {
return
}
const next: Draft = {
anchors: [...current.anchors, associated.anchor],
points: [...current.points, associated.point],
stage: 'witnesses',
}
updateDraft(next)
triggerSFX('sfx:grid-snap')
if (
!collectsMany &&
next.points.length === constructionDimensionRequiredAnchorCount(dimensionMode)
) {
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
else commitDraft(next)
}
}
const onDoubleClick = (event: MouseEvent) => {
if (event.button !== 0 || !collectsMany) return
consume(event)
finishWitnesses()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && collectsMany) {
if (!finishWitnesses()) return
event.preventDefault()
event.stopImmediatePropagation()
return
}
if (event.key === 'Backspace') {
if (!removeLastWitness()) return
event.preventDefault()
event.stopImmediatePropagation()
markToolCancelConsumed()
return
}
if (event.key !== 'Escape') return
event.preventDefault()
event.stopImmediatePropagation()
markToolCancelConsumed()
const current = draftRef.current
if (current.stage === 'baseline') {
updateDraft({ ...current, stage: 'witnesses' })
return
}
if (removeLastWitness()) return
finishTool()
}
const onBlur = () => clearSurfacePlanSnapFeedback()
svg.addEventListener('pointerdown', onPointerDown, true)
svg.addEventListener('pointermove', onPointerMove, true)
svg.addEventListener('pointerleave', onPointerLeave, true)
svg.addEventListener('click', onClick, true)
svg.addEventListener('dblclick', onDoubleClick, true)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('blur', onBlur)
return () => {
clearSurfacePlanSnapFeedback()
svg.removeEventListener('pointerdown', onPointerDown, true)
svg.removeEventListener('pointermove', onPointerMove, true)
svg.removeEventListener('pointerleave', onPointerLeave, true)
svg.removeEventListener('click', onClick, true)
svg.removeEventListener('dblclick', onDoubleClick, true)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('blur', onBlur)
}
}, [
activeLevelId,
chainMode,
collectsMany,
dimensionMode,
drawingType,
finishTool,
gridSnapStep,
sceneApi,
selectNode,
updateDraft,
usesBaseline,
])
const preview = useMemo(
() =>
draft.stage === 'baseline' && hover
? buildConstructionDimensionPreviewGeometries(
draft.points,
hover.point,
unit,
dimensionMode,
metricNotation,
)
: [],
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
)
const witnessDraftPoints =
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
if (!activeLevelId) return null
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
const reticleRadius = 10 * unitsPerPixel
const hoverColor = hover?.semantic ? '#22c55e' : '#06b6d4'
return (
<g ref={groupRef}>
{witnessDraftPoints.length >= 2 && (draft.stage === 'witnesses' || preview.length === 0) ? (
<polyline
fill="none"
pointerEvents="none"
points={witnessDraftPoints.map((point) => `${point[0]},${point[2]}`).join(' ')}
stroke="#06b6d4"
strokeDasharray="6 5"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
) : null}
{preview.map((geometry, index) => (
<FloorplanGeometryRenderer
annotationUnitsPerPoint={unitsPerPixel}
geometry={geometry}
key={`${index}-${geometry.kind}`}
sceneRotationDeg={renderContext?.sceneRotationDeg ?? 0}
/>
))}
{draft.points.map((point, index) => (
<circle
fill="#06b6d4"
key={`${index}-${point.join('-')}`}
pointerEvents="none"
r={4.5 * unitsPerPixel}
stroke="#ffffff"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[2]}
/>
))}
{hover ? (
<g pointerEvents="none">
<circle
cx={hover.point[0]}
cy={hover.point[2]}
fill="none"
r={reticleRadius}
stroke={hoverColor}
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={hoverColor}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover.point[0] - reticleRadius * 1.4}
x2={hover.point[0] + reticleRadius * 1.4}
y1={hover.point[2]}
y2={hover.point[2]}
/>
<line
stroke={hoverColor}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover.point[0]}
x2={hover.point[0]}
y1={hover.point[2] - reticleRadius * 1.4}
y2={hover.point[2] + reticleRadius * 1.4}
/>
</g>
) : null}
</g>
)
}
export default FloorplanConstructionDimensionToolLayer
@@ -0,0 +1,518 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
ConstructionDimensionNode,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry,
registerNode,
WallNode,
} from '@pascal-app/core'
import { createFloorplanContextExtensions } from '@pascal-app/editor'
import { wallDefinition } from '../wall/definition'
import { buildConstructionDimensionFloorplan } from './floorplan'
const palette = {
selectedStroke: '#2563eb',
selectedFill: '#dbeafe',
selectedHatch: '#93c5fd',
wallHoverStroke: '#60a5fa',
endpointHandleFill: '#f97316',
endpointHandleStroke: '#ffffff',
endpointHandleHoverStroke: '#fdba74',
endpointHandleActiveFill: '#ea580c',
endpointHandleActiveStroke: '#ffffff',
curveHandleFill: '#14b8a6',
curveHandleStroke: '#ffffff',
curveHandleHoverStroke: '#5eead4',
measurementStroke: '#334155',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#0f172a',
}
function context(
nodes: Record<string, AnyNode> = {},
selected = false,
purpose: 'edit' | 'document' = 'edit',
metricNotation?: 'meters' | 'millimeters',
): GeometryContext {
return {
resolve: (id) => nodes[id],
children: [],
siblings: [],
parent: null,
viewState: {
selected,
unit: 'metric',
highlighted: false,
hovered: false,
moving: false,
palette,
},
extensions: createFloorplanContextExtensions({ metricNotation, purpose }),
}
}
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
}
function dimensionSegments(geometry: FloorplanGeometry | null): Array<{
start: readonly [number, number]
end: readonly [number, number]
dimensionStart?: readonly [number, number]
dimensionEnd?: readonly [number, number]
text: string
stroke?: string
}> {
if (!geometry) return []
return flatten(geometry).flatMap((entry) => {
if (entry.kind === 'dimension') return [entry]
if (entry.kind === 'dimension-string')
return entry.segments.map((segment) => ({ ...segment, stroke: entry.stroke }))
return []
})
}
describe('buildConstructionDimensionFloorplan', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(wallDefinition)
})
afterEach(() => nodeRegistry._reset())
test('projects witness origins onto the placed baseline', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[1, 0, 1],
[4, 0, 2],
],
baseline: { origin: [0, 5], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimension = dimensionSegments(geometry)[0]
expect(dimension).toMatchObject({
start: [1, 1],
end: [4, 2],
dimensionStart: [1, 5],
dimensionEnd: [4, 5],
text: '3m',
})
})
test('follows semantic anchors and reports dangling references', () => {
const wall = WallNode.parse({ id: 'wall_target', start: [0, 0], end: [4, 0] })
const node = ConstructionDimensionNode.parse({
anchors: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const linked = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
const movedWall = WallNode.parse({ ...wall, start: [2, 0], end: [6, 0] })
const moved = buildConstructionDimensionFloorplan(node, context({ [wall.id]: movedWall }))
const dangling = buildConstructionDimensionFloorplan(node, context())
const linkedDimension = dimensionSegments(linked)[0]
const movedDimension = dimensionSegments(moved)[0]
const danglingDimension = dimensionSegments(dangling)[0]
expect(linkedDimension).toMatchObject({ start: [1, 0], text: '3m' })
expect(movedDimension).toMatchObject({ start: [3, 0], text: '1m' })
expect(danglingDimension).toMatchObject({
start: [1, 0],
text: 'UNLINKED · 3m',
stroke: '#dc2626',
})
})
test('resolves wall anchors against the selected assembly datum', () => {
const wall = WallNode.parse({
id: 'wall_assembly',
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.1,
datumEligible: ['structural-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.03,
datumEligible: ['finish-face'],
},
],
})
const anchor = {
kind: 'feature' as const,
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0] as [number, number, number],
}
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
buildConstructionDimensionFloorplan(
ConstructionDimensionNode.parse({
anchors: [anchor, [3, 0, 0]],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy,
}),
context({ [wall.id]: wall }),
)
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
})
test('uses millimetre notation in document output', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[3, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
expect(
dimensionSegments(
buildConstructionDimensionFloorplan(node, context({}, false, 'document')),
)[0]?.text,
).toBe('3000')
})
test('renders a continuous string as adjacent associative segments', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(dimensions).toHaveLength(3)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '3m', '4m'])
expect(dimensions[1]).toMatchObject({
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
})
})
test('renders point-to-point strings as independent witness pairs', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'point-to-point',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(dimensions).toHaveLength(2)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
expect(dimensions[1]).toMatchObject({
start: [5, 0],
end: [9, 0],
dimensionStart: [5, 1],
dimensionEnd: [9, 1],
})
})
test('suppresses view-specific string segments without mutating physical anchors', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
metadata: { suppressedDimensionSegmentIndexes: [1] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(node.anchors).toHaveLength(4)
expect(dimensions).toHaveLength(2)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
})
test('passes persistent dimension standards to linear dimension strings', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy: 'finish-face',
terminator: 'dot',
textPosition: 'centered',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
const geometry = buildConstructionDimensionFloorplan(node, context({}, false, 'document'))
const string = geometry
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
: null
expect(string).toMatchObject({
terminator: 'dot',
textPosition: 'centered',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
})
test('uses the live metric notation for manual dimensions in edit mode', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(
node,
context({}, false, 'edit', 'millimeters'),
)
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
})
test('shows witness and baseline handles only while selected', () => {
const node = ConstructionDimensionNode.parse({})
const idle = buildConstructionDimensionFloorplan(node, context())
const selected = buildConstructionDimensionFloorplan(node, context({}, true))
expect(idle && flatten(idle).filter((entry) => entry.kind === 'endpoint-handle')).toHaveLength(
0,
)
const handles = selected
? flatten(selected).filter((entry) => entry.kind === 'endpoint-handle')
: []
expect(handles).toHaveLength(3)
expect(handles).toContainEqual(
expect.objectContaining({
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex: 0 },
}),
)
expect(handles).toContainEqual(
expect.objectContaining({
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex: 1 },
}),
)
expect(handles).toContainEqual(
expect.objectContaining({ affordance: 'move-construction-dimension-baseline' }),
)
})
test('keeps linked geometry read-only in a dependent drawing', () => {
const node = ConstructionDimensionNode.parse({
metadata: { drawingCoordinationLocked: true },
})
const geometry = buildConstructionDimensionFloorplan(node, context({}, true))
expect(
geometry && flatten(geometry).filter((entry) => entry.kind === 'endpoint-handle'),
).toHaveLength(0)
})
test('renders radius notation with a leader and center mark', () => {
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [3, 1], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
text: 'R 2m',
cx: 3,
cy: 1,
})
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
})
test('updates an associative curved-wall radius when the host curve changes', () => {
const wall = WallNode.parse({
id: 'wall_curve',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
})
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
fallback: [2, 0, 1.5],
},
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
fallback: [2, 0, -1],
},
],
baseline: { origin: [2, -1.5], direction: [0, -1] },
})
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
const originalLabel =
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
const reshapedLabel =
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
})
test('renders diameter and repeated-feature notation', () => {
const node = ConstructionDimensionNode.parse({
mode: 'diameter',
anchors: [
[-1, 0, 0],
[1, 0, 0],
],
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(dimensionSegments(geometry)[0]).toMatchObject({
text: 'TYP · 6 x Ø 2m CLR',
start: [-1, 0],
end: [1, 0],
})
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
})
test('renders a standalone center mark from a center and radius point', () => {
const node = ConstructionDimensionNode.parse({
mode: 'center-mark',
anchors: [
[3, 0, 4],
[5, 0, 4],
],
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
expect(dimensionSegments(geometry).length).toBe(0)
})
test('renders chord and arc-length dimensions', () => {
const chord = ConstructionDimensionNode.parse({
mode: 'chord',
anchors: [
[-1, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const arc = ConstructionDimensionNode.parse({
mode: 'arc-length',
anchors: [
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
baseline: { origin: [2, 2], direction: [1, 0] },
})
const chordGeometry = buildConstructionDimensionFloorplan(chord, context())
const arcGeometry = buildConstructionDimensionFloorplan(arc, context())
const chordEntries = chordGeometry ? flatten(chordGeometry) : []
const arcEntries = arcGeometry ? flatten(arcGeometry) : []
expect(dimensionSegments(chordGeometry)[0]).toMatchObject({
text: 'CH 2m',
})
expect(arcEntries.some((entry) => entry.kind === 'path')).toBe(true)
expect(arcEntries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
text: 'ARC 3.14m',
})
})
test('renders angular dimensions with an architectural angle label', () => {
const node = ConstructionDimensionNode.parse({
mode: 'angular',
anchors: [
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
baseline: { origin: [1.5, 0.5], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.some((entry) => entry.kind === 'path')).toBe(true)
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
cx: 1.5,
cy: 0.5,
text: '∠ 90°',
screenUpright: true,
})
expect(entries).toContainEqual(expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }))
})
test('renders signed coordinate labels for repeated circular features', () => {
const node = ConstructionDimensionNode.parse({
mode: 'coordinate',
anchors: [
[0, 0, 0],
[2, 0, 3],
[-1, 0, 4],
],
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const labels = geometry
? flatten(geometry)
.filter((entry) => entry.kind === 'dimension-label')
.map((entry) => entry.text)
: []
expect(labels).toEqual(['P1 · X 2m · Y 3m', 'P2 · X -1m · Y 4m'])
})
})
@@ -0,0 +1,660 @@
import type {
AnyNodeId,
ConstructionDimensionNode,
FloorplanGeometry,
FloorplanPoint,
FloorplanStyle,
GeometryContext,
MeasurementAnchor,
MeasurementPoint,
WallNode,
} from '@pascal-app/core'
import {
constructionDimensionRequiredAnchorCount,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallCurveFrameAt,
resolveWallAssemblyDatumReferences,
} from '@pascal-app/core'
import {
readFloorplanContext,
readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import { resolveMeasurementAnchor } from '../measurement/resolve'
import {
type ConstructionLengthFormatOptions,
type ConstructionLengthProfile,
formatConstructionLength,
} from '../shared/construction-length'
import { buildDimensionStringGeometry } from '../shared/dimension-string'
import {
resolveCircularConstructionDimensionLayout,
resolveConstructionDimensionLayout,
} from './geometry'
const DEFAULT_STROKE = '#334155'
const DANGLING_STROKE = '#dc2626'
const EPSILON = 1e-6
export function buildConstructionDimensionFloorplan(
node: ConstructionDimensionNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.visible === false) return null
const resolved = node.anchors.map((anchor) => resolveDimensionAnchor(node, anchor, ctx))
const points = resolved.map((anchor) => anchor.point) as MeasurementPoint[]
if (points.length < constructionDimensionRequiredAnchorCount(node.mode)) return null
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
const baseStroke = selected
? (ctx.viewState?.palette.selectedStroke ?? '#2563eb')
: (ctx.viewState?.palette.measurementStroke ?? DEFAULT_STROKE)
const dangling = resolved.some((anchor) => anchor.dangling)
const stroke = dangling ? DANGLING_STROKE : baseStroke
const unit = ctx.viewState?.unit ?? 'metric'
const floorplanContext = readFloorplanContext(ctx)
const profile: ConstructionLengthProfile =
floorplanContext.purpose === 'document' ? 'document' : 'editor'
const metricNotationOverride = readFloorplanMetricNotationOverride(ctx)
const displayNode =
profile === 'editor' && metricNotationOverride
? { ...node, metricNotation: metricNotationOverride }
: node
const editable =
ctx.viewState?.selected === true &&
!(
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
node.metadata.drawingCoordinationLocked === true
)
switch (node.mode) {
case 'linear':
case 'chord':
return withFloorplanGeometryMetadata(
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'radius':
return withFloorplanGeometryMetadata(
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'diameter':
return withFloorplanGeometryMetadata(
buildDiameter(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'center-mark':
return withFloorplanGeometryMetadata(
buildCenterMarkOnly(displayNode, points, stroke, editable),
{ annotationRole: 'manual-dimension' },
)
case 'arc-length':
return withFloorplanGeometryMetadata(
buildArcLength(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'angular':
return withFloorplanGeometryMetadata(
buildAngular(displayNode, points, stroke, dangling, editable),
{ annotationRole: 'manual-dimension' },
)
case 'coordinate':
return withFloorplanGeometryMetadata(
buildCoordinate(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
}
}
function resolveDimensionAnchor(
node: ConstructionDimensionNode,
anchor: MeasurementAnchor,
ctx: GeometryContext,
): ReturnType<typeof resolveMeasurementAnchor> {
const resolved = resolveMeasurementAnchor(anchor, (id) => ctx.resolve(id))
if (Array.isArray(anchor) || resolved.dangling) return resolved
if (!supportsWallDatum(anchor.reference.featureId)) return resolved
const referenced = ctx.resolve<WallNode>(anchor.reference.nodeId as AnyNodeId)
if (referenced?.type !== 'wall') return resolved
const t = wallFeatureParameter(anchor.reference.featureId, anchor.reference.parameters?.t)
const frame = getWallCurveFrameAt(referenced, t)
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
return {
...resolved,
point: [
frame.point.x + frame.normal.x * offset,
resolved.point[1],
frame.point.y + frame.normal.y * offset,
],
}
}
function supportsWallDatum(featureId: string): boolean {
return (
featureId === 'wall:start' ||
featureId === 'wall:end' ||
featureId === 'wall:centerline' ||
featureId === 'wall:midpoint' ||
featureId === 'wall:face:left' ||
featureId === 'wall:face:right' ||
featureId === 'wall:top-centerline'
)
}
function wallFeatureParameter(featureId: string, parameter: unknown): number {
if (featureId === 'wall:start') return 0
if (featureId === 'wall:end') return 1
return typeof parameter === 'number' ? Math.max(0, Math.min(1, parameter)) : 0.5
}
function wallDatumSide(
node: ConstructionDimensionNode,
featureId: string,
resolved: ReturnType<typeof resolveMeasurementAnchor>,
frame: ReturnType<typeof getWallCurveFrameAt>,
): 1 | -1 {
if (featureId === 'wall:face:left') return 1
if (featureId === 'wall:face:right') return -1
const baselineProjection =
(node.baseline.origin[0] - frame.point.x) * frame.normal.x +
(node.baseline.origin[1] - frame.point.y) * frame.normal.y
if (Math.abs(baselineProjection) > EPSILON) return baselineProjection > 0 ? 1 : -1
const resolvedNormal = resolved.normal
if (resolvedNormal) {
const normalProjection = resolvedNormal[0] * frame.normal.x + resolvedNormal[2] * frame.normal.y
if (Math.abs(normalProjection) > EPSILON) return normalProjection > 0 ? 1 : -1
}
return 1
}
function wallDatumOffset(
wall: WallNode,
policy: ConstructionDimensionNode['datumPolicy'],
side: 1 | -1,
): number {
if (policy === 'centerline') return 0
if (policy === 'wall-face') {
const faces = getWallAssemblyFaceOffsets(wall)
return side > 0 ? faces.exterior : faces.interior
}
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
const candidates = resolveWallAssemblyDatumReferences(wall)
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
.map((reference) => reference.offset)
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
}
function buildLinearOrChord(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry {
const layout = resolveConstructionDimensionLayout(node, points)
const children: FloorplanGeometry[] = []
const suppressedSegments = suppressedDimensionSegmentIndexes(node)
const visibleSegments = layout.segments.filter((_, index) => !suppressedSegments.has(index))
const dimensionSegments = visibleSegments.map((segment) => {
const baseText = `${node.mode === 'chord' ? 'CH ' : ''}${formatConstructionLength(segment.value, unit, profile, lengthFormatOptions(node))}`
return {
witnessStart: segment.witnessStart,
witnessEnd: segment.witnessEnd,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
text: notation(node, baseText, dangling),
}
})
children.push(
...(dimensionSegments.length > 0
? [
buildDimensionStringGeometry({
segments: dimensionSegments,
offsetNormal: layout.normal,
offsetDistance: 0,
extensionStartGap: node.extensionStartGap,
extensionOvershoot: node.extensionOvershoot,
terminator: node.terminator,
textPosition: node.textPosition,
stroke,
}),
]
: []),
...visibleSegments.map((segment) => hitLine(segment.dimensionStart, segment.dimensionEnd)),
)
if (editable)
children.push(...witnessHandles(layout.witnessPoints), baselineHandle(layout.midpoint))
return dimensionGroup(children)
}
function buildRadius(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('radius', points)
if (!layout) return null
const labelPoint: FloorplanPoint = node.baseline.origin
const children: FloorplanGeometry[] = [
styledPolyline([layout.center, layout.start, labelPoint], stroke),
...openArrow(layout.start, layout.center, stroke),
labelGeometry(
labelPoint,
notation(
node,
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
angle(layout.start, labelPoint),
),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
return dimensionGroup(children)
}
function buildDiameter(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('diameter', points)
if (!layout?.end) return null
const direction = normalized(layout.start, layout.end)
if (!direction) return null
const normal: FloorplanPoint = [-direction[1], direction[0]]
const children: FloorplanGeometry[] = [
dimensionGeometry(
node,
layout.start,
layout.end,
layout.start,
layout.end,
normal,
notation(
node,
`Ø ${formatConstructionLength(layout.radius * 2, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
stroke,
),
hitLine(layout.start, layout.end),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function buildCenterMarkOnly(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('center-mark', points)
if (!layout) return null
const children: FloorplanGeometry[] = centerMark(layout.center, layout.radius, stroke, true)
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function buildArcLength(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('arc-length', points)
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
const projectedEnd = arcPoint(layout.center, layout.radius, layout.endAngle)
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = arcPoint(layout.center, layout.radius, midAngle)
const labelPoint: FloorplanPoint = node.baseline.origin
const children: FloorplanGeometry[] = [
arcGeometry(layout.center, layout.radius, layout.startAngle, layout.sweep, stroke),
styledLine(layout.center, layout.start, stroke, '0.08 0.08'),
styledLine(layout.center, projectedEnd, stroke, '0.08 0.08'),
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
...openArrow(
layout.start,
arcPoint(layout.center, layout.radius, layout.startAngle + layout.sweep * 0.08),
stroke,
),
...openArrow(
projectedEnd,
arcPoint(layout.center, layout.radius, layout.endAngle - layout.sweep * 0.08),
stroke,
),
labelGeometry(
labelPoint,
notation(
node,
`ARC ${formatConstructionLength(layout.arcLength, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
0,
true,
),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
return dimensionGroup(children)
}
function buildAngular(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('angular', points)
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
const endRadius = distance(layout.center, layout.end)
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
const requestedRadius = distance(layout.center, node.baseline.origin)
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = arcPoint(layout.center, arcRadius, midAngle)
const labelPoint: FloorplanPoint = node.baseline.origin
const startRayEnd = arcPoint(
layout.center,
Math.max(layout.radius, arcRadius + 0.12),
layout.startAngle,
)
const endRayEnd = arcPoint(layout.center, Math.max(endRadius, arcRadius + 0.12), layout.endAngle)
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
const children: FloorplanGeometry[] = [
styledLine(layout.center, startRayEnd, stroke),
styledLine(layout.center, endRayEnd, stroke),
arcGeometry(layout.center, arcRadius, layout.startAngle, layout.sweep, stroke),
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
labelGeometry(labelPoint, notation(node, `${formatDegrees(degrees)}`, dangling), 0, true),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, arcRadius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(node.baseline.origin))
return dimensionGroup(children)
}
function buildCoordinate(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const datum: FloorplanPoint = [points[0]![0], points[0]![2]]
const features = points.slice(1).map((point): FloorplanPoint => [point[0], point[2]])
if (features.length === 0) return null
const children: FloorplanGeometry[] = [...centerMark(datum, 0.4, stroke, true)]
features.forEach((feature, index) => {
const dx = feature[0] - datum[0]
const dy = feature[1] - datum[1]
const label = notation(
node,
`P${index + 1} · X ${formatConstructionLength(dx, unit, profile, lengthFormatOptions(node))} · Y ${formatConstructionLength(dy, unit, profile, lengthFormatOptions(node))}`,
dangling,
false,
)
children.push(
styledLine(datum, feature, stroke, '0.08 0.08'),
labelGeometry(feature, label, 0, true, 10),
...centerMark(feature, 0.3, stroke, true),
)
})
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function suppressedDimensionSegmentIndexes(node: ConstructionDimensionNode): ReadonlySet<number> {
const metadata = node.metadata
if (!(typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata))) {
return new Set()
}
const value = metadata.suppressedDimensionSegmentIndexes
if (!Array.isArray(value)) return new Set()
return new Set(
value.filter(
(entry): entry is number =>
typeof entry === 'number' && Number.isInteger(entry) && entry >= 0,
),
)
}
function dimensionGroup(children: FloorplanGeometry[]): FloorplanGeometry {
return { kind: 'group', children }
}
function lengthFormatOptions(node: ConstructionDimensionNode): ConstructionLengthFormatOptions {
return {
imperialPrecision: node.imperialPrecision,
metricNotation: node.metricNotation,
}
}
function notation(
node: ConstructionDimensionNode,
base: string,
dangling: boolean,
includeFeatureCount = true,
): string {
const repeated = includeFeatureCount && node.featureCount > 1 ? `${node.featureCount} x ` : ''
const content = node.textOverride ?? `${repeated}${base}`
const decorated = `${node.prefix}${content}${node.suffix}`
return dangling ? `UNLINKED · ${decorated}` : decorated
}
function dimensionGeometry(
node: ConstructionDimensionNode,
start: FloorplanPoint,
end: FloorplanPoint,
dimensionStart: FloorplanPoint,
dimensionEnd: FloorplanPoint,
offsetNormal: FloorplanPoint,
text: string,
stroke: string,
): FloorplanGeometry {
return {
kind: 'dimension',
start,
end,
dimensionStart,
dimensionEnd,
offsetNormal,
offsetDistance: 0,
extensionStartGap: node.extensionStartGap,
extensionOvershoot: node.extensionOvershoot,
terminator: node.terminator,
textPosition: node.textPosition,
text,
stroke,
}
}
function arcGeometry(
center: FloorplanPoint,
radius: number,
startAngle: number,
sweep: number,
stroke: string,
): FloorplanGeometry {
const start = arcPoint(center, radius, startAngle)
const end = arcPoint(center, radius, startAngle + sweep)
return {
kind: 'path',
d: `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep >= 0 ? 1 : 0} ${end[0]} ${end[1]}`,
...lineStyle(stroke),
}
}
function styledLine(
start: FloorplanPoint,
end: FloorplanPoint,
stroke: string,
strokeDasharray?: string,
): FloorplanGeometry {
return {
kind: 'line',
x1: start[0],
y1: start[1],
x2: end[0],
y2: end[1],
...lineStyle(stroke, strokeDasharray),
}
}
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
}
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
return {
fill: 'none',
stroke,
strokeWidth: 0.9,
strokeDasharray,
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'butt',
strokeLinejoin: 'miter',
}
}
function labelGeometry(
point: FloorplanPoint,
text: string,
labelAngle: number,
screenUpright = false,
offsetPx = 0,
): FloorplanGeometry {
return {
kind: 'dimension-label',
cx: point[0],
cy: point[1],
text,
angle: labelAngle,
screenUpright,
offsetPx,
appearance: 'outlined',
}
}
function centerMark(
center: FloorplanPoint,
radius: number,
stroke: string,
force = false,
): FloorplanGeometry[] {
if (!force && radius <= EPSILON) return []
const half = Math.min(0.22, Math.max(0.1, radius * 0.18))
const gap = Math.min(0.045, half * 0.3)
return [
styledLine([center[0] - half, center[1]], [center[0] - gap, center[1]], stroke),
styledLine([center[0] + gap, center[1]], [center[0] + half, center[1]], stroke),
styledLine([center[0], center[1] - half], [center[0], center[1] - gap], stroke),
styledLine([center[0], center[1] + gap], [center[0], center[1] + half], stroke),
]
}
function openArrow(
tip: FloorplanPoint,
toward: FloorplanPoint,
stroke: string,
): FloorplanGeometry[] {
const direction = normalized(tip, toward)
if (!direction) return []
const length = 0.15
const halfWidth = 0.055
const base: FloorplanPoint = [tip[0] + direction[0] * length, tip[1] + direction[1] * length]
const normal: FloorplanPoint = [-direction[1], direction[0]]
return [
styledLine(tip, [base[0] + normal[0] * halfWidth, base[1] + normal[1] * halfWidth], stroke),
styledLine(tip, [base[0] - normal[0] * halfWidth, base[1] - normal[1] * halfWidth], stroke),
]
}
function baselineHandle(point: FloorplanPoint): FloorplanGeometry {
return {
kind: 'endpoint-handle',
point,
state: 'idle',
variant: 'curve',
affordance: 'move-construction-dimension-baseline',
payload: null,
}
}
function anchorHandles(points: readonly MeasurementPoint[]): FloorplanGeometry[] {
return witnessHandles(points.map((point): FloorplanPoint => [point[0], point[2]]))
}
function witnessHandles(points: readonly FloorplanPoint[]): FloorplanGeometry[] {
return points.map((point, witnessIndex) => ({
kind: 'endpoint-handle',
point,
state: 'idle',
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex },
}))
}
function hitLine(start: FloorplanPoint, end: FloorplanPoint): FloorplanGeometry {
return {
kind: 'hit-line',
x1: start[0],
y1: start[1],
x2: end[0],
y2: end[1],
strokeWidthPx: 12,
}
}
function arcPoint(center: FloorplanPoint, radius: number, pointAngle: number): FloorplanPoint {
return [center[0] + Math.cos(pointAngle) * radius, center[1] + Math.sin(pointAngle) * radius]
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= EPSILON ? null : [dx / magnitude, dy / magnitude]
}
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.atan2(second[1] - first[1], second[0] - first[0])
}
function formatDegrees(value: number): string {
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
}
@@ -0,0 +1,154 @@
import type {
ConstructionDimensionMode,
ConstructionDimensionNode,
FloorplanPoint,
MeasurementPoint,
} from '@pascal-app/core'
export type ConstructionDimensionSegmentLayout = {
dimensionStart: FloorplanPoint
dimensionEnd: FloorplanPoint
value: number
witnessStart: FloorplanPoint
witnessEnd: FloorplanPoint
}
export type ConstructionDimensionLayout = {
dimensionPoints: FloorplanPoint[]
direction: FloorplanPoint
midpoint: FloorplanPoint
normal: FloorplanPoint
segments: ConstructionDimensionSegmentLayout[]
witnessPoints: FloorplanPoint[]
}
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
export type CircularConstructionDimensionLayout = {
center: FloorplanPoint
start: FloorplanPoint
end: FloorplanPoint | null
radius: number
startAngle: number
endAngle: number
sweep: number
chordLength: number
arcLength: number
}
export function resolveCircularConstructionDimensionLayout(
mode: ConstructionDimensionMode,
anchors: readonly MeasurementPoint[],
): CircularConstructionDimensionLayout | null {
if (anchors.length < 2) return null
const first = project(anchors[0]!)
const second = project(anchors[1]!)
if (mode === 'diameter') {
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
const radius = distance(first, second) / 2
if (radius <= 1e-9) return null
return {
center,
start: first,
end: second,
radius,
startAngle: Math.atan2(first[1] - center[1], first[0] - center[0]),
endAngle: Math.atan2(second[1] - center[1], second[0] - center[0]),
sweep: Math.PI,
chordLength: radius * 2,
arcLength: Math.PI * radius,
}
}
const usesMiddleCenter = mode === 'arc-length' || mode === 'angular'
const center = usesMiddleCenter ? second : first
const start = usesMiddleCenter ? first : second
const radius = distance(center, start)
if (radius <= 1e-9) return null
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0])
const endAnchor = anchors[2]
const end = endAnchor ? project(endAnchor) : null
const endAngle = end ? Math.atan2(end[1] - center[1], end[0] - center[0]) : startAngle
const sweep = end ? normalizedSignedSweep(startAngle, endAngle) : 0
return {
center,
start,
end,
radius,
startAngle,
endAngle,
sweep,
chordLength: end ? distance(start, end) : radius,
arcLength: Math.abs(sweep) * radius,
}
}
function normalizedSignedSweep(startAngle: number, endAngle: number): number {
let sweep = endAngle - startAngle
while (sweep > Math.PI) sweep -= Math.PI * 2
while (sweep <= -Math.PI) sweep += Math.PI * 2
return sweep
}
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
export function resolveConstructionDimensionLayout(
node: Pick<ConstructionDimensionNode, 'baseline' | 'chainMode'>,
anchors: readonly MeasurementPoint[],
): ConstructionDimensionLayout {
if (anchors.length < 2) {
throw new Error('Construction dimension layout requires at least two anchors')
}
const magnitude = Math.hypot(node.baseline.direction[0], node.baseline.direction[1])
const direction: FloorplanPoint = [
node.baseline.direction[0] / magnitude,
node.baseline.direction[1] / magnitude,
]
const normal: FloorplanPoint = [-direction[1], direction[0]]
const witnessPoints = anchors.map(project)
const dimensionPoints = witnessPoints.map((point): FloorplanPoint => {
const deltaX = point[0] - node.baseline.origin[0]
const deltaY = point[1] - node.baseline.origin[1]
const distance = deltaX * direction[0] + deltaY * direction[1]
return [
node.baseline.origin[0] + distance * direction[0],
node.baseline.origin[1] + distance * direction[1],
]
})
const segmentIndexes =
node.chainMode === 'continuous'
? witnessPoints.slice(0, -1).map((_, index) => [index, index + 1] as const)
: Array.from(
{ length: Math.floor(witnessPoints.length / 2) },
(_, index) => [index * 2, index * 2 + 1] as const,
)
const segments = segmentIndexes.map(([startIndex, endIndex]) => {
const witnessStart = witnessPoints[startIndex]!
const witnessEnd = witnessPoints[endIndex]!
const dimensionStart = dimensionPoints[startIndex]!
const dimensionEnd = dimensionPoints[endIndex]!
return {
dimensionStart,
dimensionEnd,
value: Math.abs(
(witnessEnd[0] - witnessStart[0]) * direction[0] +
(witnessEnd[1] - witnessStart[1]) * direction[1],
),
witnessStart,
witnessEnd,
}
})
const first = dimensionPoints[0]!
const last = dimensionPoints.at(-1)!
return {
dimensionPoints,
direction,
midpoint: [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2],
normal,
segments,
witnessPoints,
}
}
@@ -0,0 +1,6 @@
export { constructionDimensionDefinition } from './definition'
export { buildConstructionDimensionFloorplan } from './floorplan'
export {
resolveCircularConstructionDimensionLayout,
resolveConstructionDimensionLayout,
} from './geometry'
@@ -0,0 +1,416 @@
'use client'
import {
type AnyNodeId,
type ConstructionDimensionDatumPolicy,
type ConstructionDimensionDrawingPresentation,
type ConstructionDimensionImperialPrecision,
type ConstructionDimensionMetricNotation,
type ConstructionDimensionNode,
type ConstructionDimensionTerminator,
type ConstructionDimensionTextPosition,
type ConstructionDrawingType,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
DRAWING_TYPE_OPTIONS,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useDrawingView,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Trash2 } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
const MODE_LABELS: Record<ConstructionDimensionNode['mode'], string> = {
linear: 'Linear',
radius: 'Radius',
diameter: 'Diameter',
'center-mark': 'Center mark',
chord: 'Chord',
'arc-length': 'Arc length',
angular: 'Angular',
coordinate: 'Coordinate',
}
const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [
{ label: 'Centerline', value: 'centerline' },
{ label: 'Wall face', value: 'wall-face' },
{ label: 'Structural face', value: 'structural-face' },
{ label: 'Finish face', value: 'finish-face' },
]
const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [
{ label: 'Architectural tick', value: 'architectural-tick' },
{ label: 'Filled arrow', value: 'filled-arrow' },
{ label: 'Open arrow', value: 'open-arrow' },
{ label: 'Dot', value: 'dot' },
]
const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [
{ label: 'Above line', value: 'above' },
{ label: 'Centered on line', value: 'centered' },
]
const IMPERIAL_PRECISION_OPTIONS: Array<{
label: string
value: ConstructionDimensionImperialPrecision
}> = [
{ label: 'Nearest inch', value: '1' },
{ label: 'Nearest 1/2 inch', value: '1/2' },
{ label: 'Nearest 1/4 inch', value: '1/4' },
{ label: 'Nearest 1/8 inch', value: '1/8' },
{ label: 'Nearest 1/16 inch', value: '1/16' },
]
const METRIC_NOTATION_OPTIONS: Array<{
label: string
value: ConstructionDimensionMetricNotation
}> = [
{ label: 'Meters', value: 'meters' },
{ label: 'Millimeters', value: 'millimeters' },
]
export default function ConstructionDimensionPanel() {
const selectedId = useViewer((state) => state.selection.selectedIds[0])
const setSelection = useViewer((state) => state.setSelection)
const dimension = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined
return node?.type === 'construction-dimension' ? node : null
})
const foundationControllers = useScene(
useShallow((state) =>
Object.values(state.nodes).filter(
(candidate): candidate is ConstructionDimensionNode =>
candidate.type === 'construction-dimension' &&
candidate.id !== dimension?.id &&
candidate.drawingType === 'foundation-plan',
),
),
)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const activeDrawingType = useDrawingView((state) => state.drawingType)
if (!(dimension && selectedId)) return null
const update = (patch: Partial<ConstructionDimensionNode>) => updateNode(dimension.id, patch)
const supportsCenterMark = ['radius', 'diameter', 'arc-length', 'angular'].includes(
dimension.mode,
)
const activeDrawingLabel =
DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan'
const activePresentation = resolveConstructionDimensionDrawingPresentation(
dimension,
activeDrawingType,
)
const activeDrawingOverride = resolveConstructionDimensionDrawingOverride(
dimension,
activeDrawingType,
)
const suppressedSegmentsText = formatSuppressedSegments(
activeDrawingOverride?.suppressedSegmentIndexes ?? [],
)
const updateDrawingPresentation = (
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
) => {
const drawingOverrides = setConstructionDimensionDrawingPresentation(
dimension,
drawingType,
presentation,
)
update({
drawingOverrides,
...(presentation === 'controlled' && !dimension.controllingDimensionId
? { controllingDimensionId: foundationControllers[0]?.id ?? null }
: {}),
})
}
const updateSuppressedSegments = (value: string) => {
update({
drawingOverrides: setConstructionDimensionDrawingSuppressedSegments(
dimension,
activeDrawingType,
parseSuppressedSegments(value),
),
})
}
return (
<PanelWrapper
icon="/icons/blueprint.webp"
onClose={() => setSelection({ selectedIds: [] })}
title="Construction Dimension"
width={320}
>
<PanelSection title="Dimension">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Mode</span>
<span className="font-medium text-foreground">{MODE_LABELS[dimension.mode]}</span>
</div>
<SliderControl
label="Feature count"
max={999}
min={1}
onChange={(featureCount) => update({ featureCount })}
precision={0}
step={1}
value={dimension.featureCount}
/>
{supportsCenterMark ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Center mark</span>
<input
checked={dimension.showCenterMark}
onChange={(event) => update({ showCenterMark: event.target.checked })}
type="checkbox"
/>
</label>
) : null}
</PanelSection>
<PanelSection title="Drawing coordination">
<SelectField
label="Primary drawing"
onChange={(drawingType) =>
update({ drawingType: drawingType as ConstructionDrawingType })
}
options={DRAWING_TYPE_OPTIONS.map((option) => ({
label: option.label,
value: option.id,
}))}
value={dimension.drawingType}
/>
<SelectField
label={`${activeDrawingLabel} presentation`}
onChange={(presentation) =>
updateDrawingPresentation(
activeDrawingType,
presentation as ConstructionDimensionDrawingPresentation,
)
}
options={[
{ label: 'Shown', value: 'shown' },
{ label: 'Omitted', value: 'omit' },
...(activeDrawingType === 'floor-plan'
? [{ label: 'Controlled by foundation', value: 'controlled' }]
: []),
]}
value={activePresentation}
/>
{activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? (
<SelectField
disabled={foundationControllers.length === 0}
label="Foundation controller"
onChange={(controllingDimensionId) =>
update({
controllingDimensionId: controllingDimensionId as NonNullable<
ConstructionDimensionNode['controllingDimensionId']
>,
})
}
options={foundationControllers.map((controller) => ({
label: controller.name || 'Foundation dimension',
value: controller.id,
}))}
placeholder="No foundation dimensions"
value={dimension.controllingDimensionId ?? ''}
/>
) : null}
<p className="text-muted-foreground text-xs">
Linked dimensions reuse the controller's associative anchors and update with it.
</p>
<TextField
label={`${activeDrawingLabel} suppressed segments`}
onCommit={updateSuppressedSegments}
placeholder="e.g. 2, 4"
value={suppressedSegmentsText}
/>
<p className="text-muted-foreground text-xs">
Segment numbers are one-based and apply only in this drawing view.
</p>
</PanelSection>
<PanelSection title="Notation">
<TextField
label="Prefix"
onCommit={(prefix) => update({ prefix })}
value={dimension.prefix}
/>
<TextField
label="Suffix"
onCommit={(suffix) => update({ suffix })}
value={dimension.suffix}
/>
<TextField
label="Text override"
onCommit={(textOverride) => update({ textOverride: textOverride || null })}
placeholder="Use measured value"
value={dimension.textOverride ?? ''}
/>
</PanelSection>
<PanelSection title="Standards">
<SelectField
label="Datum policy"
onChange={(datumPolicy) =>
update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy })
}
options={DATUM_POLICY_OPTIONS}
value={dimension.datumPolicy}
/>
<SelectField
label="Terminator"
onChange={(terminator) =>
update({ terminator: terminator as ConstructionDimensionTerminator })
}
options={TERMINATOR_OPTIONS}
value={dimension.terminator}
/>
<SelectField
label="Text position"
onChange={(textPosition) =>
update({ textPosition: textPosition as ConstructionDimensionTextPosition })
}
options={TEXT_POSITION_OPTIONS}
value={dimension.textPosition}
/>
<SelectField
label="Imperial precision"
onChange={(imperialPrecision) =>
update({
imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision,
})
}
options={IMPERIAL_PRECISION_OPTIONS}
value={dimension.imperialPrecision}
/>
<SelectField
label="Metric notation"
onChange={(metricNotation) =>
update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation })
}
options={METRIC_NOTATION_OPTIONS}
value={dimension.metricNotation}
/>
<SliderControl
label="Extension gap"
max={0.5}
min={0}
onChange={(extensionStartGap) => update({ extensionStartGap })}
precision={3}
step={0.005}
value={dimension.extensionStartGap}
/>
<SliderControl
label="Extension overshoot"
max={0.5}
min={0}
onChange={(extensionOvershoot) => update({ extensionOvershoot })}
precision={3}
step={0.005}
value={dimension.extensionOvershoot}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={() => {
triggerSFX('sfx:structure-delete')
deleteNode(dimension.id)
setSelection({ selectedIds: [] })
}}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
function parseSuppressedSegments(value: string): number[] {
return [
...new Set(
value
.split(/[,\s]+/)
.map((part) => Number.parseInt(part, 10))
.filter((index) => Number.isInteger(index) && index > 0)
.map((index) => index - 1),
),
].sort((left, right) => left - right)
}
function formatSuppressedSegments(indexes: readonly number[]): string {
return indexes.map((index) => index + 1).join(', ')
}
function SelectField({
label,
value,
options,
placeholder,
disabled,
onChange,
}: {
label: string
value: string
options: Array<{ label: string; value: string }>
placeholder?: string
disabled?: boolean
onChange: (value: string) => void
}) {
return (
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<select
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground disabled:opacity-50"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={value}
>
{placeholder && options.length === 0 ? <option value="">{placeholder}</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)
}
function TextField({
label,
value,
placeholder,
onCommit,
}: {
label: string
value: string
placeholder?: string
onCommit: (value: string) => void
}) {
return (
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<input
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground"
defaultValue={value}
key={value}
onBlur={(event) => onCommit(event.target.value)}
placeholder={placeholder}
/>
</label>
)
}
@@ -0,0 +1,6 @@
import type { ConstructionDimensionNode, ParametricDescriptor } from '@pascal-app/core'
export const constructionDimensionParametrics: ParametricDescriptor<ConstructionDimensionNode> = {
groups: [],
customPanel: () => import('./panel'),
}
@@ -0,0 +1,6 @@
export {
ConstructionDimensionBaseline,
ConstructionDimensionChainMode,
ConstructionDimensionMode,
ConstructionDimensionNode,
} from '@pascal-app/core'
+12 -1
View File
@@ -6,6 +6,11 @@ import type {
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import {
buildDoorFloorplanSchedule,
computeDoorFloorplanLevelData,
} from '../shared/opening-documentation'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
@@ -164,9 +169,14 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schemaVersion: 2,
schema: DoorNode,
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
schedule: buildDoorFloorplanSchedule,
} satisfies FloorplanNodeExtension<DoorNodeType>,
},
surfaceRole: 'joinery',
// Leverage the schema's zod `.default()` annotations to compute the
@@ -222,6 +232,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
// direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan,
computeFloorplanLevelData: computeDoorFloorplanLevelData,
floorplanDependsOnSiblings: true,
// Opening symbols position from `ctx.parent` (the host wall); merge the
// walls' live drag overrides so the symbol tracks a wall / group drag in
+37 -1
View File
@@ -5,6 +5,11 @@ import type {
GeometryContext,
WallNode,
} from '@pascal-app/core'
import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor'
import {
buildOpeningMarkAnnotation,
type OpeningFloorplanLevelData,
} from '../shared/opening-documentation'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/**
@@ -722,7 +727,38 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
}
}
return { kind: 'group', children }
const markAnnotation = buildOpeningMarkAnnotation(
node,
wall,
ctx.levelData as OpeningFloorplanLevelData | undefined,
{
preferredSide: swingSign === 1 ? -1 : 1,
stroke: showSelectedChrome ? '#f97316' : '#334155',
},
)
if (markAnnotation) children.push(markAnnotation)
return { kind: 'group', children: children.map(markDoorPlanObstacle) }
}
function markDoorPlanObstacle(geometry: FloorplanGeometry): FloorplanGeometry {
if (geometry.kind === 'group') {
const isOpeningMark = readFloorplanGeometryMetadata(geometry).annotationRole === 'opening-mark'
return isOpeningMark
? geometry
: { ...geometry, children: geometry.children.map(markDoorPlanObstacle) }
}
if (
geometry.kind === 'path' ||
geometry.kind === 'polygon' ||
geometry.kind === 'polyline' ||
geometry.kind === 'rect' ||
geometry.kind === 'circle' ||
geometry.kind === 'line'
) {
return withFloorplanGeometryMetadata(geometry, { annotationObstacle: 'bounds' })
}
return geometry
}
/**
+17
View File
@@ -16,6 +16,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { OpeningDocumentationFields } from '../shared/opening-documentation-fields'
import { scaleHandleHeight } from './door-math'
const doorTypeOptions = [
@@ -254,6 +255,7 @@ export default function DoorPanel() {
useScene.temporal.getState().pause()
const cloned = structuredClone(node) as any
delete cloned.id
delete cloned.mark
cloned.metadata = { ...cloned.metadata, isNew: true }
const duplicate = DoorNode.parse(cloned)
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
@@ -583,6 +585,21 @@ export default function DoorPanel() {
)}
</PanelSection>
<PanelSection title="Documentation">
<OpeningDocumentationFields
constructionType={node.constructionType}
dimensionReference={node.dimensionReference}
finishOpeningHeight={node.finishOpeningHeight}
finishOpeningWidth={node.finishOpeningWidth}
mark={node.mark}
masonryOpeningHeight={node.masonryOpeningHeight}
masonryOpeningWidth={node.masonryOpeningWidth}
onChange={handleUpdate}
roughOpeningHeight={node.roughOpeningHeight}
roughOpeningWidth={node.roughOpeningWidth}
/>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={
+5 -1
View File
@@ -1 +1,5 @@
export { DoorNode } from '@pascal-app/core'
export {
DoorNode,
OpeningConstructionType,
OpeningDimensionReference,
} from '@pascal-app/core'
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
import { drawingSheetDefinition } from './definition'
describe('drawingSheetDefinition', () => {
test('registers persistent drawing sheets as non-geometric document nodes', () => {
expect(drawingSheetDefinition.kind).toBe('drawing-sheet')
expect(drawingSheetDefinition.bake).toBe('strip')
expect(drawingSheetDefinition.schemaVersion).toBe(4)
expect(drawingSheetDefinition.dirtyTracking).toBe(false)
expect(drawingSheetDefinition.capabilities).toMatchObject({
deletable: true,
duplicable: true,
presettable: false,
})
})
test('produces schema-valid defaults', () => {
expect(
drawingSheetDefinition.schema.safeParse({
id: 'drawing-sheet_default',
type: 'drawing-sheet',
...drawingSheetDefinition.defaults(),
}).success,
).toBe(true)
})
test('contributes drawing-sheet matching through the editor extension', () => {
const sheet = drawingSheetDefinition.schema.parse({
id: 'drawing-sheet_a101',
placedViews: [
{
id: 'drawing-view_floor',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '1',
title: 'Main floor',
scale: '1:50',
},
],
})
const resolveDrawingSheet =
getFloorplanNodeExtension(drawingSheetDefinition)?.resolveDrawingSheet
expect(
resolveDrawingSheet?.({ node: sheet, levelId: 'level_main', drawingType: 'floor-plan' }),
).toBe(sheet)
expect(
resolveDrawingSheet?.({ node: sheet, levelId: 'level_upper', drawingType: 'floor-plan' }),
).toBeNull()
})
})
@@ -0,0 +1,51 @@
import { DrawingSheetNode as DrawingSheetNodeSchema, type NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { DrawingSheetNode } from './schema'
export const drawingSheetDefinition: NodeDefinition<typeof DrawingSheetNode> = {
kind: 'drawing-sheet',
bake: 'strip',
schemaVersion: 4,
schema: DrawingSheetNode,
category: 'analysis',
extensions: {
'pascal:editor/floorplan': {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
} satisfies FloorplanNodeExtension<DrawingSheetNodeSchema>,
},
defaults: () => {
const stub = DrawingSheetNodeSchema.parse({
id: 'drawing-sheet_default' as never,
type: 'drawing-sheet',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
deletable: true,
duplicable: true,
presettable: false,
},
dirtyTracking: false,
presentation: {
label: 'Drawing Sheet',
description: 'A persistent construction-document sheet with placed views and title-block data.',
icon: { kind: 'iconify', name: 'lucide:file-text' },
hidden: true,
},
mcp: {
description:
'A persistent construction-document sheet containing paper setup, placed drawing views, notes, schedules, and title-block metadata.',
},
}
@@ -0,0 +1 @@
export { drawingSheetDefinition } from './definition'

Some files were not shown because too many files have changed in this diff Show More