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
@@ -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'
@@ -0,0 +1,18 @@
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
} from '@pascal-app/core'
+1
View File
@@ -162,6 +162,7 @@ function toMiterWall(segment: SegmentLike): WallNode {
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start: segment.start,
end: segment.end,
thickness: segment.thickness,
+38
View File
@@ -5,10 +5,12 @@ import { cabinetDefinition, cabinetModuleDefinition } from './cabinet'
import { ceilingDefinition } from './ceiling'
import { chimneyDefinition } from './chimney'
import { columnDefinition } from './column'
import { constructionDimensionDefinition } from './construction-dimension'
import { cupolaDefinition } from './cupola'
import { doorDefinition } from './door'
import { dormerDefinition } from './dormer'
import { downspoutDefinition } from './downspout'
import { drawingSheetDefinition } from './drawing-sheet'
import { ductFittingDefinition } from './duct-fitting'
import { ductSegmentDefinition } from './duct-segment'
import { ductTerminalDefinition } from './duct-terminal'
@@ -38,6 +40,7 @@ import { solarPanelDefinition } from './solar-panel'
import { spawnDefinition } from './spawn'
import { stairDefinition } from './stair'
import { stairSegmentDefinition } from './stair-segment'
import { structuralGridDefinition } from './structural-grid'
import { turbineVentDefinition } from './turbine-vent'
import { wallDefinition } from './wall'
import { windowDefinition } from './window'
@@ -90,6 +93,9 @@ export const builtinPlugin: Plugin = {
guideDefinition as unknown as AnyNodeDefinition,
scanDefinition as unknown as AnyNodeDefinition,
measurementDefinition as unknown as AnyNodeDefinition,
constructionDimensionDefinition as unknown as AnyNodeDefinition,
drawingSheetDefinition as unknown as AnyNodeDefinition,
structuralGridDefinition as unknown as AnyNodeDefinition,
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
boxVentDefinition as unknown as AnyNodeDefinition,
ridgeVentDefinition as unknown as AnyNodeDefinition,
@@ -130,10 +136,12 @@ export {
export { ceilingDefinition } from './ceiling'
export { chimneyDefinition } from './chimney'
export { columnDefinition } from './column'
export { constructionDimensionDefinition } from './construction-dimension'
export { cupolaDefinition } from './cupola'
export { doorDefinition } from './door'
export { dormerDefinition } from './dormer'
export { downspoutDefinition } from './downspout'
export { drawingSheetDefinition } from './drawing-sheet'
export { ductFittingDefinition } from './duct-fitting'
export { ductSegmentDefinition } from './duct-segment'
export { ductTerminalDefinition } from './duct-terminal'
@@ -155,6 +163,35 @@ export { ridgeVentDefinition } from './ridge-vent'
export { roofDefinition } from './roof'
export { roofSegmentDefinition } from './roof-segment'
export { scanDefinition } from './scan'
export {
type BuildClearanceAdvisoriesOptions,
buildClearanceAdvisories,
type ClearanceAdvisory,
type ClearanceAdvisoryCategory,
type ClearanceAdvisorySeverity,
type ClearanceEvidence,
type ClearanceProfile,
type ClearanceRule,
type ClearanceRuleSource,
DEFAULT_CLEARANCE_PROFILES,
} from './shared/clearance-advisories'
export {
type BuildConstructionModuleAdvisoriesOptions,
buildConstructionModuleAdvisories,
type ConstructionModuleAdvisory,
type ConstructionModuleAdvisorySeverity,
type ConstructionModuleMeasurementKind,
type ConstructionModuleProfile,
type ConstructionModuleSystem,
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
} from './shared/construction-module-advisories'
export {
type BuildDimensionCompletenessAuditOptions,
buildDimensionCompletenessAudit,
type DimensionCompletenessIssue,
type DimensionCompletenessIssueKind,
type DimensionCompletenessIssueSeverity,
} from './shared/dimension-completeness-audit'
export { shelfDefinition } from './shelf'
export { siteDefinition } from './site'
export { skylightDefinition } from './skylight'
@@ -163,6 +200,7 @@ export { solarPanelDefinition } from './solar-panel'
export { spawnDefinition } from './spawn'
export { stairDefinition } from './stair'
export { stairSegmentDefinition } from './stair-segment'
export { structuralGridDefinition } from './structural-grid'
export { turbineVentDefinition } from './turbine-vent'
export { wallDefinition } from './wall'
export { windowDefinition } from './window'
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core'
import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor'
import {
createFloorplanContextExtensions,
MEASUREMENT_ACTIVE_COLOR,
MEASUREMENT_FLOORPLAN_COLOR,
} from '@pascal-app/editor'
import { buildMeasurementFloorplan } from './floorplan'
const palette = {
@@ -21,7 +25,11 @@ const palette = {
measurementLabelText: '#0f172a',
}
const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({
const context = (
unit: 'metric' | 'imperial',
selected = false,
metricNotation: 'meters' | 'millimeters' = 'meters',
): GeometryContext => ({
resolve: () => undefined,
children: [],
siblings: [],
@@ -34,6 +42,7 @@ const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext
moving: false,
palette,
},
extensions: createFloorplanContextExtensions({ metricNotation }),
})
const labels = (geometry: FloorplanGeometry): string[] => {
@@ -69,6 +78,23 @@ describe('buildMeasurementFloorplan', () => {
).toMatchObject({ appearance: 'outlined' })
})
test('formats metric distance labels in millimeters', () => {
const node = MeasurementNode.parse({
id: 'measurement_distance_mm',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
[0, 0, 0],
[3.048, 0, 0],
],
},
})
const metric = buildMeasurementFloorplan(node, context('metric', false, 'millimeters'))
expect(metric && labels(metric)).toEqual(['3048mm'])
})
test('uses indigo analysis colors in plan view', () => {
const node = MeasurementNode.parse({
id: 'measurement_appearance',
+105 -90
View File
@@ -19,6 +19,8 @@ import {
formatVolumeLabel,
measurementFloorplanPresentationColor,
measurementPolygonLabelAnchor,
readFloorplanContext,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import { measurementResolvedEditPoints } from './edit'
import { resolveMeasurementNode } from './resolve'
@@ -46,6 +48,7 @@ export function buildMeasurementFloorplan(
if (node.visible === false) return null
const unit = ctx.viewState?.unit ?? 'metric'
const metricNotation = readFloorplanContext(ctx).metricNotation
const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id))
const measurement = resolved.payload
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
@@ -85,77 +88,83 @@ export function buildMeasurementFloorplan(
]
: []
return {
kind: 'group',
children: [
{ kind: 'line', x1, y1, x2, y2, ...style },
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
...collapsedHitTarget,
{
kind: 'circle',
cx: x1,
cy: y1,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'circle',
cx: x2,
cy: y2,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: (x1 + x2) / 2,
cy: (y1 + y2) / 2,
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`,
angle: Math.atan2(y2 - y1, x2 - x1),
offsetPx: 14,
},
...editHandles,
],
}
return withFloorplanGeometryMetadata(
{
kind: 'group',
children: [
{ kind: 'line', x1, y1, x2, y2, ...style },
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
...collapsedHitTarget,
{
kind: 'circle',
cx: x1,
cy: y1,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'circle',
cx: x2,
cy: y2,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: (x1 + x2) / 2,
cy: (y1 + y2) / 2,
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation)}`,
angle: Math.atan2(y2 - y1, x2 - x1),
offsetPx: 14,
},
...editHandles,
],
},
{ annotationRole: 'measurement' },
)
}
if (measurement.kind === 'angle') {
const [start, vertex, end] = measurement.points
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
return {
kind: 'group',
children: [
{
kind: 'polyline',
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
...style,
},
...(angleArc.length >= 2
? [
{
kind: 'polyline' as const,
points: angleArc.map(projectPoint),
...style,
strokeWidth: 3,
},
]
: []),
{
kind: 'dimension-label',
appearance: 'outlined',
cx: labelPoint[0],
cy: labelPoint[2],
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
angle: 0,
offsetPx: 10,
screenUpright: true,
},
...editHandles,
],
}
return withFloorplanGeometryMetadata(
{
kind: 'group',
children: [
{
kind: 'polyline',
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
...style,
},
...(angleArc.length >= 2
? [
{
kind: 'polyline' as const,
points: angleArc.map(projectPoint),
...style,
strokeWidth: 3,
},
]
: []),
{
kind: 'dimension-label',
appearance: 'outlined',
cx: labelPoint[0],
cy: labelPoint[2],
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
angle: 0,
offsetPx: 10,
screenUpright: true,
},
...editHandles,
],
},
{ annotationRole: 'measurement' },
)
}
if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
@@ -163,31 +172,34 @@ export function buildMeasurementFloorplan(
const label =
measurement.kind === 'area'
? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit, metricNotation)}`
return {
kind: 'group',
children: [
{
kind: 'polygon',
points: measurement.base.map(projectPoint),
fill: stroke,
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
pointerEvents: 'all',
...style,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: centroid[0],
cy: centroid[2],
text: `${statusPrefix}${label}`,
angle: 0,
screenUpright: true,
},
...editHandles,
],
}
return withFloorplanGeometryMetadata(
{
kind: 'group',
children: [
{
kind: 'polygon',
points: measurement.base.map(projectPoint),
fill: stroke,
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
pointerEvents: 'all',
...style,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: centroid[0],
cy: centroid[2],
text: `${statusPrefix}${label}`,
angle: 0,
screenUpright: true,
},
...editHandles,
],
},
{ annotationRole: 'measurement' },
)
}
const volume = measurement
@@ -232,5 +244,8 @@ export function buildMeasurementFloorplan(
})
children.push(...editHandles)
return { kind: 'group', children }
return withFloorplanGeometryMetadata(
{ kind: 'group', children },
{ annotationRole: 'measurement' },
)
}
+1
View File
@@ -11,5 +11,6 @@ export {
type ResolvedMeasurement,
type ResolvedMeasurementPayload,
remapMeasurementReferences,
resolveMeasurementAnchor,
resolveMeasurementNode,
} from './resolve'
+2 -2
View File
@@ -170,7 +170,7 @@ export function measurementFeaturePoint(
}
}
function resolveAnchor(
export function resolveMeasurementAnchor(
anchor: MeasurementAnchor,
resolve: NodeResolver,
): {
@@ -227,7 +227,7 @@ export function resolveMeasurementNode(
const dangling: MeasurementFeatureReference[] = []
const anchorNormals: Array<MeasurementPoint | null> = []
const point = (anchor: MeasurementAnchor) => {
const result = resolveAnchor(anchor, resolve)
const result = resolveMeasurementAnchor(anchor, resolve)
if (result.dangling) dangling.push(result.dangling)
anchorNormals.push(result.normal)
return result.point
@@ -0,0 +1,209 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
CabinetNode,
DoorNode,
ItemNode,
StairNode,
StairSegmentNode,
ZoneNode,
} from '@pascal-app/core'
import {
buildClearanceAdvisories,
type ClearanceProfile,
DEFAULT_CLEARANCE_PROFILES,
} from './clearance-advisories'
const adaProfile: ClearanceProfile = {
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'us-ada-2010-advisory')!,
enabled: true,
}
const officeProfile: ClearanceProfile = {
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'office-residential-advisory')!,
enabled: true,
}
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
describe('clearance advisories', () => {
test('keeps default clearance profiles optional and quiet', () => {
const narrowHall = ZoneNode.parse({
id: 'zone_hall',
name: 'Hallway',
polygon: [
[0, 0],
[0.8, 0],
[0.8, 4],
[0, 4],
],
})
expect(buildClearanceAdvisories(nodes(narrowHall))).toEqual([])
})
test('checks circulation, entry, and door clear widths with ADA provenance', () => {
const hall = ZoneNode.parse({
id: 'zone_hall',
name: 'North Corridor',
polygon: [
[0, 0],
[0.8, 0],
[0.8, 5],
[0, 5],
],
})
const entry = ZoneNode.parse({
id: 'zone_entry',
name: 'Entry vestibule',
polygon: [
[0, 0],
[0.86, 0],
[0.86, 2],
[0, 2],
],
})
const door = DoorNode.parse({
id: 'door_narrow',
width: 0.78,
})
const advisories = buildClearanceAdvisories(nodes(hall, entry, door), {
profiles: [adaProfile],
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
'ada-door-clear-opening',
'ada-entry-clear-width',
'ada-accessible-route-clear-width',
])
expect(advisories.every((advisory) => advisory.source.edition === '2010')).toBe(true)
expect(advisories.every((advisory) => advisory.severity === 'warning')).toBe(true)
})
test('reports missing fixture, cabinet, and appliance clearance evidence', () => {
const toilet = ItemNode.parse({
id: 'item_toilet',
asset: {
id: 'asset_toilet',
category: 'plumbing',
name: 'Accessible Toilet',
thumbnail: '',
src: 'asset://toilet.glb',
tags: ['fixture'],
},
})
const sinkCabinet = CabinetNode.parse({
id: 'cabinet_sink',
stack: [{ id: 'sink', type: 'sink' }],
})
const applianceCabinet = CabinetNode.parse({
id: 'cabinet_dishwasher',
stack: [{ id: 'dishwasher', type: 'dishwasher' }],
})
const advisories = buildClearanceAdvisories(nodes(toilet, sinkCabinet, applianceCabinet), {
profiles: [adaProfile, officeProfile],
})
expect(advisories.map((advisory) => advisory.id)).toEqual([
'clearance:office-residential-advisory:cabinet_dishwasher:office-appliance-front-clearance',
'clearance:office-residential-advisory:cabinet_dishwasher:office-cabinet-front-clearance',
'clearance:office-residential-advisory:cabinet_sink:office-cabinet-front-clearance',
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-depth',
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-width',
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-depth',
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-width',
])
expect(advisories.every((advisory) => advisory.measured === null)).toBe(true)
expect(advisories.every((advisory) => advisory.severity === 'info')).toBe(true)
})
test('accepts explicit clearance evidence for surrounding cabinet and fixture checks', () => {
const toilet = ItemNode.parse({
id: 'item_toilet',
asset: {
id: 'asset_toilet',
category: 'plumbing',
name: 'Accessible Toilet',
thumbnail: '',
src: 'asset://toilet.glb',
tags: ['fixture'],
},
})
const cabinet = CabinetNode.parse({
id: 'cabinet_base',
})
const advisories = buildClearanceAdvisories(nodes(toilet, cabinet), {
profiles: [adaProfile, officeProfile],
evidence: {
item_toilet: {
'ada-fixture-clear-floor-width': 0.9,
'ada-fixture-clear-floor-depth': 1.0,
},
cabinet_base: {
'office-cabinet-front-clearance': 1.0,
},
},
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual(['ada-fixture-clear-floor-depth'])
expect(advisories[0]?.measured).toBe(1)
expect(advisories[0]?.severity).toBe('warning')
})
test('checks closet depth and stair geometry from modeled dimensions', () => {
const closet = ZoneNode.parse({
id: 'zone_closet',
name: 'Bedroom Closet',
polygon: [
[0, 0],
[0.55, 0],
[0.55, 2],
[0, 2],
],
})
const stair = StairNode.parse({
id: 'stair_tall_riser',
width: 0.82,
totalRise: 2.8,
stepCount: 12,
})
const segment = StairSegmentNode.parse({
id: 'sseg_shallow_treads',
width: 1,
length: 2.2,
height: 2,
stepCount: 10,
})
const advisories = buildClearanceAdvisories(nodes(closet, stair, segment), {
profiles: [officeProfile],
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
'office-stair-tread-depth',
'office-stair-riser-height',
'office-stair-tread-depth',
'office-stair-width',
'office-closet-depth',
])
expect(advisories.every((advisory) => advisory.source.title.includes('Pascal'))).toBe(true)
})
test('can include disabled profiles for profile preview UIs', () => {
const door = DoorNode.parse({
id: 'door_preview',
width: 0.78,
})
const advisories = buildClearanceAdvisories(nodes(door), {
includeDisabled: true,
})
expect(advisories.map((advisory) => advisory.profileId)).toEqual(['us-ada-2010-advisory'])
})
})
@@ -0,0 +1,513 @@
import {
type AnyNode,
type CabinetModuleNode,
type CabinetNode,
type DoorNode,
type ItemNode,
resolveStairTotalRise,
type StairNode,
type StairSegmentNode,
type ZoneNode,
} from '@pascal-app/core'
import { formatConstructionLength } from './construction-length'
export type ClearanceAdvisoryCategory =
| 'circulation'
| 'entry'
| 'door-approach'
| 'fixture'
| 'cabinet'
| 'appliance'
| 'closet'
| 'stair'
export type ClearanceAdvisorySeverity = 'info' | 'warning'
export type ClearanceRuleSource = {
title: string
edition: string
section: string
url?: string
note?: string
}
export type ClearanceRule = {
id: string
category: ClearanceAdvisoryCategory
label: string
measurement:
| 'clear-width'
| 'clear-depth'
| 'clear-floor-width'
| 'clear-floor-depth'
| 'front-clearance'
| 'stair-width'
| 'tread-depth'
| 'riser-height'
minValue: number
source: ClearanceRuleSource
}
export type ClearanceProfile = {
id: string
label: string
jurisdiction?: string
enabled: boolean
rules: readonly ClearanceRule[]
}
export type ClearanceEvidence = Readonly<
Record<string, Partial<Record<ClearanceRule['id'], number>>>
>
export type BuildClearanceAdvisoriesOptions = {
profiles?: readonly ClearanceProfile[]
includeDisabled?: boolean
evidence?: ClearanceEvidence
}
export type ClearanceAdvisory = {
id: string
nodeId: string
nodeType: string
profileId: string
profileLabel: string
category: ClearanceAdvisoryCategory
ruleId: string
label: string
measured: number | null
required: number
severity: ClearanceAdvisorySeverity
source: ClearanceRuleSource
message: string
}
type ClearanceTarget = {
nodeId: string
nodeType: string
category: ClearanceAdvisoryCategory
measurements: Partial<Record<ClearanceRule['measurement'], number>>
}
const ADA_2010: Pick<ClearanceRuleSource, 'title' | 'edition' | 'url'> = {
title: '2010 ADA Standards for Accessible Design',
edition: '2010',
url: 'https://www.access-board.gov/ada/',
}
const OFFICE_STANDARD: Pick<ClearanceRuleSource, 'title' | 'edition'> = {
title: 'Pascal construction-document advisory profile',
edition: '2026-07-21',
}
export const DEFAULT_CLEARANCE_PROFILES: readonly ClearanceProfile[] = [
{
id: 'us-ada-2010-advisory',
label: 'U.S. ADA 2010 advisory checks',
jurisdiction: 'US',
enabled: false,
rules: [
{
id: 'ada-accessible-route-clear-width',
category: 'circulation',
label: 'accessible route clear width',
measurement: 'clear-width',
minValue: 36 * 0.0254,
source: {
...ADA_2010,
section: '403.5.1',
note: 'Accessible routes generally require 36 inches minimum clear width.',
},
},
{
id: 'ada-entry-clear-width',
category: 'entry',
label: 'entry clear width',
measurement: 'clear-width',
minValue: 36 * 0.0254,
source: {
...ADA_2010,
section: '403.5.1',
note: 'Entries serving an accessible route are checked against the route clear width.',
},
},
{
id: 'ada-door-clear-opening',
category: 'door-approach',
label: 'door clear opening',
measurement: 'clear-width',
minValue: 32 * 0.0254,
source: {
...ADA_2010,
section: '404.2.3',
note: 'Door openings on accessible routes require 32 inches minimum clear width.',
},
},
{
id: 'ada-fixture-clear-floor-width',
category: 'fixture',
label: 'fixture clear floor space width',
measurement: 'clear-floor-width',
minValue: 30 * 0.0254,
source: {
...ADA_2010,
section: '305.3',
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
},
},
{
id: 'ada-fixture-clear-floor-depth',
category: 'fixture',
label: 'fixture clear floor space depth',
measurement: 'clear-floor-depth',
minValue: 48 * 0.0254,
source: {
...ADA_2010,
section: '305.3',
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
},
},
],
},
{
id: 'office-residential-advisory',
label: 'Office residential advisory checks',
enabled: false,
rules: [
{
id: 'office-cabinet-front-clearance',
category: 'cabinet',
label: 'cabinet front working clearance',
measurement: 'front-clearance',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Kitchen working clearances',
note: 'Office drafting convention for cabinet and drawer operation clearance.',
},
},
{
id: 'office-appliance-front-clearance',
category: 'appliance',
label: 'appliance front working clearance',
measurement: 'front-clearance',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Kitchen appliance clearances',
note: 'Office drafting convention for appliance door and working clearance.',
},
},
{
id: 'office-closet-depth',
category: 'closet',
label: 'closet clear depth',
measurement: 'clear-depth',
minValue: 0.6,
source: {
...OFFICE_STANDARD,
section: 'Storage clearances',
note: 'Office drafting convention for reach-in closet depth.',
},
},
{
id: 'office-stair-width',
category: 'stair',
label: 'stair clear width',
measurement: 'stair-width',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Office drafting convention; verify against local stair code before permit use.',
},
},
{
id: 'office-stair-tread-depth',
category: 'stair',
label: 'stair tread depth',
measurement: 'tread-depth',
minValue: 0.25,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Office drafting convention; verify against local stair code before permit use.',
},
},
{
id: 'office-stair-riser-height',
category: 'stair',
label: 'stair riser height',
measurement: 'riser-height',
minValue: -0.2,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Negative minValue means measured riser height must be less than or equal to the absolute value.',
},
},
],
},
] as const
export function buildClearanceAdvisories(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildClearanceAdvisoriesOptions = {},
): ClearanceAdvisory[] {
const profiles = (options.profiles ?? DEFAULT_CLEARANCE_PROFILES).filter(
(profile) => options.includeDisabled === true || profile.enabled,
)
if (profiles.length === 0) return []
const targets = Object.values(nodes).flatMap((node) => clearanceTargets(node, nodes))
const advisories: ClearanceAdvisory[] = []
for (const target of targets) {
for (const profile of profiles) {
for (const rule of profile.rules) {
if (rule.category !== target.category) continue
const measured =
target.measurements[rule.measurement] ?? options.evidence?.[target.nodeId]?.[rule.id]
if (measured === undefined) {
advisories.push(clearanceAdvisory({ target, profile, rule, measured: null }))
continue
}
if (violatesClearanceRule(measured, rule)) {
advisories.push(clearanceAdvisory({ target, profile, rule, measured }))
}
}
}
}
return advisories.sort((left, right) => left.id.localeCompare(right.id))
}
function clearanceTargets(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): ClearanceTarget[] {
if (node.type === 'zone') return zoneTargets(node)
if (node.type === 'door') return doorTargets(node)
if (node.type === 'item') return itemTargets(node)
if (node.type === 'cabinet' || node.type === 'cabinet-module') return cabinetTargets(node)
if (node.type === 'stair') return stairTargets(node, nodes)
if (node.type === 'stair-segment') return stairSegmentTargets(node)
return []
}
function zoneTargets(zone: ZoneNode): ClearanceTarget[] {
const role = normalizedText([zone.name, zone.occupancy, String(zone.metadata ?? '')])
const dimensions = zoneClearDimensions(zone)
const targets: ClearanceTarget[] = []
if (containsAny(role, ['hall', 'hallway', 'corridor', 'passage', 'circulation'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'circulation',
measurements: { 'clear-width': dimensions.minSpan },
})
}
if (containsAny(role, ['entry', 'entrance', 'vestibule', 'foyer'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'entry',
measurements: { 'clear-width': dimensions.minSpan },
})
}
if (containsAny(role, ['closet', 'wardrobe'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'closet',
measurements: { 'clear-depth': dimensions.minSpan },
})
}
return targets
}
function doorTargets(door: DoorNode): ClearanceTarget[] {
return [
{
nodeId: door.id,
nodeType: door.type,
category: 'door-approach',
measurements: { 'clear-width': door.width },
},
]
}
function itemTargets(item: ItemNode): ClearanceTarget[] {
const text = normalizedText([
item.asset.name,
item.asset.category,
...(item.asset.tags ?? []),
...(item.asset.functionTags ?? []),
])
const targets: ClearanceTarget[] = []
if (containsAny(text, ['toilet', 'lavatory', 'sink', 'fixture', 'tub', 'shower', 'wc'])) {
targets.push({
nodeId: item.id,
nodeType: item.type,
category: 'fixture',
measurements: {},
})
}
if (containsAny(text, ['appliance', 'fridge', 'refrigerator', 'oven', 'range', 'dishwasher'])) {
targets.push({
nodeId: item.id,
nodeType: item.type,
category: 'appliance',
measurements: {},
})
}
return targets
}
function cabinetTargets(cabinet: CabinetNode | CabinetModuleNode): ClearanceTarget[] {
const targets: ClearanceTarget[] = [
{
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'cabinet',
measurements: {},
},
]
if ((cabinet.stack ?? []).some((compartment) => isApplianceCompartment(compartment.type))) {
targets.push({
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'appliance',
measurements: {},
})
}
if ((cabinet.stack ?? []).some((compartment) => compartment.type === 'sink')) {
targets.push({
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'fixture',
measurements: {},
})
}
return targets
}
function stairTargets(
stair: StairNode,
nodes: Readonly<Record<string, AnyNode>>,
): ClearanceTarget[] {
const measurements: ClearanceTarget['measurements'] = { 'stair-width': stair.width }
const totalRise = resolveStairTotalRise(stair, nodes as Record<string, AnyNode>)
if (stair.stepCount > 0 && totalRise > 0) {
measurements['riser-height'] = totalRise / stair.stepCount
}
return [
{
nodeId: stair.id,
nodeType: stair.type,
category: 'stair',
measurements,
},
]
}
function stairSegmentTargets(segment: StairSegmentNode): ClearanceTarget[] {
const measurements: ClearanceTarget['measurements'] = { 'stair-width': segment.width }
if (segment.segmentType === 'stair' && segment.stepCount > 0) {
measurements['tread-depth'] = segment.length / segment.stepCount
if (segment.height > 0) measurements['riser-height'] = segment.height / segment.stepCount
}
return [
{
nodeId: segment.id,
nodeType: segment.type,
category: 'stair',
measurements,
},
]
}
function clearanceAdvisory(args: {
target: ClearanceTarget
profile: ClearanceProfile
rule: ClearanceRule
measured: number | null
}): ClearanceAdvisory {
const { target, profile, rule, measured } = args
const measuredLabel =
measured === null ? 'not verified' : formatConstructionLength(measured, 'metric')
const requiredLabel = formatConstructionLength(Math.abs(rule.minValue), 'metric')
const comparator = rule.minValue < 0 ? 'at most' : 'at least'
return {
id: ['clearance', profile.id, target.nodeId, rule.id].join(':'),
nodeId: target.nodeId,
nodeType: target.nodeType,
profileId: profile.id,
profileLabel: profile.label,
category: rule.category,
ruleId: rule.id,
label: rule.label,
measured,
required: Math.abs(rule.minValue),
severity: measured === null ? 'info' : 'warning',
source: rule.source,
message:
measured === null
? `${titleCase(target.nodeType)} ${target.nodeId} requires ${rule.label} verification (${comparator} ${requiredLabel}) per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`
: `${titleCase(target.nodeType)} ${target.nodeId} ${rule.label} ${measuredLabel} is below ${requiredLabel} per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`,
}
}
function violatesClearanceRule(measured: number, rule: ClearanceRule): boolean {
if (!Number.isFinite(measured)) return true
if (rule.minValue < 0) return measured > Math.abs(rule.minValue)
return measured < rule.minValue
}
function zoneClearDimensions(zone: ZoneNode): { minSpan: number } {
const xs = zone.polygon.map((point) => point[0])
const zs = zone.polygon.map((point) => point[1])
if (xs.length === 0 || zs.length === 0) return { minSpan: 0 }
return {
minSpan: Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs)),
}
}
function normalizedText(parts: readonly string[]): string {
return parts.join(' ').toLowerCase()
}
function containsAny(text: string, needles: readonly string[]): boolean {
return needles.some((needle) => text.includes(needle))
}
function isApplianceCompartment(type: string): boolean {
return [
'oven',
'microwave',
'dishwasher',
'cooktop-gas',
'cooktop-induction',
'fridge-single',
'fridge-double',
'fridge-top-freezer',
'fridge-bottom-freezer',
].includes(type)
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -0,0 +1,43 @@
import type { DimensionTerminator, DimensionTextPosition } from '@pascal-app/core'
import type {
ConstructionImperialPrecision,
ConstructionMetricNotation,
} from './construction-length'
export type ConstructionDimensionDrawingStandard = {
datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face'
intersectionReferencePolicy: 'single' | 'both-faces'
terminator: DimensionTerminator
textPosition: DimensionTextPosition
imperialPrecision: ConstructionImperialPrecision
metricNotation: ConstructionMetricNotation
openingChainOffset: number
wallSpanOffset: number
firstOpeningWidthOffset: number
firstGeneralTierOffset: number
tierSpacing: number
extensionStartGap: number
extensionOvershoot: number
}
export const DEFAULT_CONSTRUCTION_DIMENSION_STANDARD = {
datumPolicy: 'wall-face',
intersectionReferencePolicy: 'single',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
openingChainOffset: 0.55,
wallSpanOffset: 1.05,
firstOpeningWidthOffset: 0.62,
firstGeneralTierOffset: 0.55,
tierSpacing: 0.62,
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
} satisfies ConstructionDimensionDrawingStandard
export function constructionDimensionStandard(
overrides: Partial<ConstructionDimensionDrawingStandard> = {},
): ConstructionDimensionDrawingStandard {
return { ...DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, ...overrides }
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import { formatConstructionLength } from './construction-length'
describe('formatConstructionLength profiles', () => {
test('keeps metre notation for interactive metric dimensions', () => {
expect(formatConstructionLength(3.456, 'metric')).toBe('3.46m')
expect(formatConstructionLength(-0.004, 'metric')).toBe('0m')
})
test('uses whole millimetres without a suffix for metric documents', () => {
expect(formatConstructionLength(3.4564, 'metric', 'document')).toBe('3456')
expect(formatConstructionLength(-0.004, 'metric', 'document')).toBe('-4')
})
test('keeps architectural imperial notation in document output', () => {
expect(formatConstructionLength(1.524, 'imperial', 'document')).toBe(`5'-0"`)
})
test('honors drafting standard precision and notation overrides', () => {
expect(
formatConstructionLength(1.524, 'metric', 'editor', { metricNotation: 'millimeters' }),
).toBe('1524')
expect(
formatConstructionLength((7 * 12 + 5.25) * 0.0254, 'imperial', 'editor', {
imperialPrecision: '1/2',
}),
).toBe(`7'-5 1/2"`)
})
})
@@ -0,0 +1,77 @@
const INCHES_PER_METER = 1 / 0.0254
const IMPERIAL_FRACTION_DENOMINATOR = 16
export type ConstructionLinearUnit = 'metric' | 'imperial'
export type ConstructionLengthProfile = 'editor' | 'document'
export type ConstructionMetricNotation = 'meters' | 'millimeters'
export type ConstructionImperialPrecision = '1' | '1/2' | '1/4' | '1/8' | '1/16'
export type ConstructionLengthFormatOptions = {
metricNotation?: ConstructionMetricNotation
imperialPrecision?: ConstructionImperialPrecision
}
export function formatConstructionLength(
meters: number,
unit: ConstructionLinearUnit,
profile: ConstructionLengthProfile = 'editor',
options: ConstructionLengthFormatOptions = {},
): string {
if (!Number.isFinite(meters)) return '--'
if (unit === 'metric') {
if (profile === 'document' || options.metricNotation === 'millimeters') {
return `${Math.round(meters * 1000)}`
}
const rounded = Number.parseFloat(Math.abs(meters).toFixed(2))
const sign = meters < 0 && rounded !== 0 ? '-' : ''
return `${sign}${rounded}m`
}
const sign = meters < 0 ? '-' : ''
const denominator = imperialPrecisionDenominator(options.imperialPrecision)
const totalFractionUnits = Math.round(Math.abs(meters) * INCHES_PER_METER * denominator)
const unitsPerFoot = 12 * denominator
const feet = Math.floor(totalFractionUnits / unitsPerFoot)
const remainder = totalFractionUnits - feet * unitsPerFoot
const inches = Math.floor(remainder / denominator)
const numerator = remainder - inches * denominator
const fraction = formatFraction(numerator, denominator)
const inchText = fraction ? `${inches} ${fraction}` : `${inches}`
if (feet === 0) return `${sign}${inchText}"`
return `${sign}${feet}'-${inchText}"`
}
function imperialPrecisionDenominator(precision?: ConstructionImperialPrecision): number {
switch (precision) {
case '1':
return 1
case '1/2':
return 2
case '1/4':
return 4
case '1/8':
return 8
default:
return IMPERIAL_FRACTION_DENOMINATOR
}
}
function formatFraction(numerator: number, denominator: number): string {
if (numerator === 0) return ''
const divisor = greatestCommonDivisor(numerator, denominator)
return `${numerator / divisor}/${denominator / divisor}`
}
function greatestCommonDivisor(a: number, b: number): number {
let left = Math.abs(a)
let right = Math.abs(b)
while (right !== 0) {
const next = left % right
left = right
right = next
}
return left || 1
}
@@ -0,0 +1,181 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, WallNode, WindowNode } from '@pascal-app/core'
import {
buildConstructionModuleAdvisories,
type ConstructionModuleProfile,
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
} from './construction-module-advisories'
const FOOT = 0.3048
const metricProfile: ConstructionModuleProfile = {
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'metric-common')!,
enabled: true,
}
const imperialProfile: ConstructionModuleProfile = {
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'imperial-common')!,
enabled: true,
}
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
describe('construction module advisories', () => {
test('keeps default construction module profiles optional and quiet', () => {
const wall = WallNode.parse({
id: 'wall_off_module',
start: [0, 0],
end: [3.97, 0],
})
expect(buildConstructionModuleAdvisories(nodes(wall))).toEqual([])
})
test('reports metric wall lengths that miss the configured construction module', () => {
const compliantWall = WallNode.parse({
id: 'wall_metric_ok',
start: [0, 0],
end: [4, 0],
})
const offModuleWall = WallNode.parse({
id: 'wall_metric_off',
start: [0, 0],
end: [3.97, 0],
})
const advisories = buildConstructionModuleAdvisories(nodes(compliantWall, offModuleWall), {
profiles: [metricProfile],
})
expect(advisories).toHaveLength(1)
expect(advisories[0]).toMatchObject({
id: 'construction-module:metric-common:wall_metric_off:wall-length',
nodeId: 'wall_metric_off',
profileId: 'metric-common',
kind: 'wall-length',
module: 0.1,
measured: 3.97,
nearestMultiple: 4,
severity: 'info',
})
expect(advisories[0]?.deviation).toBeCloseTo(0.03)
expect(advisories[0]?.message).toContain('100 mm construction module')
})
test('checks overall level extents at exterior finish faces', () => {
const walls = [
WallNode.parse({
id: 'wall_bottom',
parentId: 'level_main',
start: [0, 0],
end: [4.03, 0],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_right',
parentId: 'level_main',
start: [4.03, 0],
end: [4.03, 3],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_top',
parentId: 'level_main',
start: [4.03, 3],
end: [0, 3],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_left',
parentId: 'level_main',
start: [0, 3],
end: [0, 0],
thickness: 0.2,
}),
]
const advisories = buildConstructionModuleAdvisories(nodes(...walls), {
profiles: [metricProfile],
})
expect(advisories).toContainEqual(
expect.objectContaining({
id: 'construction-module:metric-common:level_main:level-overall-width',
nodeId: 'level_main',
nodeType: 'level',
kind: 'level-overall-width',
}),
)
expect(
advisories.find((advisory) => advisory.kind === 'level-overall-width')?.measured,
).toBeCloseTo(4.23)
expect(advisories).not.toContainEqual(expect.objectContaining({ kind: 'level-overall-depth' }))
})
test('reports imperial opening widths that miss common inch modules', () => {
const compliantDoor = DoorNode.parse({
id: 'door_imperial_ok',
width: 3 * FOOT,
})
const offModuleDoor = DoorNode.parse({
id: 'door_imperial_off',
width: 0.95,
})
const advisories = buildConstructionModuleAdvisories(nodes(compliantDoor, offModuleDoor), {
profiles: [imperialProfile],
})
expect(advisories).toHaveLength(1)
expect(advisories[0]).toMatchObject({
id: 'construction-module:imperial-common:door_imperial_off:opening-width',
nodeId: 'door_imperial_off',
profileId: 'imperial-common',
kind: 'opening-width',
})
expect(advisories[0]?.module).toBeCloseTo(12 * 0.0254)
expect(advisories[0]?.message).toContain('1\'-0" construction module')
})
test('checks verified rough, masonry, and finish opening widths without inventing them', () => {
const door = DoorNode.parse({
id: 'door_verified_widths',
width: 1.2,
roughOpeningWidth: 1.23,
masonryOpeningWidth: 1.4,
})
const window = WindowNode.parse({
id: 'window_verified_widths',
width: 1.2,
finishOpeningWidth: 1.27,
})
const advisories = buildConstructionModuleAdvisories(nodes(door, window), {
profiles: [metricProfile],
})
expect(advisories.map((advisory) => advisory.id)).toEqual([
'construction-module:metric-common:door_verified_widths:rough-opening-width',
'construction-module:metric-common:window_verified_widths:finish-opening-width',
])
})
test('can explicitly include disabled profiles for preflight previews', () => {
const wall = WallNode.parse({
id: 'wall_preview',
start: [0, 0],
end: [3.97, 0],
})
const advisories = buildConstructionModuleAdvisories(nodes(wall), {
includeDisabled: true,
})
expect(advisories.map((advisory) => advisory.profileId).sort()).toEqual([
'imperial-common',
'metric-common',
])
})
})
@@ -0,0 +1,326 @@
import {
type AnyNode,
type DoorNode,
getWallAssemblyFaceOffsets,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { formatConstructionLength } from './construction-length'
const INCH = 0.0254
export type ConstructionModuleSystem = 'imperial' | 'metric'
export type ConstructionModuleAdvisorySeverity = 'info' | 'warning'
export type ConstructionModuleProfile = {
id: string
label: string
system: ConstructionModuleSystem
modules: readonly number[]
tolerance: number
enabled: boolean
}
export type ConstructionModuleMeasurementKind =
| 'wall-length'
| 'level-overall-width'
| 'level-overall-depth'
| 'opening-width'
| 'rough-opening-width'
| 'masonry-opening-width'
| 'finish-opening-width'
export type ConstructionModuleAdvisory = {
id: string
nodeId: string
nodeType: string
profileId: string
profileLabel: string
system: ConstructionModuleSystem
kind: ConstructionModuleMeasurementKind
label: string
module: number
measured: number
deviation: number
nearestMultiple: number
severity: ConstructionModuleAdvisorySeverity
message: string
}
export type BuildConstructionModuleAdvisoriesOptions = {
profiles?: readonly ConstructionModuleProfile[]
includeDisabled?: boolean
}
type ConstructionModuleMeasurement = {
nodeId: string
nodeType: string
kind: ConstructionModuleMeasurementKind
label: string
measured: number
}
type ModuleFit = {
module: number
nearestMultiple: number
deviation: number
}
export const DEFAULT_CONSTRUCTION_MODULE_PROFILES: readonly ConstructionModuleProfile[] = [
{
id: 'imperial-common',
label: 'Imperial common modules',
system: 'imperial',
modules: [12 * INCH, 16 * INCH, 24 * INCH],
tolerance: 0.25 * INCH,
enabled: false,
},
{
id: 'metric-common',
label: 'Metric common modules',
system: 'metric',
modules: [0.1, 0.2, 0.4, 0.6],
tolerance: 0.005,
enabled: false,
},
] as const
export function buildConstructionModuleAdvisories(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildConstructionModuleAdvisoriesOptions = {},
): ConstructionModuleAdvisory[] {
const profiles = (options.profiles ?? DEFAULT_CONSTRUCTION_MODULE_PROFILES).filter(
(profile) => options.includeDisabled === true || profile.enabled,
)
if (profiles.length === 0) return []
const measurements = [
...Object.values(nodes).flatMap((node) => constructionModuleMeasurements(node)),
...levelOverallMeasurements(nodes),
]
const advisories: ConstructionModuleAdvisory[] = []
for (const measurement of measurements) {
for (const profile of profiles) {
const fit = bestModuleFit(measurement.measured, profile.modules)
if (!fit || fit.deviation <= profile.tolerance) continue
advisories.push({
id: ['construction-module', profile.id, measurement.nodeId, measurement.kind].join(':'),
nodeId: measurement.nodeId,
nodeType: measurement.nodeType,
profileId: profile.id,
profileLabel: profile.label,
system: profile.system,
kind: measurement.kind,
label: measurement.label,
module: fit.module,
measured: measurement.measured,
deviation: fit.deviation,
nearestMultiple: fit.nearestMultiple,
severity: 'info',
message: moduleAdvisoryMessage(measurement, profile, fit),
})
}
}
return advisories.sort((left, right) => left.id.localeCompare(right.id))
}
function levelOverallMeasurements(
nodes: Readonly<Record<string, AnyNode>>,
): ConstructionModuleMeasurement[] {
const wallsByLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (node.type !== 'wall' || !node.parentId) continue
if (node.curveOffset !== undefined && Math.abs(node.curveOffset) > 1e-6) continue
const levelWalls = wallsByLevel.get(node.parentId) ?? []
levelWalls.push(node)
wallsByLevel.set(node.parentId, levelWalls)
}
const measurements: ConstructionModuleMeasurement[] = []
for (const [levelId, walls] of wallsByLevel) {
const primaryWall = walls.reduce((longest, wall) =>
wallLength(wall) > wallLength(longest) ? wall : longest,
)
const primaryLength = wallLength(primaryWall)
if (
walls.length < 2 ||
!isUsefulLength(primaryLength) ||
!walls.some((wall) => !wallsAreParallel(primaryWall, wall))
) {
continue
}
const footprintPoints = walls.flatMap(wallFootprintPoints)
const direction: [number, number] = [
(primaryWall.end[0] - primaryWall.start[0]) / primaryLength,
(primaryWall.end[1] - primaryWall.start[1]) / primaryLength,
]
const normal: [number, number] = [-direction[1], direction[0]]
const along = footprintPoints.map(([x, y]) => x * direction[0] + y * direction[1])
const across = footprintPoints.map(([x, y]) => x * normal[0] + y * normal[1])
const width = Math.max(...along) - Math.min(...along)
const depth = Math.max(...across) - Math.min(...across)
if (isUsefulLength(width)) {
measurements.push({
nodeId: levelId,
nodeType: 'level',
kind: 'level-overall-width',
label: 'overall plan width',
measured: width,
})
}
if (isUsefulLength(depth)) {
measurements.push({
nodeId: levelId,
nodeType: 'level',
kind: 'level-overall-depth',
label: 'overall plan depth',
measured: depth,
})
}
}
return measurements
}
function wallLength(wall: WallNode): number {
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}
function wallsAreParallel(left: WallNode, right: WallNode): boolean {
const leftLength = wallLength(left)
const rightLength = wallLength(right)
if (!(isUsefulLength(leftLength) && isUsefulLength(rightLength))) return true
const leftDirection = [
(left.end[0] - left.start[0]) / leftLength,
(left.end[1] - left.start[1]) / leftLength,
]
const rightDirection = [
(right.end[0] - right.start[0]) / rightLength,
(right.end[1] - right.start[1]) / rightLength,
]
return (
Math.abs(leftDirection[0]! * rightDirection[1]! - leftDirection[1]! * rightDirection[0]!) < 1e-4
)
}
function wallFootprintPoints(wall: WallNode): [number, number][] {
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = wallLength(wall)
if (!isUsefulLength(length)) return []
const normal: [number, number] = [-dy / length, dx / length]
const offsets = getWallAssemblyFaceOffsets(wall)
return [offsets.interior, offsets.exterior].flatMap((offset) => [
[wall.start[0] + normal[0] * offset, wall.start[1] + normal[1] * offset],
[wall.end[0] + normal[0] * offset, wall.end[1] + normal[1] * offset],
])
}
function constructionModuleMeasurements(node: AnyNode): ConstructionModuleMeasurement[] {
if (node.type === 'wall') return wallMeasurements(node)
if (node.type === 'door' || node.type === 'window') return openingMeasurements(node)
return []
}
function wallMeasurements(wall: WallNode): ConstructionModuleMeasurement[] {
if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return []
const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
if (!isUsefulLength(length)) return []
return [
{
nodeId: wall.id,
nodeType: wall.type,
kind: 'wall-length',
label: 'wall length',
measured: length,
},
]
}
function openingMeasurements(opening: DoorNode | WindowNode): ConstructionModuleMeasurement[] {
return [
widthMeasurement(opening, 'opening-width', 'nominal width', opening.width),
widthMeasurement(
opening,
'rough-opening-width',
'rough opening width',
opening.roughOpeningWidth,
),
widthMeasurement(
opening,
'masonry-opening-width',
'masonry opening width',
opening.masonryOpeningWidth,
),
widthMeasurement(
opening,
'finish-opening-width',
'finish opening width',
opening.finishOpeningWidth,
),
].filter((measurement): measurement is ConstructionModuleMeasurement => measurement !== null)
}
function widthMeasurement(
opening: DoorNode | WindowNode,
kind: ConstructionModuleMeasurementKind,
label: string,
measured: number | undefined,
): ConstructionModuleMeasurement | null {
if (!isUsefulLength(measured)) return null
return {
nodeId: opening.id,
nodeType: opening.type,
kind,
label,
measured,
}
}
function bestModuleFit(measured: number, modules: readonly number[]): ModuleFit | null {
let best: ModuleFit | null = null
for (const module of modules) {
if (!isUsefulLength(module)) continue
const multiple = Math.max(1, Math.round(measured / module))
const nearestMultiple = multiple * module
const deviation = Math.abs(measured - nearestMultiple)
if (!best || deviation < best.deviation) {
best = { module, nearestMultiple, deviation }
}
}
return best
}
function moduleAdvisoryMessage(
measurement: ConstructionModuleMeasurement,
profile: ConstructionModuleProfile,
fit: ModuleFit,
): string {
const unit = profile.system === 'imperial' ? 'imperial' : 'metric'
const measured = formatConstructionLength(measurement.measured, unit)
const module = formatModuleLength(fit.module, profile.system)
const deviation = formatConstructionLength(fit.deviation, unit)
return `${titleCase(measurement.nodeType)} ${measurement.nodeId} ${measurement.label} ${measured} is ${deviation} off the ${module} construction module.`
}
function formatModuleLength(module: number, system: ConstructionModuleSystem): string {
if (system === 'metric') return `${Math.round(module * 1000)} mm`
return formatConstructionLength(module, 'imperial')
}
function isUsefulLength(value: number | undefined): value is number {
return value !== undefined && Number.isFinite(value) && value > 1e-6
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -0,0 +1,340 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
CabinetNode,
ConstructionDimensionNode,
DoorNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { buildDimensionCompletenessAudit } from './dimension-completeness-audit'
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
function featureAnchor(nodeId: string, fallback: [number, number, number] = [0, 0, 0]) {
return {
kind: 'feature' as const,
reference: { nodeId, featureId: 'center' },
fallback,
}
}
describe('dimension completeness audit', () => {
test('reports missing overall exterior wall dimensions and partition references', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const partitionWall = WallNode.parse({
id: 'wall_partition',
start: [1, 0],
end: [1, 3],
frontSide: 'interior',
backSide: 'interior',
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'missing-partition-reference',
'undocumented-critical-node',
'undocumented-critical-node',
])
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'missing-overall-dimension',
nodeId: 'wall_exterior',
severity: 'warning',
}),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'missing-partition-reference',
nodeId: 'wall_partition',
severity: 'info',
}),
)
})
test('uses associative construction-dimension anchors as dimension coverage', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const partitionWall = WallNode.parse({
id: 'wall_partition',
start: [1, 0],
end: [1, 3],
frontSide: 'interior',
backSide: 'interior',
})
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_wall_refs',
anchors: [
featureAnchor(exteriorWall.id, [0, 0, 0]),
featureAnchor(partitionWall.id, [1, 0, 0]),
],
})
expect(buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall, dimension))).toEqual(
[],
)
})
test('can count the automatic wall and opening dimension plan as coverage', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
roughOpeningWidth: 0.96,
})
expect(
buildDimensionCompletenessAudit(nodes(exteriorWall, door), {
includeAutomaticDimensions: true,
}),
).toEqual([])
})
test('reports undimensioned exterior openings and missing verified rough openings', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry', 'window_front'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
width: 0.9,
})
const window = WindowNode.parse({
id: 'window_front',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
roughOpeningWidth: 1.22,
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, window))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'missing-verified-rough-opening',
'undimensioned-exterior-opening',
'undimensioned-exterior-opening',
'undocumented-critical-node',
])
expect(issues.filter((auditIssue) => auditIssue.nodeId === 'window_front')).toHaveLength(1)
})
test('suppresses exterior opening and rough-opening issues when evidence exists', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
width: 0.9,
roughOpeningWidth: 0.96,
})
const openingDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_door',
anchors: [featureAnchor(door.id, [2, 0, 0]), featureAnchor(door.id, [3, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, openingDimension))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'undocumented-critical-node',
])
})
test('can require rough-opening height verification as a stricter profile', () => {
const door = DoorNode.parse({
id: 'door_entry',
roughOpeningWidth: 0.96,
})
expect(
buildDimensionCompletenessAudit(nodes(door), { requireRoughOpeningHeights: true }),
).toMatchObject([
{
kind: 'missing-verified-rough-opening',
nodeId: 'door_entry',
},
])
})
test('does not require rough openings for masonry openings or frameless openings', () => {
const masonryWindow = WindowNode.parse({
id: 'window_masonry',
constructionType: 'masonry',
})
const framelessOpening = DoorNode.parse({
id: 'door_opening',
openingKind: 'opening',
})
expect(buildDimensionCompletenessAudit(nodes(masonryWindow, framelessOpening))).toEqual([])
})
test('detects duplicate and contradictory dimension string overrides', () => {
const wall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const firstDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_first',
textOverride: '5.00m',
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [5, 0, 0])],
})
const duplicateDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_duplicate',
textOverride: '5.00 m',
anchors: [featureAnchor('wall_other', [0, 0, 0]), featureAnchor('wall_other', [5, 0, 0])],
})
const conflictingDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_conflict',
textOverride: '4.80m',
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [4.8, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(
nodes(wall, firstDimension, duplicateDimension, conflictingDimension),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'duplicate-dimension-string',
nodeId: 'construction-dimension_first',
}),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'contradictory-dimension-string',
nodeId: wall.id,
}),
)
})
test('detects continuous dimension segment totals that disagree with the overall string', () => {
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_chain',
chainMode: 'continuous',
textOverride: '3.00m',
anchors: [
featureAnchor('wall_a', [0, 0, 0]),
featureAnchor('wall_b', [1, 0, 0]),
featureAnchor('wall_c', [2, 0, 0]),
],
})
const issues = buildDimensionCompletenessAudit(nodes(dimension))
expect(issues).toEqual([
expect.objectContaining({
kind: 'dimension-segment-total-mismatch',
nodeId: 'construction-dimension_chain',
}),
])
})
test('reports construction-critical nodes without dimensions or schedules', () => {
const undocumentedCabinet = CabinetNode.parse({
id: 'cabinet_undocumented',
})
const stair = StairNode.parse({
id: 'stair_documented',
})
const stairDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_stair',
anchors: [featureAnchor(stair.id, [0, 0, 0]), featureAnchor(stair.id, [1, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(
nodes(undocumentedCabinet, stair, stairDimension),
)
expect(issues).toEqual([
expect.objectContaining({
kind: 'undocumented-critical-node',
nodeId: undocumentedCabinet.id,
}),
])
})
test('includes unresolved annotation collisions from preflight evidence', () => {
const issues = buildDimensionCompletenessAudit(nodes(), {
preflightIssues: [
{
id: 'dimension-label_wall_a',
kind: 'unresolved-collision',
severity: 'warning',
message:
'Wall A dimension label still overlaps another annotation after automatic layout.',
},
{
id: 'dimension-label_wall_b',
kind: 'short-unreadable-segment',
severity: 'warning',
message: 'Wall B uses an outside label.',
},
],
})
expect(issues).toEqual([
expect.objectContaining({
id: 'dimension-completeness:unresolved-annotation-collision:dimension-label_wall_a',
kind: 'unresolved-annotation-collision',
nodeId: 'dimension-label_wall_a',
nodeType: 'annotation',
}),
])
})
test('includes clipped sheet content from sheet preflight evidence', () => {
const issues = buildDimensionCompletenessAudit(nodes(), {
preflightIssues: [
{
message:
'Scaled plan exceeds the sheet viewport. Review clipped view or annotation content.',
},
],
})
expect(issues).toEqual([
expect.objectContaining({
id: 'dimension-completeness:clipped-sheet-content:sheet',
kind: 'clipped-sheet-content',
nodeId: 'sheet',
nodeType: 'sheet',
severity: 'warning',
}),
])
})
})
@@ -0,0 +1,495 @@
import {
type AnyNode,
type ConstructionDimensionNode,
type DoorNode,
measurementAnchorReferenceNodeIds,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
export type DimensionCompletenessIssueKind =
| 'missing-overall-dimension'
| 'undimensioned-exterior-opening'
| 'missing-partition-reference'
| 'missing-verified-rough-opening'
| 'duplicate-dimension-string'
| 'contradictory-dimension-string'
| 'dimension-segment-total-mismatch'
| 'undocumented-critical-node'
| 'unresolved-annotation-collision'
| 'clipped-sheet-content'
export type DimensionCompletenessIssueSeverity = 'info' | 'warning'
export type DimensionCompletenessIssue = {
id: string
kind: DimensionCompletenessIssueKind
nodeId: string
nodeType: string
severity: DimensionCompletenessIssueSeverity
message: string
}
export type BuildDimensionCompletenessAuditOptions = {
includeAutomaticDimensions?: boolean
requireRoughOpeningHeights?: boolean
dimensionValueTolerance?: number
preflightIssues?: readonly DimensionCompletenessPreflightIssue[]
}
export type DimensionCompletenessPreflightIssue = {
id?: string
kind?: string
severity?: DimensionCompletenessIssueSeverity
message: string
}
type DimensionCoverage = ReadonlySet<string>
type DocumentationCoverage = {
dimensioned: ReadonlySet<string>
scheduled: ReadonlySet<string>
}
type OpeningNode = DoorNode | WindowNode
type DimensionRecord = {
dimension: ConstructionDimensionNode
referencedNodeIds: readonly string[]
normalizedText: string | null
parsedTextValue: number | null
segmentTotal: number | null
}
export function buildDimensionCompletenessAudit(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions = {},
): DimensionCompletenessIssue[] {
const coverage = dimensionCoverage(nodes, options)
const documentation = documentationCoverage(nodes, coverage)
const issues: DimensionCompletenessIssue[] = []
issues.push(...dimensionStringIssues(nodes, options))
issues.push(...preflightCompletenessIssues(options.preflightIssues ?? []))
for (const node of Object.values(nodes)) {
if (node.type === 'wall') {
issues.push(...wallDimensionIssues(node, coverage))
} else if (node.type === 'door' || node.type === 'window') {
issues.push(...openingDimensionIssues(node, nodes, coverage, options))
}
if (isConstructionCriticalNode(node, nodes) && !hasDocumentationCoverage(node, documentation)) {
issues.push(
issue(
'undocumented-critical-node',
node,
'warning',
`${titleCase(node.type)} ${node.id} has no construction dimension or schedule entry.`,
),
)
}
}
return issues.sort((left, right) => left.id.localeCompare(right.id))
}
function dimensionCoverage(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCoverage {
const covered = new Set<string>()
for (const node of Object.values(nodes)) {
if (node.type !== 'construction-dimension') continue
for (const nodeId of measurementAnchorReferenceNodeIds(
(node as ConstructionDimensionNode).anchors,
)) {
covered.add(nodeId)
}
}
if (options.includeAutomaticDimensions === true) {
for (const node of Object.values(nodes)) {
if (
node.type === 'wall' &&
node.visible !== false &&
Math.abs(node.curveOffset ?? 0) <= 1e-6 &&
(isExteriorWall(node) || isPartitionWall(node))
) {
covered.add(node.id)
}
}
for (const node of Object.values(nodes)) {
if (node.type !== 'door' && node.type !== 'window') continue
const host = openingHostWall(node, nodes)
if (host && covered.has(host.id)) covered.add(node.id)
}
}
return covered
}
function documentationCoverage(
nodes: Readonly<Record<string, AnyNode>>,
dimensioned: DimensionCoverage,
): DocumentationCoverage {
const scheduled = new Set<string>()
for (const node of Object.values(nodes)) {
if (hasGeneratedScheduleEntry(node)) scheduled.add(node.id)
}
return { dimensioned, scheduled }
}
function dimensionStringIssues(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCompletenessIssue[] {
const records = Object.values(nodes)
.filter((node): node is ConstructionDimensionNode => node.type === 'construction-dimension')
.map((dimension) => dimensionRecord(dimension))
const issues: DimensionCompletenessIssue[] = []
issues.push(...duplicateDimensionStringIssues(records))
issues.push(...contradictoryDimensionStringIssues(records))
issues.push(...segmentTotalMismatchIssues(records, options.dimensionValueTolerance ?? 0.005))
return issues
}
function dimensionRecord(dimension: ConstructionDimensionNode): DimensionRecord {
const normalizedText = normalizedDimensionText(dimension.textOverride)
return {
dimension,
referencedNodeIds: measurementAnchorReferenceNodeIds(dimension.anchors),
normalizedText,
parsedTextValue: normalizedText ? parseDimensionTextValue(normalizedText) : null,
segmentTotal: continuousSegmentTotal(dimension),
}
}
function duplicateDimensionStringIssues(
records: readonly DimensionRecord[],
): DimensionCompletenessIssue[] {
const byText = new Map<string, DimensionRecord[]>()
for (const record of records) {
if (!record.normalizedText) continue
const existing = byText.get(record.normalizedText)
if (existing) existing.push(record)
else byText.set(record.normalizedText, [record])
}
const issues: DimensionCompletenessIssue[] = []
for (const [text, duplicates] of byText) {
if (duplicates.length < 2) continue
const dimension = duplicates[0]?.dimension
if (!dimension) continue
issues.push(
issue(
'duplicate-dimension-string',
dimension,
'info',
`Dimension string "${text}" is used by ${duplicates.length} construction dimensions.`,
),
)
}
return issues
}
function contradictoryDimensionStringIssues(
records: readonly DimensionRecord[],
): DimensionCompletenessIssue[] {
const byNode = new Map<string, Map<string, DimensionRecord[]>>()
for (const record of records) {
if (!record.normalizedText) continue
for (const nodeId of record.referencedNodeIds) {
const byText = byNode.get(nodeId) ?? new Map<string, DimensionRecord[]>()
const matchingText = byText.get(record.normalizedText)
if (matchingText) matchingText.push(record)
else byText.set(record.normalizedText, [record])
byNode.set(nodeId, byText)
}
}
const issues: DimensionCompletenessIssue[] = []
for (const [nodeId, byText] of byNode) {
if (byText.size < 2) continue
const firstRecord = [...byText.values()][0]?.[0]
if (!firstRecord) continue
issues.push({
id: ['dimension-completeness', 'contradictory-dimension-string', nodeId].join(':'),
kind: 'contradictory-dimension-string',
nodeId,
nodeType: 'unknown',
severity: 'warning',
message: `Referenced node ${nodeId} has contradictory construction dimension strings: ${[
...byText.keys(),
].join(', ')}.`,
})
}
return issues
}
function segmentTotalMismatchIssues(
records: readonly DimensionRecord[],
tolerance: number,
): DimensionCompletenessIssue[] {
return records.flatMap((record) => {
if (record.dimension.chainMode !== 'continuous') return []
if (record.parsedTextValue === null || record.segmentTotal === null) return []
if (Math.abs(record.parsedTextValue - record.segmentTotal) <= tolerance) return []
return [
issue(
'dimension-segment-total-mismatch',
record.dimension,
'warning',
`Continuous dimension ${record.dimension.id} text ${record.normalizedText} does not match its segment total ${record.segmentTotal.toFixed(3)}m.`,
),
]
})
}
function preflightCompletenessIssues(
preflightIssues: readonly DimensionCompletenessPreflightIssue[],
): DimensionCompletenessIssue[] {
const issues: DimensionCompletenessIssue[] = []
for (const preflightIssue of preflightIssues) {
const normalizedKind = preflightIssue.kind?.trim().toLowerCase()
const normalizedMessage = preflightIssue.message.trim().toLowerCase()
if (normalizedKind === 'unresolved-collision') {
issues.push(
preflightIssueCompletenessIssue(
'unresolved-annotation-collision',
preflightIssue,
'annotation',
),
)
continue
}
if (
normalizedKind === 'clipped-content' ||
normalizedKind === 'clipped-sheet-content' ||
normalizedMessage.includes('clipped') ||
normalizedMessage.includes('exceeds the sheet viewport')
) {
issues.push(preflightIssueCompletenessIssue('clipped-sheet-content', preflightIssue, 'sheet'))
}
}
return issues
}
function preflightIssueCompletenessIssue(
kind: Extract<
DimensionCompletenessIssueKind,
'unresolved-annotation-collision' | 'clipped-sheet-content'
>,
preflightIssue: DimensionCompletenessPreflightIssue,
fallbackNodeId: string,
): DimensionCompletenessIssue {
const nodeId = preflightIssue.id?.trim() || fallbackNodeId
return {
id: ['dimension-completeness', kind, nodeId].join(':'),
kind,
nodeId,
nodeType: fallbackNodeId,
severity: preflightIssue.severity ?? 'warning',
message: preflightIssue.message,
}
}
function wallDimensionIssues(
wall: WallNode,
coverage: DimensionCoverage,
): DimensionCompletenessIssue[] {
if (coverage.has(wall.id)) return []
if (isExteriorWall(wall)) {
return [
issue(
'missing-overall-dimension',
wall,
'warning',
`Exterior wall ${wall.id} has no associative overall construction dimension.`,
),
]
}
if (isPartitionWall(wall)) {
return [
issue(
'missing-partition-reference',
wall,
'info',
`Partition wall ${wall.id} has no associative partition reference dimension.`,
),
]
}
return []
}
function openingDimensionIssues(
opening: OpeningNode,
nodes: Readonly<Record<string, AnyNode>>,
coverage: DimensionCoverage,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCompletenessIssue[] {
const issues: DimensionCompletenessIssue[] = []
const hostWall = openingHostWall(opening, nodes)
if (hostWall && isExteriorWall(hostWall) && !coverage.has(opening.id)) {
issues.push(
issue(
'undimensioned-exterior-opening',
opening,
'warning',
`${titleCase(opening.type)} ${opening.id} is on exterior wall ${hostWall.id} but has no associative opening dimension.`,
),
)
}
if (missingVerifiedRoughOpening(opening, options)) {
issues.push(
issue(
'missing-verified-rough-opening',
opening,
'info',
`${titleCase(opening.type)} ${opening.id} has no verified rough-opening ${options.requireRoughOpeningHeights === true ? 'width and height' : 'width'} recorded.`,
),
)
}
return issues
}
function openingHostWall(
opening: OpeningNode,
nodes: Readonly<Record<string, AnyNode>>,
): WallNode | null {
const hostId = opening.wallId ?? opening.parentId ?? null
if (!hostId) return null
const host = nodes[hostId]
return host?.type === 'wall' ? host : null
}
function missingVerifiedRoughOpening(
opening: OpeningNode,
options: BuildDimensionCompletenessAuditOptions,
): boolean {
if (opening.openingKind === 'opening') return false
if (opening.constructionType === 'masonry') return false
if (opening.roughOpeningWidth === undefined) return true
return options.requireRoughOpeningHeights === true && opening.roughOpeningHeight === undefined
}
function isExteriorWall(wall: WallNode): boolean {
return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
}
function isPartitionWall(wall: WallNode): boolean {
return wall.frontSide === 'interior' || wall.backSide === 'interior'
}
function hasDocumentationCoverage(node: AnyNode, coverage: DocumentationCoverage): boolean {
return coverage.dimensioned.has(node.id) || coverage.scheduled.has(node.id)
}
function hasGeneratedScheduleEntry(node: AnyNode): boolean {
if (node.type === 'door' || node.type === 'window') return node.openingKind !== 'opening'
return node.type === 'zone' && node.spaceRole === 'room'
}
function isConstructionCriticalNode(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): boolean {
if (node.type === 'wall') return isExteriorWall(node) || isPartitionWall(node)
if (node.type === 'door' || node.type === 'window') {
const hostWall = openingHostWall(node, nodes)
return hostWall ? isExteriorWall(hostWall) : false
}
if (node.type === 'zone') return node.spaceRole === 'room'
return (
node.type === 'cabinet' ||
node.type === 'cabinet-module' ||
node.type === 'stair' ||
node.type === 'stair-segment'
)
}
function normalizedDimensionText(text: string | null): string | null {
const normalized = text
?.trim()
.replace(/\s+/g, ' ')
.replace(/(\d)\s+(MM|M|")/gi, '$1$2')
.toUpperCase()
return normalized || null
}
function parseDimensionTextValue(text: string): number | null {
const metricMatch = text.match(/^([0-9]+(?:\.[0-9]+)?)\s*(MM|M)?$/)
if (metricMatch) {
const value = Number.parseFloat(metricMatch[1] ?? '')
if (!Number.isFinite(value)) return null
return metricMatch[2] === 'MM' ? value / 1000 : value
}
const imperialMatch = text.match(/^(?:(\d+(?:\.\d+)?)')?(?:-)?(?:(\d+(?:\.\d+)?)")?$/)
if (imperialMatch) {
const feet = Number.parseFloat(imperialMatch[1] ?? '0')
const inches = Number.parseFloat(imperialMatch[2] ?? '0')
const totalInches = feet * 12 + inches
return totalInches > 0 ? totalInches * 0.0254 : null
}
return null
}
function continuousSegmentTotal(dimension: ConstructionDimensionNode): number | null {
if (dimension.chainMode !== 'continuous' || dimension.anchors.length < 3) return null
const directionLength = Math.hypot(
dimension.baseline.direction[0],
dimension.baseline.direction[1],
)
if (directionLength <= 1e-9) return null
const dirX = dimension.baseline.direction[0] / directionLength
const dirZ = dimension.baseline.direction[1] / directionLength
let total = 0
for (let index = 1; index < dimension.anchors.length; index += 1) {
const previousAnchor = dimension.anchors[index - 1]
const currentAnchor = dimension.anchors[index]
if (!previousAnchor || !currentAnchor) return null
const previous = anchorFallbackPoint(previousAnchor)
const current = anchorFallbackPoint(currentAnchor)
total += Math.abs((current[0] - previous[0]) * dirX + (current[2] - previous[2]) * dirZ)
}
return total
}
function anchorFallbackPoint(
anchor: ConstructionDimensionNode['anchors'][number],
): [number, number, number] {
return Array.isArray(anchor) ? anchor : anchor.fallback
}
function issue(
kind: DimensionCompletenessIssueKind,
node: Pick<AnyNode, 'id' | 'type'>,
severity: DimensionCompletenessIssueSeverity,
message: string,
): DimensionCompletenessIssue {
return {
id: ['dimension-completeness', kind, node.id].join(':'),
kind,
nodeId: node.id,
nodeType: node.type,
severity,
message,
}
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test'
import { buildDimensionStringGeometry } from './dimension-string'
describe('buildDimensionStringGeometry', () => {
test('expands a logical dimension string into renderable dimension segments', () => {
const geometry = buildDimensionStringGeometry({
offsetNormal: [0, 1],
offsetDistance: 0,
extensionStartGap: 0.04,
extensionOvershoot: 0.12,
terminator: 'dot',
textPosition: 'centered',
stroke: '#334155',
segments: [
{
witnessStart: [0, 0],
witnessEnd: [2, 0],
dimensionStart: [0, 1],
dimensionEnd: [2, 1],
text: '2m',
},
{
witnessStart: [2, 0],
witnessEnd: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
text: '3m',
},
],
})
expect(geometry).toEqual(
expect.objectContaining({
kind: 'dimension-string',
offsetNormal: [0, 1],
offsetDistance: 0,
extensionStartGap: 0.04,
extensionOvershoot: 0.12,
terminator: 'dot',
textPosition: 'centered',
stroke: '#334155',
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',
},
],
}),
)
})
})
@@ -0,0 +1,47 @@
import type {
DimensionTerminator,
DimensionTextPosition,
FloorplanGeometry,
FloorplanPoint,
} from '@pascal-app/core'
export type DimensionStringSegment = {
witnessStart: FloorplanPoint
witnessEnd: FloorplanPoint
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}
export type DimensionStringGeometryInput = {
segments: readonly DimensionStringSegment[]
offsetNormal: FloorplanPoint
offsetDistance?: number
extensionStartGap?: number
extensionOvershoot?: number
terminator?: DimensionTerminator
textPosition?: DimensionTextPosition
stroke?: string
}
export function buildDimensionStringGeometry(
input: DimensionStringGeometryInput,
): FloorplanGeometry {
return {
kind: 'dimension-string',
segments: input.segments.map((segment) => ({
start: segment.witnessStart,
end: segment.witnessEnd,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
text: segment.text,
})),
offsetNormal: input.offsetNormal,
offsetDistance: input.offsetDistance ?? 0,
extensionStartGap: input.extensionStartGap,
extensionOvershoot: input.extensionOvershoot ?? 0,
terminator: input.terminator,
textPosition: input.textPosition,
stroke: input.stroke,
}
}
@@ -0,0 +1,199 @@
'use client'
import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
type OpeningDocumentationPatch = {
mark?: string
constructionType?: 'framed' | 'masonry'
dimensionReference?: 'nominal' | 'rough-opening' | 'masonry-opening' | 'finish-opening'
roughOpeningWidth?: number
roughOpeningHeight?: number
masonryOpeningWidth?: number
masonryOpeningHeight?: number
finishOpeningWidth?: number
finishOpeningHeight?: number
}
export function OpeningDocumentationFields({
mark,
constructionType = 'framed',
dimensionReference = 'nominal',
roughOpeningWidth,
roughOpeningHeight,
masonryOpeningWidth,
masonryOpeningHeight,
finishOpeningWidth,
finishOpeningHeight,
onChange,
}: OpeningDocumentationPatch & {
onChange: (patch: OpeningDocumentationPatch) => void
}) {
return (
<div className="flex flex-col gap-2 px-1 pb-1">
<label className="flex flex-col gap-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Mark
</span>
<input
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2.5 font-mono text-foreground text-xs outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-orange-400/60"
defaultValue={mark ?? ''}
key={`mark:${mark ?? ''}`}
maxLength={16}
onBlur={(event) => {
const next = event.currentTarget.value.trim().toLocaleUpperCase()
if (next !== (mark ?? '')) onChange({ mark: next || undefined })
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
if (event.key === 'Escape') {
event.currentTarget.value = mark ?? ''
event.currentTarget.blur()
}
}}
placeholder="Auto-assigned"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Construction
</span>
<select
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-foreground text-xs outline-none focus:border-orange-400/60"
onChange={(event) => {
const next = event.currentTarget.value as 'framed' | 'masonry'
onChange({
constructionType: next,
dimensionReference:
next === 'masonry' && dimensionReference === 'nominal'
? 'masonry-opening'
: dimensionReference,
})
}}
value={constructionType}
>
<option value="framed">Framed</option>
<option value="masonry">Masonry</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Dimension to
</span>
<select
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-foreground text-xs outline-none focus:border-orange-400/60"
onChange={(event) =>
onChange({
dimensionReference: event.currentTarget
.value as OpeningDocumentationPatch['dimensionReference'],
})
}
value={dimensionReference}
>
<option value="nominal">Nominal</option>
<option value="rough-opening">Rough opening</option>
<option value="masonry-opening">Masonry opening</option>
<option value="finish-opening">Finish opening</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<OptionalMeterInput
label="RO Width"
onChange={(value) => onChange({ roughOpeningWidth: value })}
value={roughOpeningWidth}
/>
<OptionalMeterInput
label="RO Height"
onChange={(value) => onChange({ roughOpeningHeight: value })}
value={roughOpeningHeight}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<OptionalMeterInput
label="MO Width"
onChange={(value) => onChange({ masonryOpeningWidth: value })}
value={masonryOpeningWidth}
/>
<OptionalMeterInput
label="MO Height"
onChange={(value) => onChange({ masonryOpeningHeight: value })}
value={masonryOpeningHeight}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<OptionalMeterInput
label="FO Width"
onChange={(value) => onChange({ finishOpeningWidth: value })}
value={finishOpeningWidth}
/>
<OptionalMeterInput
label="FO Height"
onChange={(value) => onChange({ finishOpeningHeight: value })}
value={finishOpeningHeight}
/>
</div>
<p className="px-0.5 text-[10px] text-muted-foreground/65 leading-4">
Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade.
</p>
</div>
)
}
function OptionalMeterInput({
label,
value,
onChange,
}: {
label: string
value?: number
onChange: (value: number | undefined) => void
}) {
const unit = useViewer((state) => state.unit)
const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit))
return (
<label className="flex flex-col gap-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
{label}
</span>
<div className="flex h-8 items-center rounded-lg border border-border/50 bg-[#2C2C2E] focus-within:border-orange-400/60">
<input
className="min-w-0 flex-1 bg-transparent px-2 font-mono text-foreground text-xs outline-none placeholder:text-muted-foreground/50"
defaultValue={displayValue}
key={`${label}:${displayValue}`}
min={0.01}
onBlur={(event) => {
const raw = event.currentTarget.value
if (raw === String(displayValue)) return
const parsed = Number.parseFloat(raw)
const next =
raw === '' || !Number.isFinite(parsed) || parsed <= 0
? undefined
: linearUnitToMeters(parsed, unit)
if (next !== value) onChange(next)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
if (event.key === 'Escape') {
event.currentTarget.value = String(displayValue)
event.currentTarget.blur()
}
}}
onWheel={(event) => event.currentTarget.blur()}
placeholder="Verify"
step={unit === 'imperial' ? 0.01 : 0.001}
type="number"
/>
<span className="pr-2 font-mono text-[10px] text-muted-foreground">
{getLinearUnitLabel(unit)}
</span>
</div>
</label>
)
}
function roundForInput(value: number): number {
return Math.round(value * 1000) / 1000
}
@@ -0,0 +1,205 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, LevelNode, WallNode, WindowNode } from '@pascal-app/core'
import {
buildDoorFloorplanSchedule,
buildOpeningMarkAnnotation,
buildWindowFloorplanSchedule,
computeDoorFloorplanLevelData,
computeWindowFloorplanLevelData,
resolveOpeningDimensionDocumentation,
} from './opening-documentation'
const FOOT = 0.3048
function fixture(levelNumber = 0) {
const level = LevelNode.parse({
id: 'level_main',
level: levelNumber,
children: ['wall_main'],
})
const wall = WallNode.parse({
id: 'wall_main',
parentId: level.id,
children: ['door_a', 'door_b', 'window_a', 'window_b'],
start: [0, 0],
end: [10, 0],
thickness: 0.2,
frontSide: 'exterior',
backSide: 'interior',
})
const doorA = DoorNode.parse({
id: 'door_a',
parentId: wall.id,
wallId: wall.id,
position: [3, 3.5 * FOOT, 0],
width: 3 * FOOT,
height: 7 * FOOT,
})
const doorB = DoorNode.parse({
id: 'door_b',
parentId: wall.id,
wallId: wall.id,
position: [6, 3.5 * FOOT, 0],
width: 3 * FOOT,
height: 7 * FOOT,
})
const windowA = WindowNode.parse({
id: 'window_a',
parentId: wall.id,
wallId: wall.id,
position: [2, 5 * FOOT, 0],
width: 4 * FOOT,
height: 4 * FOOT,
})
const windowB = WindowNode.parse({
id: 'window_b',
parentId: wall.id,
wallId: wall.id,
position: [8, 5 * FOOT, 0],
width: 4 * FOOT,
height: 4 * FOOT,
})
const nodes = Object.fromEntries(
[level, wall, doorA, doorB, windowA, windowB].map((node) => [node.id, node]),
) as Record<string, AnyNode>
return { doorA, doorB, level, nodes, wall, windowA, windowB }
}
describe('opening construction documentation', () => {
test('assigns deterministic level-based door marks and skips explicit marks', () => {
const { doorA, doorB, nodes } = fixture()
const explicit = DoorNode.parse({ ...doorA, mark: '101' })
const marks = computeDoorFloorplanLevelData({ siblings: [explicit, doorB], nodes })
expect(marks.markById.get(explicit.id)).toBe('101')
expect(marks.markById.get(doorB.id)).toBe('102')
const upperFixture = fixture(1)
const upperMarks = computeDoorFloorplanLevelData({
siblings: [upperFixture.doorA],
nodes: upperFixture.nodes,
})
expect(upperMarks.markById.get(upperFixture.doorA.id)).toBe('201')
})
test('assigns stable window marks in level order', () => {
const { nodes, windowA, windowB } = fixture()
const marks = computeWindowFloorplanLevelData({
siblings: [windowA, windowB],
nodes,
})
expect(marks.markById.get(windowA.id)).toBe('W01')
expect(marks.markById.get(windowB.id)).toBe('W02')
})
test('builds U.S. door schedule dimensions without inventing a rough opening', () => {
const { doorA, level, nodes } = fixture()
const schedule = buildDoorFloorplanSchedule({
siblings: [doorA],
nodes,
levelId: level.id,
unit: 'imperial',
})
expect(schedule?.rows[0]?.cells).toMatchObject({
mark: '101',
size: `3'-0" x 7'-0"`,
roughOpening: 'VERIFY',
})
})
test('includes verified window rough opening, sill, and head heights', () => {
const { level, nodes, windowA } = fixture()
const documented = WindowNode.parse({
...windowA,
roughOpeningWidth: 4.1 * FOOT,
roughOpeningHeight: 4.2 * FOOT,
})
const schedule = buildWindowFloorplanSchedule({
siblings: [documented],
nodes,
levelId: level.id,
unit: 'imperial',
})
expect(schedule?.rows[0]?.cells).toMatchObject({
mark: 'W01',
roughOpening: `4'-1 3/16" x 4'-2 3/8"`,
sill: `3'-0"`,
head: `7'-0"`,
})
})
test('resolves explicit opening dimension documentation without inventing missing values', () => {
const { doorA, windowA } = fixture()
const roughDoor = DoorNode.parse({
...doorA,
dimensionReference: 'rough-opening',
roughOpeningWidth: 3.1 * FOOT,
roughOpeningHeight: 7.1 * FOOT,
})
const missingRoughDoor = DoorNode.parse({
...doorA,
id: 'door_missing_ro',
dimensionReference: 'rough-opening',
})
const masonryWindow = WindowNode.parse({
...windowA,
constructionType: 'masonry',
masonryOpeningWidth: 4.25 * FOOT,
masonryOpeningHeight: 4.25 * FOOT,
})
expect(resolveOpeningDimensionDocumentation(roughDoor)).toMatchObject({
constructionType: 'framed',
reference: 'rough-opening',
locationPolicy: 'centerline',
prefix: 'RO',
verified: true,
width: 3.1 * FOOT,
})
expect(resolveOpeningDimensionDocumentation(missingRoughDoor)).toMatchObject({
reference: 'rough-opening',
prefix: 'RO',
verified: false,
width: null,
})
expect(resolveOpeningDimensionDocumentation(masonryWindow)).toMatchObject({
constructionType: 'masonry',
reference: 'masonry-opening',
locationPolicy: 'edge-to-edge',
prefix: 'MO',
verified: true,
width: 4.25 * FOOT,
})
})
test('warns about duplicate manually assigned marks', () => {
const { doorA, doorB, level, nodes } = fixture()
const schedule = buildDoorFloorplanSchedule({
siblings: [
DoorNode.parse({ ...doorA, mark: 'A1' }),
DoorNode.parse({ ...doorB, mark: 'a1' }),
],
nodes,
levelId: level.id,
unit: 'imperial',
})
expect(schedule?.issues).toEqual(['Duplicate door mark A1 (2 instances)'])
})
test('places the opening mark tag on the interior face of an exterior wall', () => {
const { doorA, nodes, wall } = fixture()
const levelData = computeDoorFloorplanLevelData({ siblings: [doorA], nodes })
const annotation = buildOpeningMarkAnnotation(doorA, wall, levelData)
expect(annotation?.kind).toBe('group')
if (annotation?.kind !== 'group') return
const tag = annotation.children.find((child) => child.kind === 'text')
expect(tag).toMatchObject({ kind: 'text', text: '101', x: 3 })
expect(tag?.kind === 'text' ? tag.y : null).toBeLessThan(0)
})
})
@@ -0,0 +1,413 @@
import type {
AnyNode,
DoorNode,
FloorplanGeometry,
LevelNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { type FloorplanSchedule, withFloorplanGeometryMetadata } from '@pascal-app/editor'
import {
type ConstructionLengthProfile,
type ConstructionLinearUnit,
formatConstructionLength,
} from './construction-length'
type OpeningNode = DoorNode | WindowNode
type OpeningKind = OpeningNode['type']
export type OpeningConstructionType = 'framed' | 'masonry'
export type OpeningDimensionReference =
| 'nominal'
| 'rough-opening'
| 'masonry-opening'
| 'finish-opening'
export type OpeningDimensionDocumentation = {
constructionType: OpeningConstructionType
reference: OpeningDimensionReference
locationPolicy: 'centerline' | 'edge-to-edge'
width: number | null
height: number | null
prefix: string
verified: boolean
}
export type OpeningFloorplanLevelData = {
markById: ReadonlyMap<string, string>
}
type MarkResolution = OpeningFloorplanLevelData & {
issues: readonly string[]
}
export function computeDoorFloorplanLevelData(args: {
siblings: ReadonlyArray<DoorNode>
nodes: Record<string, AnyNode>
}): OpeningFloorplanLevelData {
return resolveOpeningMarks(args.siblings, args.nodes, 'door')
}
export function computeWindowFloorplanLevelData(args: {
siblings: ReadonlyArray<WindowNode>
nodes: Record<string, AnyNode>
}): OpeningFloorplanLevelData {
return resolveOpeningMarks(args.siblings, args.nodes, 'window')
}
export function buildDoorFloorplanSchedule(args: {
siblings: ReadonlyArray<DoorNode>
nodes: Readonly<Record<string, AnyNode>>
levelId: string
unit: ConstructionLinearUnit
profile?: ConstructionLengthProfile
}): FloorplanSchedule | null {
if (args.siblings.length === 0) return null
const marks = resolveOpeningMarks(args.siblings, args.nodes, 'door', args.levelId)
return {
id: 'doors',
title: 'DOOR SCHEDULE',
columns: [
{ key: 'mark', label: 'MARK', weight: 0.65 },
{ key: 'type', label: 'TYPE', weight: 1.25 },
{ key: 'size', label: 'NOMINAL SIZE', weight: 1.35 },
{ key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 },
{ key: 'operation', label: 'OPERATION', weight: 1.35 },
{ key: 'frame', label: 'FRAME T / D', weight: 1.25 },
{ key: 'hardware', label: 'HARDWARE', weight: 1.35 },
],
rows: args.siblings.map((door) => ({
id: door.id,
cells: {
mark: marks.markById.get(door.id) ?? '—',
type: door.openingKind === 'opening' ? 'Opening' : titleCase(door.doorType),
size: formatSize(door.width, door.height, args.unit, args.profile ?? 'document'),
roughOpening: formatRoughOpening(door, args.unit, args.profile ?? 'document'),
operation: doorOperation(door),
frame: `${formatConstructionLength(door.frameThickness, args.unit, args.profile ?? 'document')} / ${formatConstructionLength(door.frameDepth, args.unit, args.profile ?? 'document')}`,
hardware: doorHardware(door),
},
})),
issues: marks.issues,
}
}
export function buildWindowFloorplanSchedule(args: {
siblings: ReadonlyArray<WindowNode>
nodes: Readonly<Record<string, AnyNode>>
levelId: string
unit: ConstructionLinearUnit
profile?: ConstructionLengthProfile
}): FloorplanSchedule | null {
if (args.siblings.length === 0) return null
const marks = resolveOpeningMarks(args.siblings, args.nodes, 'window', args.levelId)
return {
id: 'windows',
title: 'WINDOW SCHEDULE',
columns: [
{ key: 'mark', label: 'MARK', weight: 0.65 },
{ key: 'type', label: 'TYPE', weight: 1.2 },
{ key: 'size', label: 'NOMINAL SIZE', weight: 1.35 },
{ key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 },
{ key: 'sill', label: 'SILL', weight: 0.9 },
{ key: 'head', label: 'HEAD', weight: 0.9 },
{ key: 'operation', label: 'OPERATION', weight: 1.35 },
],
rows: args.siblings.map((window) => ({
id: window.id,
cells: {
mark: marks.markById.get(window.id) ?? '—',
type: window.openingKind === 'opening' ? 'Opening' : titleCase(window.windowType),
size: formatSize(window.width, window.height, args.unit, args.profile ?? 'document'),
roughOpening: formatRoughOpening(window, args.unit, args.profile ?? 'document'),
sill: formatConstructionLength(
Math.max(0, window.position[1] - window.height / 2),
args.unit,
args.profile ?? 'document',
),
head: formatConstructionLength(
window.position[1] + window.height / 2,
args.unit,
args.profile ?? 'document',
),
operation: windowOperation(window),
},
})),
issues: marks.issues,
}
}
export function buildOpeningMarkAnnotation(
opening: OpeningNode,
wall: WallNode,
levelData: OpeningFloorplanLevelData | undefined,
{
preferredSide = -1,
stroke = '#334155',
}: {
preferredSide?: -1 | 1
stroke?: string
} = {},
): FloorplanGeometry | null {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const wallLength = Math.hypot(dx, dz)
if (wallLength < 1e-6) return null
const dirX = dx / wallLength
const dirZ = dz / wallLength
const normalX = -dirZ
const normalZ = dirX
const side = interiorSide(wall, preferredSide)
const openingCenterX = wall.start[0] + dirX * opening.position[0]
const openingCenterZ = wall.start[1] + dirZ * opening.position[0]
const halfDepth = (wall.thickness ?? 0.1) / 2
const bubbleOffset = halfDepth + 0.5
const bubbleX = openingCenterX + normalX * bubbleOffset * side
const bubbleZ = openingCenterZ + normalZ * bubbleOffset * side
const explicitMark = opening.mark?.trim()
const mark = levelData?.markById.get(opening.id) ?? (explicitMark || fallbackMark(opening))
const bubbleWidth = Math.max(0.38, mark.length * 0.105 + 0.18)
const bubbleHeight = 0.32
const leaderEndOffset = bubbleOffset - bubbleHeight / 2
return withFloorplanGeometryMetadata(
{
kind: 'group',
children: [
{
kind: 'line',
x1: openingCenterX + normalX * halfDepth * side,
y1: openingCenterZ + normalZ * halfDepth * side,
x2: openingCenterX + normalX * leaderEndOffset * side,
y2: openingCenterZ + normalZ * leaderEndOffset * side,
stroke,
strokeWidth: 0.018,
},
{
kind: 'rect',
x: bubbleX - bubbleWidth / 2,
y: bubbleZ - bubbleHeight / 2,
width: bubbleWidth,
height: bubbleHeight,
rx: bubbleHeight / 2,
ry: bubbleHeight / 2,
fill: '#ffffff',
stroke,
strokeWidth: 0.02,
},
{
kind: 'text',
x: bubbleX,
y: bubbleZ,
text: mark,
fontSize: 0.15,
fill: stroke,
fontWeight: 700,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
textAnchor: 'middle',
dominantBaseline: 'middle',
upright: true,
},
],
},
{ annotationRole: 'opening-mark' },
)
}
export function resolveOpeningDimensionDocumentation(
opening: OpeningNode,
): OpeningDimensionDocumentation {
const constructionType = opening.constructionType ?? 'framed'
const requestedReference =
constructionType === 'masonry' &&
opening.dimensionReference === 'nominal' &&
opening.masonryOpeningWidth !== undefined
? 'masonry-opening'
: (opening.dimensionReference ?? 'nominal')
const dimensions = openingDocumentationDimensions(opening, requestedReference)
return {
constructionType,
reference: requestedReference,
locationPolicy: constructionType === 'masonry' ? 'edge-to-edge' : 'centerline',
width: dimensions.width,
height: dimensions.height,
prefix: openingDimensionPrefix(requestedReference),
verified: requestedReference === 'nominal' || dimensions.width !== null,
}
}
function resolveOpeningMarks<T extends OpeningNode>(
openings: ReadonlyArray<T>,
nodes: Readonly<Record<string, AnyNode>>,
kind: OpeningKind,
explicitLevelId?: string,
): MarkResolution {
const markById = new Map<string, string>()
const explicitMarks = new Map<string, string[]>()
const used = new Set<string>()
for (const opening of openings) {
const mark = opening.mark?.trim()
if (!mark) continue
markById.set(opening.id, mark)
used.add(mark.toLocaleUpperCase())
const normalized = mark.toLocaleUpperCase()
const ids = explicitMarks.get(normalized)
if (ids) ids.push(opening.id)
else explicitMarks.set(normalized, [opening.id])
}
const level = resolveLevel(openings[0], nodes, explicitLevelId)
let sequence = 1
for (const opening of openings) {
if (markById.has(opening.id)) continue
let candidate = automaticMark(kind, level?.level ?? 0, sequence)
while (used.has(candidate.toLocaleUpperCase())) {
sequence++
candidate = automaticMark(kind, level?.level ?? 0, sequence)
}
markById.set(opening.id, candidate)
used.add(candidate.toLocaleUpperCase())
sequence++
}
const issues = [...explicitMarks.entries()]
.filter(([, ids]) => ids.length > 1)
.map(([mark, ids]) => `Duplicate ${kind} mark ${mark} (${ids.length} instances)`)
return { markById, issues }
}
function resolveLevel(
opening: OpeningNode | undefined,
nodes: Readonly<Record<string, AnyNode>>,
explicitLevelId?: string,
): LevelNode | undefined {
const explicit = explicitLevelId ? nodes[explicitLevelId] : undefined
if (explicit?.type === 'level') return explicit
let current: AnyNode | undefined = opening
const visited = new Set<string>()
while (current?.parentId && !visited.has(current.parentId)) {
visited.add(current.parentId)
current = nodes[current.parentId]
if (current?.type === 'level') return current
}
return undefined
}
function automaticMark(kind: OpeningKind, level: number, sequence: number): string {
if (kind === 'door') return String((Math.max(0, level) + 1) * 100 + sequence)
return `W${String(sequence).padStart(2, '0')}`
}
function fallbackMark(opening: OpeningNode): string {
return opening.type === 'door' ? 'D?' : 'W?'
}
function interiorSide(wall: WallNode, fallback: -1 | 1): -1 | 1 {
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return -1
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return 1
return fallback
}
function formatSize(
width: number,
height: number,
unit: ConstructionLinearUnit,
profile: ConstructionLengthProfile,
): string {
return `${formatConstructionLength(width, unit, profile)} x ${formatConstructionLength(height, unit, profile)}`
}
function formatRoughOpening(
opening: OpeningNode,
unit: ConstructionLinearUnit,
profile: ConstructionLengthProfile,
): string {
if (opening.roughOpeningWidth === undefined || opening.roughOpeningHeight === undefined) {
return 'VERIFY'
}
return formatSize(opening.roughOpeningWidth, opening.roughOpeningHeight, unit, profile)
}
function openingDocumentationDimensions(
opening: OpeningNode,
reference: OpeningDimensionReference,
): { width: number | null; height: number | null } {
switch (reference) {
case 'nominal':
return { width: opening.width, height: opening.height }
case 'rough-opening':
return {
width: opening.roughOpeningWidth ?? null,
height: opening.roughOpeningHeight ?? null,
}
case 'masonry-opening':
return {
width: opening.masonryOpeningWidth ?? null,
height: opening.masonryOpeningHeight ?? null,
}
case 'finish-opening':
return {
width: opening.finishOpeningWidth ?? null,
height: opening.finishOpeningHeight ?? null,
}
}
}
function openingDimensionPrefix(reference: OpeningDimensionReference): string {
switch (reference) {
case 'nominal':
return ''
case 'rough-opening':
return 'RO'
case 'masonry-opening':
return 'MO'
case 'finish-opening':
return 'FO'
}
}
function doorOperation(door: DoorNode): string {
if (door.openingKind === 'opening') return 'None'
if (door.doorType === 'hinged')
return `${titleCase(door.hingesSide)} / ${titleCase(door.swingDirection)}`
if (door.doorType === 'sliding' || door.doorType === 'pocket' || door.doorType === 'barn') {
return `Slide ${titleCase(door.slideDirection)}`
}
return titleCase(door.doorType)
}
function doorHardware(door: DoorNode): string {
if (door.openingKind === 'opening') return 'None'
const hardware = []
if (door.doorCloser) hardware.push('Closer')
if (door.panicBar) hardware.push('Panic bar')
if (door.threshold) hardware.push('Threshold')
return hardware.length > 0 ? hardware.join(', ') : 'Standard'
}
function windowOperation(window: WindowNode): string {
if (window.openingKind === 'opening') return 'None'
if (window.windowType === 'fixed') return 'Fixed'
if (window.windowType === 'casement') {
return window.casementStyle === 'french'
? 'French casement'
: `${titleCase(window.hingesSide)} hinge`
}
if (window.windowType === 'awning' || window.windowType === 'hopper') {
return titleCase(window.awningDirection)
}
return titleCase(window.windowType)
}
function titleCase(value: string): string {
return value
.split('-')
.map((part) => part.charAt(0).toLocaleUpperCase() + part.slice(1))
.join(' ')
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test'
import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core'
import { buildOpeningPlacementDimensions } from './opening-placement-dimensions'
function dimensionTexts(geometry: FloorplanGeometry[]): string[] {
return geometry.flatMap((entry) => (entry.kind === 'dimension' ? [entry.text] : []))
}
function context(unit: 'metric' | 'imperial'): {
door: DoorNode
ctx: GeometryContext
} {
const door = DoorNode.parse({
id: 'door_entry',
parentId: 'wall_main',
position: [1.8288, 1.05, 0],
width: 0.6096,
})
const wall = WallNode.parse({
id: 'wall_main',
parentId: 'level_main',
children: [door.id],
start: [0, 0],
end: [3.6576, 0],
thickness: 0.2,
})
return {
door,
ctx: {
resolve: (id) => (id === door.id ? door : undefined),
children: [door],
siblings: [],
parent: wall,
viewState: {
selected: true,
unit,
highlighted: false,
hovered: false,
moving: true,
palette: {
selectedStroke: '#f97316',
hoveredStroke: '#fb923c',
wallHoverStroke: '#fb923c',
handleFill: '#ffffff',
handleStroke: '#f97316',
},
},
},
}
}
describe('buildOpeningPlacementDimensions', () => {
test('formats temporary placement clearances using the live metric preference', () => {
const { door, ctx } = context('metric')
expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual(['1.52m', '1.52m'])
})
test('formats temporary placement clearances using the live imperial preference', () => {
const { door, ctx } = context('imperial')
expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual([`5'-0"`, `5'-0"`])
})
})
@@ -12,6 +12,8 @@ import {
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { readFloorplanContext } from '@pascal-app/editor'
import { formatConstructionLength } from './construction-length'
import { resolveWallOpeningCeiling } from './wall-opening-ceiling'
/**
@@ -66,7 +68,8 @@ export function buildOpeningPlacementDimensions(
z1 + dirZ * along + outwardNormal[1] * halfThickness,
]
const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along]
const round = (value: number) => Number.parseFloat(value.toFixed(2))
const unit = ctx.viewState?.unit ?? 'metric'
const metricNotation = readFloorplanContext(ctx).metricNotation
// This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes
// same-kind nodes; doors and windows need each other, so resolve the wall's
@@ -118,7 +121,7 @@ export function buildOpeningPlacementDimensions(
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${round(gap.distance)}m`,
text: formatConstructionLength(gap.distance, unit, 'editor', { metricNotation }),
stroke: '#f97316',
})
}
@@ -126,7 +129,9 @@ export function buildOpeningPlacementDimensions(
// Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
if (guides.equalSpacing) {
const wallAngle = Math.atan2(dz, dx)
const text = `${round(guides.equalSpacing.gap)}m`
const text = formatConstructionLength(guides.equalSpacing.gap, unit, 'editor', {
metricNotation,
})
for (const seg of guides.equalSpacing.segments) {
out.push({
kind: 'equal-spacing-badge',
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test'
import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership'
function slabCreatorCount(viewMode: '2d' | '3d' | 'split', trigger: SlabCompletionTrigger): number {
const floorplanCommits = viewMode === '2d' && trigger === 'grid'
const registryCommits = shouldRegistryCommitSlab(viewMode, trigger)
return Number(floorplanCommits) + Number(registryCommits)
}
describe('slab placement ownership', () => {
test.each([
['2d', 'grid'],
['2d', 'keyboard'],
['3d', 'grid'],
['split', 'grid'],
] as const)('commits one slab in %s from %s completion', (viewMode, trigger) => {
expect(slabCreatorCount(viewMode, trigger)).toBe(1)
})
})
@@ -0,0 +1,8 @@
export type SlabCompletionTrigger = 'grid' | 'keyboard'
export function shouldRegistryCommitSlab(
viewMode: '2d' | '3d' | 'split',
trigger: SlabCompletionTrigger,
): boolean {
return trigger === 'keyboard' || viewMode !== '2d'
}
+12 -7
View File
@@ -24,6 +24,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership'
import { SlabNode } from './schema'
/**
@@ -140,8 +141,10 @@ export const SlabTool: React.FC = () => {
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
if (shouldRegistryCommitSlab(useEditor.getState().viewMode, 'grid')) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
}
setPoints([])
clearSlabSnapFeedback()
} else {
@@ -154,16 +157,18 @@ export const SlabTool: React.FC = () => {
// Finish the polygon (Enter or double-click): commit once there are enough
// vertices. Closing near the first vertex (in onGridClick) is the third way.
const finishDrawing = () => {
const finishDrawing = (trigger: SlabCompletionTrigger) => {
if (points.length < 3) return
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
if (shouldRegistryCommitSlab(useEditor.getState().viewMode, trigger)) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
}
setPoints([])
clearSlabSnapFeedback()
}
const onGridDoubleClick = (_event: GridEvent) => {
finishDrawing()
finishDrawing('grid')
}
const onCancel = () => {
@@ -175,7 +180,7 @@ export const SlabTool: React.FC = () => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
finishDrawing()
finishDrawing('keyboard')
}
}
document.addEventListener('keydown', onKeyDown)
+8
View File
@@ -1,4 +1,5 @@
import {
type AnyNodeId,
type HandleDescriptor,
type NodeDefinition,
resolveStairTotalRise,
@@ -9,6 +10,7 @@ import {
stairFootprintAABB,
useScene,
} from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
const MIN_CURVED_RISE = 0.3
const MIN_CURVED_WIDTH = 0.4
@@ -427,6 +429,12 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
schemaVersion: 1,
schema: StairNode,
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
linkedLevelIds: (node) =>
node.toLevelId && node.toLevelId !== node.parentId ? [node.toLevelId as AnyNodeId] : [],
} satisfies FloorplanNodeExtension<StairNodeType>,
},
snapProfile: 'structural',
// A footprint with a clear front: you approach a stair from the low end,
// which sits on the -Z side of the run (the run ascends along +Z). Show the
@@ -0,0 +1,141 @@
import { describe, expect, test } from 'bun:test'
import {
type FloorplanGeometry,
type GeometryContext,
LevelNode,
StairNode,
StairSegmentNode,
} from '@pascal-app/core'
import {
buildFloorplanStairEntry,
createFloorplanContextExtensions,
readFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import {
buildStairDocumentation,
resolveStairPlanDirection,
resolveStraightStairDirectionArrow,
stairPlanBreakStep,
} from './documentation'
function context(levelId = 'level_ground', unit: 'metric' | 'imperial' = 'metric') {
return {
resolve: () => undefined,
children: [],
siblings: [],
parent: LevelNode.parse({ id: levelId }),
viewState: {
selected: false,
highlighted: false,
hovered: false,
moving: false,
unit,
palette: { measurementStroke: '#123456' } as NonNullable<
GeometryContext['viewState']
>['palette'],
},
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
} satisfies GeometryContext
}
function straightFixture() {
const segment = StairSegmentNode.parse({
id: 'sseg_flight',
segmentType: 'stair',
width: 1.2,
length: 3,
height: 2.5,
stepCount: 10,
})
const stair = StairNode.parse({
id: 'stair_main',
parentId: 'level_ground',
fromLevelId: 'level_ground',
toLevelId: 'level_upper',
stairType: 'straight',
railingMode: 'both',
railingHeight: 0.92,
children: [segment.id],
})
const entry = buildFloorplanStairEntry(stair, [segment])!
return { entry, segment, stair }
}
function annotationTexts(geometry: FloorplanGeometry[]) {
return geometry.flatMap((entry) =>
entry.kind === 'text' &&
readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation'
? [entry.text]
: [],
)
}
describe('stair construction documentation', () => {
test('derives straight-flight direction, riser, tread, width, rail, and break annotations', () => {
const { entry, stair } = straightFixture()
const geometry = buildStairDocumentation(stair, entry, context())
expect(annotationTexts(geometry)).toEqual([
'UP',
'10 R @ 0.25m · T 0.3m · CLR W 1.2m',
'RAIL BOTH @ 0.92m',
])
expect(
geometry.some(
(entry) =>
entry.kind === 'polyline' &&
readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation',
),
).toBe(true)
})
test('uses DN and reverses the direction arrow on the destination level', () => {
const { entry, stair } = straightFixture()
const downArrow = resolveStraightStairDirectionArrow(entry, 'down')
expect(resolveStairPlanDirection(stair, 'level_ground')).toBe('up')
expect(resolveStairPlanDirection(stair, 'level_upper')).toBe('down')
expect(annotationTexts(buildStairDocumentation(stair, entry, context('level_upper')))[0]).toBe(
'DN',
)
expect(downArrow?.polyline.at(-1)).toEqual(entry.arrow?.polyline[0])
expect(downArrow?.head[0]).toEqual(entry.arrow?.polyline[0])
})
test('derives curved-stair tread depth at the walking line', () => {
const stair = StairNode.parse({
id: 'stair_curved',
parentId: 'level_ground',
stairType: 'curved',
width: 1.2,
innerRadius: 0.9,
sweepAngle: Math.PI / 2,
totalRise: 3,
stepCount: 12,
railingMode: 'left',
railingHeight: 1,
})
const entry = buildFloorplanStairEntry(stair, [])!
expect(annotationTexts(buildStairDocumentation(stair, entry, context()))).toEqual([
'12 R @ 0.25m · T(CL) 0.2m · CLR W 1.2m',
'UP',
'RAIL LEFT @ 1m',
])
})
test('uses the same construction notation in imperial plans', () => {
const { entry, stair } = straightFixture()
const texts = annotationTexts(
buildStairDocumentation(stair, entry, context('level_ground', 'imperial')),
)
expect(texts[1]).toContain(`10 R @ 9 13/16"`)
expect(texts[1]).toContain(`CLR W 3'-11 1/4"`)
})
test('aligns tread visibility with the documented break position', () => {
expect(stairPlanBreakStep(10)).toBe(7)
expect(stairPlanBreakStep(15)).toBe(11)
})
})
+349
View File
@@ -0,0 +1,349 @@
import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
type Point2D,
resolveStairTotalRise,
type StairNode,
useScene,
} from '@pascal-app/core'
import type {
FloorplanStairArrowEntry,
FloorplanStairEntry,
FloorplanStairSegmentEntry,
} from '@pascal-app/editor'
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
import {
type ConstructionLengthProfile,
type ConstructionMetricNotation,
formatConstructionLength,
} from '../shared/construction-length'
const ANNOTATION_OFFSET = 0.28
const ANNOTATION_FONT_SIZE = 0.125
const DIRECTION_FONT_SIZE = 0.16
const BREAK_POSITION = 0.68
const BREAK_ZIGZAG = 0.07
const MIN_ARROW_HEAD = 0.14
const MAX_ARROW_HEAD = 0.24
export type StairPlanDirection = 'up' | 'down'
export function resolveStairPlanDirection(
stair: StairNode,
activeLevelId: string | null | undefined,
): StairPlanDirection {
if (
activeLevelId &&
stair.toLevelId &&
stair.toLevelId !== stair.fromLevelId &&
activeLevelId === stair.toLevelId
) {
return 'down'
}
return 'up'
}
export function resolveStraightStairDirectionArrow(
entry: FloorplanStairEntry,
direction: StairPlanDirection,
): FloorplanStairArrowEntry | null {
const arrow = entry.arrow
if (!arrow || direction === 'up') return arrow
const polyline = [...arrow.polyline].reverse()
const tip = polyline[polyline.length - 1]
const tail = polyline[polyline.length - 2]
if (!(tip && tail)) return null
const bodyLength = distance(tail, tip)
if (bodyLength <= Number.EPSILON) return null
const headLength = clamp(bodyLength * 0.72, MIN_ARROW_HEAD, MAX_ARROW_HEAD)
const directionX = (tip.x - tail.x) / bodyLength
const directionY = (tip.y - tail.y) / bodyLength
const base = {
x: tip.x - directionX * headLength,
y: tip.y - directionY * headLength,
}
const halfWidth = headLength * 0.34
return {
polyline,
head: [
tip,
{ x: base.x - directionY * halfWidth, y: base.y + directionX * halfWidth },
{ x: base.x + directionY * halfWidth, y: base.y - directionX * halfWidth },
],
}
}
export function stairPlanBreakStep(stepCount: number): number {
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * BREAK_POSITION))
}
export function buildStairDocumentation(
stair: StairNode,
entry: FloorplanStairEntry,
ctx: GeometryContext,
): FloorplanGeometry[] {
const activeLevelId = ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId
const direction = resolveStairPlanDirection(stair, activeLevelId)
const unit = ctx.viewState?.unit ?? 'metric'
const floorplanContext = readFloorplanContext(ctx)
const profile: ConstructionLengthProfile =
floorplanContext.purpose === 'document' ? 'document' : 'editor'
const metricNotation = floorplanContext.metricNotation
const stroke = ctx.viewState?.palette.measurementStroke ?? '#334155'
return stair.stairType === 'straight'
? buildStraightDocumentation(stair, entry, direction, unit, profile, metricNotation, stroke)
: buildCurvedDocumentation(stair, direction, unit, profile, metricNotation, stroke)
}
function buildStraightDocumentation(
stair: StairNode,
entry: FloorplanStairEntry,
direction: StairPlanDirection,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry[] {
const geometries: FloorplanGeometry[] = []
const arrow = resolveStraightStairDirectionArrow(entry, direction)
const arrowStart = arrow?.polyline[0]
const arrowNext = arrow?.polyline[1]
if (arrowStart && arrowNext) {
const arrowDirection = normalizedDirection(arrowStart, arrowNext)
const labelPoint = arrowDirection
? {
x: arrowStart.x - arrowDirection.y * 0.18,
y: arrowStart.y + arrowDirection.x * 0.18,
}
: arrowStart
geometries.push(
annotationText(labelPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke),
)
}
let railNotePlaced = false
for (const segmentEntry of entry.segments) {
if (segmentEntry.segment.segmentType !== 'stair') continue
const frame = segmentFrame(segmentEntry)
if (!frame) continue
const segment = segmentEntry.segment
const riserCount = Math.max(1, Math.round(segment.stepCount))
const riserHeight = segment.height / riserCount
const treadDepth = segment.length / riserCount
const rightAnchor = {
x: frame.rightMid.x + frame.widthDirection.x * ANNOTATION_OFFSET,
y: frame.rightMid.y + frame.widthDirection.y * ANNOTATION_OFFSET,
}
geometries.push(
annotationText(
rightAnchor,
`${riserCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(segment.width, unit, profile, { metricNotation })}`,
ANNOTATION_FONT_SIZE,
stroke,
),
buildStraightBreakLine(segmentEntry, stroke),
)
if (!railNotePlaced && stair.railingMode !== 'none') {
const leftAnchor = {
x: frame.leftMid.x - frame.widthDirection.x * ANNOTATION_OFFSET,
y: frame.leftMid.y - frame.widthDirection.y * ANNOTATION_OFFSET,
}
geometries.push(
annotationText(
leftAnchor,
`RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`,
ANNOTATION_FONT_SIZE,
stroke,
),
)
railNotePlaced = true
}
}
return geometries
}
function buildCurvedDocumentation(
stair: StairNode,
direction: StairPlanDirection,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry[] {
const stairType = stair.stairType === 'spiral' ? 'spiral' : 'curved'
const stepCount = Math.max(stairType === 'spiral' ? 6 : 4, Math.round(stair.stepCount))
const sweep = normalizedSweep(stair)
const startAngle = -stair.rotation - sweep / 2
const endAngle = startAngle + sweep
const innerRadius = Math.max(stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius)
const outerRadius = innerRadius + stair.width
const walkingRadius = innerRadius + stair.width / 2
const riserHeight = resolveStairTotalRise(stair, useScene.getState().nodes) / stepCount
const treadDepth = (Math.abs(sweep) * walkingRadius) / stepCount
const center = { x: stair.position[0], y: stair.position[2] }
const noteAngle = (startAngle + endAngle) / 2
const notePoint = arcPoint(center, outerRadius + ANNOTATION_OFFSET, noteAngle)
const directionAngle = direction === 'up' ? startAngle + sweep * 0.18 : endAngle - sweep * 0.18
const directionPoint = arcPoint(center, walkingRadius, directionAngle)
const breakAngle = startAngle + sweep * BREAK_POSITION
const geometries: FloorplanGeometry[] = [
annotationText(
notePoint,
`${stepCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T(CL) ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(stair.width, unit, profile, { metricNotation })}`,
ANNOTATION_FONT_SIZE,
stroke,
),
annotationText(directionPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke),
buildCurvedBreakLine(center, innerRadius, outerRadius, breakAngle, stroke),
]
if (stair.railingMode !== 'none') {
geometries.push(
annotationText(
arcPoint(center, outerRadius + ANNOTATION_OFFSET * 2, noteAngle),
`RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`,
ANNOTATION_FONT_SIZE,
stroke,
),
)
}
return geometries
}
function buildStraightBreakLine(
segmentEntry: FloorplanStairSegmentEntry,
stroke: string,
): FloorplanGeometry {
const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.innerPolygon
if (!(backLeft && backRight && frontRight && frontLeft)) {
return { kind: 'group', children: [] }
}
const left = interpolate(backLeft, frontLeft, BREAK_POSITION)
const right = interpolate(backRight, frontRight, BREAK_POSITION)
const travel = normalizedDirection(backLeft, frontLeft) ?? { x: 0, y: 1 }
return {
kind: 'polyline',
points: [
toTuple(left),
offset(interpolate(left, right, 0.42), travel, BREAK_ZIGZAG),
offset(interpolate(left, right, 0.5), travel, -BREAK_ZIGZAG),
offset(interpolate(left, right, 0.58), travel, BREAK_ZIGZAG),
toTuple(right),
],
fill: 'none',
stroke,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
}
}
function buildCurvedBreakLine(
center: Point2D,
innerRadius: number,
outerRadius: number,
angle: number,
stroke: string,
): FloorplanGeometry {
const radial = { x: Math.cos(angle), y: Math.sin(angle) }
const tangent = { x: -radial.y, y: radial.x }
const pointAt = (t: number, tangentOffset = 0): FloorplanPoint => {
const radius = innerRadius + (outerRadius - innerRadius) * t
return [
center.x + radial.x * radius + tangent.x * tangentOffset,
center.y + radial.y * radius + tangent.y * tangentOffset,
]
}
return {
kind: 'polyline',
points: [
pointAt(0),
pointAt(0.42, BREAK_ZIGZAG),
pointAt(0.5, -BREAK_ZIGZAG),
pointAt(0.58, BREAK_ZIGZAG),
pointAt(1),
],
fill: 'none',
stroke,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
}
}
function annotationText(
point: Point2D,
text: string,
fontSize: number,
fill: string,
): FloorplanGeometry {
return {
kind: 'text',
x: point.x,
y: point.y,
text,
fontSize,
fill,
stroke: '#ffffff',
strokeWidth: fontSize * 0.22,
paintOrder: 'stroke',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontWeight: 650,
textAnchor: 'middle',
dominantBaseline: 'central',
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
}
}
function segmentFrame(segmentEntry: FloorplanStairSegmentEntry) {
const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.polygon
if (!(backLeft && backRight && frontRight && frontLeft)) return null
const widthDirection = normalizedDirection(backLeft, backRight)
if (!widthDirection) return null
return {
widthDirection,
leftMid: interpolate(backLeft, frontLeft, 0.5),
rightMid: interpolate(backRight, frontRight, 0.5),
}
}
function normalizedSweep(stair: StairNode): number {
const defaultSweep = stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2
const sweep = stair.sweepAngle ?? defaultSweep
if (Math.abs(sweep) < Math.PI * 2) return sweep
return Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
}
function arcPoint(center: Point2D, radius: number, angle: number): Point2D {
return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }
}
function normalizedDirection(start: Point2D, end: Point2D): Point2D | null {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.hypot(dx, dy)
return length <= Number.EPSILON ? null : { x: dx / length, y: dy / length }
}
function interpolate(start: Point2D, end: Point2D, t: number): Point2D {
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t }
}
function offset(point: Point2D, direction: Point2D, amount: number): FloorplanPoint {
return [point.x + direction.x * amount, point.y + direction.y * amount]
}
function toTuple(point: Point2D): FloorplanPoint {
return [point.x, point.y]
}
function distance(first: Point2D, second: Point2D): number {
return Math.hypot(second.x - first.x, second.y - first.y)
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test'
import {
type FloorplanGeometry,
type GeometryContext,
LevelNode,
StairNode,
StairSegmentNode,
} from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
import { buildStairFloorplan } from './floorplan'
function textValues(geometry: FloorplanGeometry | null) {
if (geometry?.kind !== 'group') return []
return geometry.children.flatMap((child) =>
child.kind === 'text' &&
readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation'
? [child.text]
: [],
)
}
describe('buildStairFloorplan documentation', () => {
test('integrates stair notes, break line, and visible treads below the break', () => {
const segment = StairSegmentNode.parse({
id: 'sseg_main',
width: 1.2,
length: 3,
height: 2.5,
stepCount: 10,
})
const stair = StairNode.parse({
id: 'stair_main',
parentId: 'level_ground',
fromLevelId: 'level_ground',
toLevelId: 'level_upper',
children: [segment.id],
railingMode: 'both',
})
const geometry = buildStairFloorplan(stair, {
resolve: () => undefined,
children: [segment],
siblings: [],
parent: LevelNode.parse({ id: 'level_ground' }),
} satisfies GeometryContext)
expect(textValues(geometry)[0]).toBe('UP')
expect(textValues(geometry)).toContain('10 R @ 0.25m · T 0.3m · CLR W 1.2m')
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
expect(
geometry.children.some(
(child) =>
child.kind === 'polyline' &&
readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation',
),
).toBe(true)
expect(
geometry.children.filter((child) => child.kind === 'polygon' && child.fill === '#262626'),
).toHaveLength(6)
expect(geometry.children.some((child) => 'strokeDasharray' in child)).toBe(false)
})
})
+39 -13
View File
@@ -17,8 +17,15 @@ import {
buildSvgAnnularSectorPath,
buildSvgArcPath,
buildSvgArrowHeadPoints,
floorplanGeometryMetadata,
getArcPlanPoint,
} from '@pascal-app/editor'
import {
buildStairDocumentation,
resolveStairPlanDirection,
resolveStraightStairDirectionArrow,
stairPlanBreakStep,
} from './documentation'
/**
* Stage C floor-plan emitter for stair. The stair is the parent; its
@@ -105,7 +112,10 @@ export function buildStairFloorplan(
// Tread bars — one per visible step inside the segment.
// `buildFloorplanStairEntry` already returns the thickened
// polygons; we emit them as filled polygons.
for (const treadBar of segmentEntry.treadBars) {
const breakStep = stairPlanBreakStep(segmentEntry.segment.stepCount)
for (let treadIndex = 0; treadIndex < segmentEntry.treadBars.length; treadIndex += 1) {
if (treadIndex + 1 >= breakStep) continue
const treadBar = segmentEntry.treadBars[treadIndex]!
children.push({
kind: 'polygon',
points: toFloorplanPoints(treadBar),
@@ -256,10 +266,9 @@ export function buildStairFloorplan(
const stepBase = stairType === 'spiral' ? 6 : 4
const stepCount = Math.max(stepBase, Math.round(stair.stepCount ?? 10))
const stepSweep = normalizedSweepAngle / stepCount
// For spirals only: the last ~32% of the sweep is dashed (matches
// the legacy `dashedFromIndex = Math.floor(stepCount * 0.68)`).
const dashedFromIndex = stairType === 'spiral' ? Math.floor(stepCount * 0.68) : Infinity
const breakStep = stairPlanBreakStep(stepCount)
for (let index = 0; index <= stepCount; index += 1) {
if (index >= breakStep && index !== stepCount) continue
const angle = sectorStartAngle + stepSweep * index
const inner = getArcPlanPoint(stairCenter, innerRadius, angle)
const outer = getArcPlanPoint(stairCenter, outerRadius, angle)
@@ -268,8 +277,7 @@ export function buildStairFloorplan(
// Curved: regular stroke everywhere, but both the starting and the
// ending step lines are bolded (matches the legacy
// `<FloorplanStairLayer>` curved branch).
// Spiral: only the last step is accented + bolded; intermediate
// steps past `dashedFromIndex` are dashed.
// Spiral: only the last step is accented + bolded.
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
const stepWidth =
stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1
@@ -281,7 +289,6 @@ export function buildStairFloorplan(
y2: outer.y,
stroke: stairType === 'spiral' && isLast ? stairAccent : stairStroke,
strokeWidth: stepWidth,
strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined,
vectorEffect: 'non-scaling-stroke',
})
}
@@ -322,9 +329,18 @@ export function buildStairFloorplan(
}
// 6. Direction arrow — head only, at the upper end of the sweep.
const arrowAngle = visualSectorEndAngle - stepSweep * 0.8
const direction = resolveStairPlanDirection(
stair,
ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId,
)
const arrowAngle =
direction === 'up'
? visualSectorEndAngle - stepSweep * 0.8
: sectorStartAngle + stepSweep * 0.8
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
const tangentAngle = arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
const sweepDirection = normalizedSweepAngle >= 0 ? 1 : -1
const tangentAngle =
arrowAngle + sweepDirection * (direction === 'up' ? Math.PI / 2 : -Math.PI / 2)
const arrowSize = clamp(stair.width * (stairType === 'spiral' ? 0.18 : 0.16), 0.1, 0.18)
const headPts = buildSvgArrowHeadPoints(arrowPoint, tangentAngle, arrowSize)
children.push({
@@ -332,6 +348,7 @@ export function buildStairFloorplan(
points: headPts.map((p) => [p.x, p.y] as FloorplanPoint),
fill: stairAccent,
stroke: 'none',
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
})
// 7. Resize arrows — mirror of the 3D `CurvedStairWidthArrow`,
@@ -397,29 +414,38 @@ export function buildStairFloorplan(
// the stair-segment chain in straight space and produces a malformed
// polyline once the chain is laid around an arc.
if (stairType === 'straight' && entry.arrow) {
if (entry.arrow.polyline.length >= 2) {
const direction = resolveStairPlanDirection(
stair,
ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId,
)
const directionArrow = resolveStraightStairDirectionArrow(entry, direction)
if (directionArrow && directionArrow.polyline.length >= 2) {
children.push({
kind: 'polyline',
points: toFloorplanPoints(entry.arrow.polyline),
points: toFloorplanPoints(directionArrow.polyline),
fill: 'none',
stroke: stairAccent,
strokeWidth: 0.02,
strokeLinecap: 'round',
strokeLinejoin: 'round',
opacity: showSelectedChrome ? 0.92 : 0.72,
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
})
}
if (entry.arrow.head.length >= 3) {
if (directionArrow && directionArrow.head.length >= 3) {
children.push({
kind: 'polygon',
points: toFloorplanPoints(entry.arrow.head),
points: toFloorplanPoints(directionArrow.head),
fill: stairAccent,
stroke: 'none',
opacity: showSelectedChrome ? 0.92 : 0.72,
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
})
}
}
children.push(...buildStairDocumentation(stair, entry, ctx))
// Whole-stair rotation handle — sister to the 3D `stairRotateHandle`
// (arc-resize, curved-arrow). 2D doesn't have a dedicated curved-arrow
// primitive, so we emit a `move-arrow` with the `'stair-rotate'`
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import { StructuralGridNode } from '@pascal-app/core'
import {
collectStructuralGridAxes,
resolveStructuralGridReference,
resolveStructuralGridSnap,
} from './coordination'
const vertical = StructuralGridNode.parse({
id: 'structural-grid_1',
parentId: 'level_main',
start: [2, 0],
end: [2, 8],
label: '1',
})
const horizontal = StructuralGridNode.parse({
id: 'structural-grid_a',
parentId: 'level_main',
start: [0, 3],
end: [8, 3],
label: 'A',
})
describe('structural-grid coordination', () => {
test('snaps columns to a nearby grid intersection before an individual axis', () => {
expect(resolveStructuralGridSnap([2.18, 3.12], [vertical, horizontal])).toMatchObject({
point: [2, 3],
kind: 'intersection',
reference: 'A-1',
})
})
test('projects onto one axis when no intersection is within range', () => {
expect(resolveStructuralGridSnap([2.12, 6], [vertical, horizontal])).toMatchObject({
point: [2, 6],
kind: 'line',
reference: '1',
})
})
test('does not snap beyond the configured distance or past an axis endpoint', () => {
expect(resolveStructuralGridSnap([2.4, 6], [vertical, horizontal])).toBeNull()
expect(resolveStructuralGridSnap([2.05, 8.4], [vertical], 0.25)).toBeNull()
})
test('derives an associative alphabetic-numeric reference at the column center', () => {
expect(resolveStructuralGridReference([2, 3], [vertical, horizontal])).toBe('A-1')
expect(resolveStructuralGridReference([2, 3], [vertical, { ...horizontal, label: 'B' }])).toBe(
'B-1',
)
})
test('collects only visible axes from the active level', () => {
const hidden = StructuralGridNode.parse({
...horizontal,
id: 'structural-grid_hidden',
visible: false,
})
const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal, [hidden.id]: hidden }
expect(collectStructuralGridAxes(nodes, 'level_main').map((axis) => axis.id)).toEqual([
vertical.id,
horizontal.id,
])
expect(collectStructuralGridAxes(nodes, 'level_other')).toEqual([])
})
})
@@ -0,0 +1,149 @@
import type { AnyNode, StructuralGridNode } from '@pascal-app/core'
export type StructuralGridPoint = readonly [x: number, z: number]
export type StructuralGridSnap = {
point: [number, number]
distance: number
kind: 'intersection' | 'line'
axes: StructuralGridNode[]
reference: string
}
export const STRUCTURAL_GRID_SNAP_DISTANCE_M = 0.25
export const STRUCTURAL_GRID_REFERENCE_TOLERANCE_M = 0.02
const EPSILON = 1e-9
export function collectStructuralGridAxes(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined,
): StructuralGridNode[] {
if (!levelId) return []
return Object.values(nodes).filter(
(node): node is StructuralGridNode =>
node.type === 'structural-grid' && node.parentId === levelId && node.visible !== false,
)
}
export function formatStructuralGridReference(axes: readonly StructuralGridNode[]): string {
const labels = [...new Set(axes.map((axis) => axis.label.trim()).filter(Boolean))]
labels.sort((left, right) => {
const leftFamily = structuralGridLabelSortFamily(left)
const rightFamily = structuralGridLabelSortFamily(right)
if (leftFamily !== rightFamily) return leftFamily - rightFamily
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
})
return labels.join('-')
}
export function resolveStructuralGridSnap(
point: StructuralGridPoint,
axes: readonly StructuralGridNode[],
maxDistance = STRUCTURAL_GRID_SNAP_DISTANCE_M,
): StructuralGridSnap | null {
let nearestIntersection: StructuralGridSnap | null = null
for (let firstIndex = 0; firstIndex < axes.length; firstIndex += 1) {
const first = axes[firstIndex]
if (!first) continue
for (let secondIndex = firstIndex + 1; secondIndex < axes.length; secondIndex += 1) {
const second = axes[secondIndex]
if (!second) continue
const intersection = segmentIntersection(first.start, first.end, second.start, second.end)
if (!intersection) continue
const distance = pointDistance(point, intersection)
if (
distance > maxDistance ||
(nearestIntersection && distance >= nearestIntersection.distance)
) {
continue
}
nearestIntersection = {
point: intersection,
distance,
kind: 'intersection',
axes: [first, second],
reference: formatStructuralGridReference([first, second]),
}
}
}
if (nearestIntersection) return nearestIntersection
let nearestLine: StructuralGridSnap | null = null
for (const axis of axes) {
const projected = closestPointOnSegment(point, axis.start, axis.end)
const distance = pointDistance(point, projected)
if (distance > maxDistance || (nearestLine && distance >= nearestLine.distance)) continue
nearestLine = {
point: projected,
distance,
kind: 'line',
axes: [axis],
reference: formatStructuralGridReference([axis]),
}
}
return nearestLine
}
export function resolveStructuralGridReference(
point: StructuralGridPoint,
axes: readonly StructuralGridNode[],
tolerance = STRUCTURAL_GRID_REFERENCE_TOLERANCE_M,
): string | null {
const matching = axes.filter(
(axis) => pointDistance(point, closestPointOnSegment(point, axis.start, axis.end)) <= tolerance,
)
const reference = formatStructuralGridReference(matching)
return reference || null
}
function structuralGridLabelSortFamily(label: string): number {
if (/^[A-Za-z]+$/.test(label)) return 0
if (/^\d+$/.test(label)) return 1
return 2
}
function pointDistance(first: StructuralGridPoint, second: StructuralGridPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
function closestPointOnSegment(
point: StructuralGridPoint,
start: StructuralGridPoint,
end: StructuralGridPoint,
): [number, number] {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared <= EPSILON) return [start[0], start[1]]
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
)
return [start[0] + dx * t, start[1] + dz * t]
}
function segmentIntersection(
firstStart: StructuralGridPoint,
firstEnd: StructuralGridPoint,
secondStart: StructuralGridPoint,
secondEnd: StructuralGridPoint,
): [number, number] | null {
const firstDx = firstEnd[0] - firstStart[0]
const firstDz = firstEnd[1] - firstStart[1]
const secondDx = secondEnd[0] - secondStart[0]
const secondDz = secondEnd[1] - secondStart[1]
const denominator = firstDx * secondDz - firstDz * secondDx
if (Math.abs(denominator) <= EPSILON) return null
const offsetX = secondStart[0] - firstStart[0]
const offsetZ = secondStart[1] - firstStart[1]
const firstT = (offsetX * secondDz - offsetZ * secondDx) / denominator
const secondT = (offsetX * firstDz - offsetZ * firstDx) / denominator
if (firstT < -EPSILON || firstT > 1 + EPSILON || secondT < -EPSILON || secondT > 1 + EPSILON) {
return null
}
return [firstStart[0] + firstDx * firstT, firstStart[1] + firstDz * firstT]
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
import { structuralGridDefinition } from './definition'
describe('structuralGridDefinition', () => {
test('registers as a floor-plan-only structural annotation', () => {
expect(structuralGridDefinition.kind).toBe('structural-grid')
expect(structuralGridDefinition.bake).toBe('strip')
expect(structuralGridDefinition.dirtyTracking).toBe(false)
expect(structuralGridDefinition.floorplan).toBeFunction()
expect(structuralGridDefinition.capabilities.selectable).toBeDefined()
expect(getFloorplanNodeExtension(structuralGridDefinition)?.preferredView).toBe('2d')
})
})
@@ -0,0 +1,59 @@
import type { NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { buildStructuralGridFloorplan } from './floorplan'
import { StructuralGridNode } from './schema'
export const structuralGridDefinition: NodeDefinition<typeof StructuralGridNode> = {
kind: 'structural-grid',
bake: 'strip',
schemaVersion: 1,
schema: StructuralGridNode,
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
tool: () => import('./floorplan-tool'),
preferredView: '2d',
} satisfies FloorplanNodeExtension<StructuralGridNode>,
},
snapProfile: 'structural',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
start: [0, 0],
end: [0, 5],
label: '1',
showStartBubble: true,
showEndBubble: true,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
deletable: true,
presettable: false,
},
dirtyTracking: false,
floorplan: buildStructuralGridFloorplan,
toolHints: [
{ key: 'Left click', label: 'Start grid axis' },
{ key: 'Left click', label: 'Finish grid axis' },
{ key: 'Alt', label: 'Bypass snapping' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Structural Grid',
description: 'Persistent construction grid axis with identification bubbles.',
icon: { kind: 'url', src: '/icons/structural-grid.webp' },
paletteSection: 'structure',
paletteOrder: 72,
},
mcp: {
description:
'A floor-plan structural datum axis defined by two level-local points and a grid identifier.',
},
}
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, StructuralGridNode } from '@pascal-app/core'
import {
alphabeticGridLabel,
nextStructuralGridLabel,
shouldConsumeStructuralGridPointerEvent,
snapStructuralGridAngle,
structuralGridLabelFamily,
} from './floorplan-tool'
describe('structural-grid drafting helpers', () => {
test('assigns numbers to vertical axes and letters to horizontal axes', () => {
expect(structuralGridLabelFamily([2, 0], [2, 8])).toBe('numeric')
expect(structuralGridLabelFamily([0, 3], [8, 3])).toBe('alphabetic')
})
test('continues labels within the active level and direction family', () => {
const vertical = StructuralGridNode.parse({
id: 'structural-grid_1',
parentId: 'level_main',
start: [1, 0],
end: [1, 6],
label: '1',
})
const horizontal = StructuralGridNode.parse({
id: 'structural-grid_a',
parentId: 'level_main',
start: [0, 1],
end: [6, 1],
label: 'A',
})
const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal } as Record<
string,
AnyNode
>
expect(nextStructuralGridLabel(nodes, 'level_main', [2, 0], [2, 6])).toBe('2')
expect(nextStructuralGridLabel(nodes, 'level_main', [0, 2], [6, 2])).toBe('B')
expect(nextStructuralGridLabel(nodes, 'level_other', [2, 0], [2, 6])).toBe('1')
})
test('supports labels beyond Z and snaps angles to 45-degree increments', () => {
expect(alphabeticGridLabel(25)).toBe('Z')
expect(alphabeticGridLabel(26)).toBe('AA')
const snapped = snapStructuralGridAngle([0, 0], [4, 0.4])
expect(snapped[1]).toBeCloseTo(0)
expect(Math.hypot(snapped[0], snapped[1])).toBeCloseTo(Math.hypot(4, 0.4))
})
test('leaves right-button drag moves available for floor-plan rotation', () => {
expect(
shouldConsumeStructuralGridPointerEvent({
type: 'pointermove',
button: -1,
buttons: 2,
}),
).toBe(false)
})
})
@@ -0,0 +1,313 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
StructuralGridNode,
type StructuralGridNode as StructuralGridNodeType,
} from '@pascal-app/core'
import {
clearSurfacePlanSnapFeedback,
type FloorplanToolContext,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
resolveSurfacePlanPointSnap,
triggerSFX,
useFloorplanRender,
useInteractionScope,
} from '@pascal-app/editor'
import { useCallback, useEffect, useRef, useState } from 'react'
const MIN_GRID_LENGTH = 0.01
const GRID_BUBBLE_RADIUS = 0.22
const GRID_LABEL_SIZE = 0.18
const ANGLE_INCREMENT = Math.PI / 4
type PlanPoint = [number, number]
export type StructuralGridLabelFamily = 'numeric' | 'alphabetic'
export function shouldConsumeStructuralGridPointerEvent(event: {
type: string
button: number
buttons: number
}): boolean {
if (event.type === 'pointerdown') return event.button === 0
return (event.buttons & 0b110) === 0
}
function snap(value: number, step: number): number {
return step > 0 ? Math.round(value / step) * step : value
}
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null {
const matrix = group.getScreenCTM()
if (!matrix) return null
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
return [local.x, local.y]
}
export function structuralGridLabelFamily(
start: PlanPoint,
end: PlanPoint,
): StructuralGridLabelFamily {
return Math.abs(end[1] - start[1]) >= Math.abs(end[0] - start[0]) ? 'numeric' : 'alphabetic'
}
export function alphabeticGridLabel(index: number): string {
let value = Math.max(0, Math.floor(index))
let label = ''
do {
label = String.fromCharCode(65 + (value % 26)) + label
value = Math.floor(value / 26) - 1
} while (value >= 0)
return label
}
export function nextStructuralGridLabel(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string,
start: PlanPoint,
end: PlanPoint,
): string {
const family = structuralGridLabelFamily(start, end)
const used = new Set(
Object.values(nodes)
.filter(
(node): node is StructuralGridNodeType =>
node.type === 'structural-grid' &&
node.parentId === levelId &&
structuralGridLabelFamily(node.start, node.end) === family,
)
.map((node) => node.label.toUpperCase()),
)
for (let index = 0; ; index += 1) {
const candidate = family === 'numeric' ? String(index + 1) : alphabeticGridLabel(index)
if (!used.has(candidate)) return candidate
}
}
export function snapStructuralGridAngle(start: PlanPoint, point: PlanPoint): PlanPoint {
const dx = point[0] - start[0]
const dz = point[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < MIN_GRID_LENGTH) return point
const angle = Math.round(Math.atan2(dz, dx) / ANGLE_INCREMENT) * ANGLE_INCREMENT
return [start[0] + Math.cos(angle) * length, start[1] + Math.sin(angle) * length]
}
export function FloorplanStructuralGridToolLayer({
activeLevelId,
finishTool,
gridSnapStep,
sceneApi,
selectNode,
}: FloorplanToolContext) {
const groupRef = useRef<SVGGElement>(null)
const startRef = useRef<PlanPoint | null>(null)
const [start, setStart] = useState<PlanPoint | null>(null)
const [hover, setHover] = useState<PlanPoint | null>(null)
const renderContext = useFloorplanRender()
useEffect(() => {
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'structural-grid' })
return () =>
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'structural-grid')
}, [])
const updateStart = useCallback((point: PlanPoint | null) => {
startRef.current = point
setStart(point)
}, [])
useEffect(() => {
updateStart(null)
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): PlanPoint | null => {
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
if (!raw) return null
const anglePoint =
startRef.current && !event.altKey && isAngleSnapActive()
? snapStructuralGridAngle(startRef.current, raw)
: raw
const step = !event.altKey && isGridSnapActive() ? gridSnapStep : 0
const fallback: PlanPoint = [snap(anglePoint[0], step), snap(anglePoint[1], step)]
const snapped = resolveSurfacePlanPointSnap({
rawPoint: anglePoint,
fallbackPoint: fallback,
levelId: activeLevelId,
magnetic: !event.altKey && isMagneticSnapActive(),
align: isMagneticSnapActive(),
})
return snapped.point
}
const onPointerDown = (event: PointerEvent) => {
if (shouldConsumeStructuralGridPointerEvent(event)) consume(event)
}
const onPointerMove = (event: PointerEvent) => {
if (shouldConsumeStructuralGridPointerEvent(event)) consume(event)
setHover(resolveEvent(event))
}
const onPointerLeave = () => {
clearSurfacePlanSnapFeedback()
setHover(null)
}
const onClick = (event: MouseEvent) => {
if (event.button !== 0) return
consume(event)
const point = resolveEvent(event)
if (!point) return
const currentStart = startRef.current
if (!currentStart) {
updateStart(point)
triggerSFX('sfx:grid-snap')
return
}
if (Math.hypot(point[0] - currentStart[0], point[1] - currentStart[1]) < MIN_GRID_LENGTH) {
return
}
const label = nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, currentStart, point)
const node = StructuralGridNode.parse({
name: `Grid ${label}`,
start: currentStart,
end: point,
label,
})
sceneApi.upsert(node, activeLevelId as AnyNodeId)
selectNode(node.id)
triggerSFX('sfx:structure-build')
updateStart(null)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopImmediatePropagation()
markToolCancelConsumed()
if (startRef.current) {
updateStart(null)
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)
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)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('blur', onBlur)
}
}, [activeLevelId, finishTool, gridSnapStep, sceneApi, selectNode, updateStart])
if (!activeLevelId) return null
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
const reticleRadius = 9 * unitsPerPixel
const label =
start && hover ? nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, start, hover) : null
const renderBubble = (point: PlanPoint, key: string) => (
<g key={key} pointerEvents="none">
<circle
cx={point[0]}
cy={point[1]}
fill="#ffffff"
r={GRID_BUBBLE_RADIUS}
stroke="#0ea5e9"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<g
transform={`translate(${point[0]} ${point[1]}) rotate(${-(renderContext?.sceneRotationDeg ?? 0)})`}
>
<text
dominantBaseline="middle"
fill="#0369a1"
fontSize={GRID_LABEL_SIZE}
fontWeight={700}
textAnchor="middle"
x={0}
y={0}
>
{label}
</text>
</g>
</g>
)
return (
<g ref={groupRef}>
{start && hover && label ? (
<g pointerEvents="none">
<line
stroke="#0ea5e9"
strokeDasharray="10 4 2 4"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={start[0]}
x2={hover[0]}
y1={start[1]}
y2={hover[1]}
/>
{renderBubble(start, 'start')}
{renderBubble(hover, 'end')}
</g>
) : null}
{hover ? (
<g pointerEvents="none">
<circle
cx={hover[0]}
cy={hover[1]}
fill="none"
r={reticleRadius}
stroke="#0ea5e9"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
<line
stroke="#0ea5e9"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover[0] - reticleRadius * 1.4}
x2={hover[0] + reticleRadius * 1.4}
y1={hover[1]}
y2={hover[1]}
/>
<line
stroke="#0ea5e9"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover[0]}
x2={hover[0]}
y1={hover[1] - reticleRadius * 1.4}
y2={hover[1] + reticleRadius * 1.4}
/>
</g>
) : null}
</g>
)
}
export default FloorplanStructuralGridToolLayer
@@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test'
import { type GeometryContext, StructuralGridNode } from '@pascal-app/core'
import { buildStructuralGridFloorplan } from './floorplan'
const context = {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
} satisfies GeometryContext
describe('buildStructuralGridFloorplan', () => {
test('draws a datum axis with labels at both ends', () => {
const grid = StructuralGridNode.parse({
id: 'structural-grid_axis-1',
start: [2, 1],
end: [2, 8],
label: '3',
})
const geometry = buildStructuralGridFloorplan(grid, context)
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
expect(geometry.children[0]).toMatchObject({
kind: 'line',
x1: 2,
y1: 1,
x2: 2,
y2: 8,
strokeDasharray: '10 4 2 4',
})
expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(2)
expect(JSON.stringify(geometry)).toContain('"text":"3"')
})
test('respects independent endpoint-bubble visibility', () => {
const grid = StructuralGridNode.parse({
start: [0, 0],
end: [5, 0],
label: 'A',
showStartBubble: false,
})
const geometry = buildStructuralGridFloorplan(grid, context)
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(1)
})
test('omits a degenerate axis', () => {
const grid = StructuralGridNode.parse({ start: [1, 1], end: [1, 1] })
expect(buildStructuralGridFloorplan(grid, context)).toBeNull()
})
})
@@ -0,0 +1,76 @@
import type {
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
StructuralGridNode,
} from '@pascal-app/core'
import { withFloorplanGeometryMetadata } from '@pascal-app/editor'
const GRID_BUBBLE_RADIUS = 0.22
const GRID_LABEL_SIZE = 0.18
function bubble(point: FloorplanPoint, label: string, stroke: string): FloorplanGeometry {
return {
kind: 'group',
children: [
{
kind: 'circle',
cx: point[0],
cy: point[1],
r: GRID_BUBBLE_RADIUS,
fill: '#ffffff',
stroke,
strokeWidth: 1.2,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'text',
x: point[0],
y: point[1],
text: label,
fontSize: GRID_LABEL_SIZE,
fill: stroke,
fontWeight: 700,
textAnchor: 'middle',
dominantBaseline: 'middle',
upright: true,
},
],
}
}
export function buildStructuralGridFloorplan(
node: StructuralGridNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const length = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1])
if (length < 0.001) return null
const selected = ctx.viewState?.selected ?? false
const highlighted = ctx.viewState?.highlighted ?? false
const palette = ctx.viewState?.palette
const active = selected || highlighted
const stroke = active && palette ? palette.selectedStroke : '#475569'
const children: FloorplanGeometry[] = [
{
kind: 'line',
x1: node.start[0],
y1: node.start[1],
x2: node.end[0],
y2: node.end[1],
stroke,
strokeWidth: active ? 1.6 : 1,
strokeDasharray: '10 4 2 4',
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'stroke',
},
]
if (node.showStartBubble) children.push(bubble(node.start, node.label, stroke))
if (node.showEndBubble) children.push(bubble(node.end, node.label, stroke))
return withFloorplanGeometryMetadata(
{ kind: 'group', children },
{ annotationRole: 'structural-grid' },
)
}
@@ -0,0 +1 @@
export { structuralGridDefinition } from './definition'
@@ -0,0 +1 @@
export { StructuralGridNode } from '@pascal-app/core'
@@ -0,0 +1,335 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, WallNode, type WallNode as WallNodeType } from '@pascal-app/core'
import { constructionDimensionStandard } from '../shared/construction-dimension-standards'
import {
buildLevelWallConstructionDimensionPlan,
type PlannedConstructionDimension,
renderPlannedConstructionDimensions,
} from './construction-dimensions'
import { computeWallFloorplanLevelData } from './floorplan'
function wall(overrides: Partial<WallNodeType>): WallNodeType {
return WallNode.parse({
id: 'wall',
parentId: 'level_main',
start: [0, 0],
end: [1, 0],
frontSide: 'interior',
backSide: 'interior',
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.2,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
{
id: 'interior-finish',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.04,
materialRef: 'library:cladding',
datumEligible: ['finish-face'],
},
],
...overrides,
})
}
function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false) {
const top = wall({
id: 'wall_top',
start: [0, 0],
end: splitAtPartition ? [4, 0] : [10, 0],
frontSide: 'exterior',
backSide: 'interior',
})
const topContinuation = splitAtPartition
? wall({
id: 'wall_top_continuation',
start: [4, 0],
end: [10, 0],
frontSide: 'exterior',
backSide: 'interior',
})
: undefined
const right = wall({
id: 'wall_right',
start: [10, 0],
end: [10, -6],
frontSide: 'exterior',
backSide: 'interior',
})
const bottom = wall({
id: 'wall_bottom',
start: [10, -6],
end: [0, -6],
frontSide: 'exterior',
backSide: 'interior',
})
const left = wall({
id: 'wall_left',
start: [0, -6],
end: [0, 0],
frontSide: 'exterior',
backSide: 'interior',
})
const partition = wall({
id: 'wall_partition',
start: [4, partitionSpansPlan ? -6 : -4],
end: [4, 0],
frontSide: 'interior',
backSide: 'interior',
assemblyLayers: [
{
id: 'partition-stud-core',
role: 'structure',
side: 'core',
thickness: 0.12,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
{
id: 'partition-finish-left',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'partition-finish-right',
role: 'interior-finish',
side: 'exterior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'partition-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.1,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const walls = [top, ...(topContinuation ? [topContinuation] : []), right, bottom, left, partition]
const nodes = Object.fromEntries(walls.map((candidate) => [candidate.id, candidate])) as Record<
string,
AnyNode
>
return { nodes, top, walls }
}
function topFacadePlan(
datumPolicy: 'wall-face' | 'finish-face' | 'centerline' | 'structural-face',
splitAtPartition = false,
) {
const { nodes, top, walls } = topFacadeFixture(splitAtPartition)
return (
buildLevelWallConstructionDimensionPlan(
walls,
nodes,
constructionDimensionStandard({ datumPolicy }),
).get(top.id) ?? []
)
}
function tierEntries(
plan: readonly PlannedConstructionDimension[],
tier: PlannedConstructionDimension['tier'],
) {
return plan.filter((entry) => entry.tier === tier)
}
describe('automatic wall dimension reference policy', () => {
test('keeps exterior corner witnesses on outside stud faces in every mode', () => {
for (const datumPolicy of ['finish-face', 'centerline', 'structural-face'] as const) {
const overall = tierEntries(topFacadePlan(datumPolicy), 'overall')[0]
expect(overall?.start[0]).toBeCloseTo(-0.1)
expect(overall?.end[0]).toBeCloseTo(10.1)
expect(overall?.start[1]).toBeCloseTo(0.1)
expect(overall?.end[1]).toBeCloseTo(0.1)
}
})
test('applies the selected reference only to the intersecting partition', () => {
const intersection = (datumPolicy: 'finish-face' | 'centerline' | 'structural-face') => {
const entries = tierEntries(topFacadePlan(datumPolicy), 'partitions')
return entries[0]?.end[0]
}
expect(intersection('finish-face')).toBeCloseTo(3.92)
expect(intersection('centerline')).toBeCloseTo(4)
expect(intersection('structural-face')).toBeCloseTo(3.94)
})
test('keeps finished faces, centerline, and face of stud as distinct display modes', () => {
const { nodes, top, walls } = topFacadeFixture(true)
const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes })
const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
const partitionChain = tierEntries(
levelData.constructionDimensionsByReference[reference].get(top.id) ?? [],
'partitions',
)
const rendered = renderPlannedConstructionDimensions(partitionChain, 'metric')
const dimensionString = rendered[0]
return dimensionString?.kind === 'dimension-string' ? dimensionString.segments : []
}
expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([
'3.92m',
'0.26m',
'6.02m',
])
expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m'])
expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([
'4.04m',
'6.16m',
])
})
test('renders a face-of-stud partition chain with one shared witness', () => {
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
const partitionChain = tierEntries(topFacadePlan('structural-face'), 'partitions')
const rendered = renderPlannedConstructionDimensions(
partitionChain,
'metric',
undefined,
'editor',
standard,
)
expect(rendered).toHaveLength(1)
const dimensionString = rendered[0]
expect(dimensionString?.kind).toBe('dimension-string')
if (dimensionString?.kind !== 'dimension-string') return
expect(dimensionString.segments).toHaveLength(2)
expect(dimensionString.segments[0]?.end[0]).toBeCloseTo(3.94)
expect(dimensionString.segments[1]?.start[0]).toBeCloseTo(3.94)
})
test('uses only one stud face when the facade is split at the intersecting wall', () => {
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
const partitionChain = tierEntries(topFacadePlan('structural-face', true), 'partitions')
const rendered = renderPlannedConstructionDimensions(
partitionChain,
'metric',
undefined,
'editor',
standard,
)
const dimensionString = rendered[0]
expect(rendered).toHaveLength(1)
expect(dimensionString?.kind).toBe('dimension-string')
if (dimensionString?.kind !== 'dimension-string') return
expect(dimensionString.segments).toHaveLength(2)
expect(dimensionString.segments.map((segment) => segment.text)).not.toContain('0.12m')
})
test('uses the same left or top stud face from opposing sides of the plan', () => {
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
const verticalFixture = topFacadeFixture(false, true)
const verticalPlan = buildLevelWallConstructionDimensionPlan(
verticalFixture.walls,
verticalFixture.nodes,
standard,
)
const verticalReference = (wallId: string) =>
tierEntries(verticalPlan.get(wallId) ?? [], 'partitions')[0]?.end
expect(verticalReference('wall_top')?.[0]).toBeCloseTo(3.94)
expect(verticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94)
const reversedVerticalWalls = verticalFixture.walls.map((candidate) =>
candidate.id === 'wall_partition'
? { ...candidate, start: candidate.end, end: candidate.start }
: candidate,
)
const reversedVerticalNodes = Object.fromEntries(
reversedVerticalWalls.map((candidate) => [candidate.id, candidate]),
) as Record<string, AnyNode>
const reversedVerticalPlan = buildLevelWallConstructionDimensionPlan(
reversedVerticalWalls,
reversedVerticalNodes,
standard,
)
const reversedVerticalReference = (wallId: string) =>
tierEntries(reversedVerticalPlan.get(wallId) ?? [], 'partitions')[0]?.end
expect(reversedVerticalReference('wall_top')?.[0]).toBeCloseTo(3.94)
expect(reversedVerticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94)
const top = wall({
id: 'wall_horizontal_top',
start: [0, 0],
end: [10, 0],
frontSide: 'exterior',
backSide: 'interior',
})
const right = wall({
id: 'wall_horizontal_right',
start: [10, 0],
end: [10, -6],
frontSide: 'exterior',
backSide: 'interior',
})
const bottom = wall({
id: 'wall_horizontal_bottom',
start: [10, -6],
end: [0, -6],
frontSide: 'exterior',
backSide: 'interior',
})
const left = wall({
id: 'wall_horizontal_left',
start: [0, -6],
end: [0, 0],
frontSide: 'exterior',
backSide: 'interior',
})
const partition = wall({
id: 'wall_horizontal_partition',
start: [0, -3],
end: [10, -3],
assemblyLayers: [
{
id: 'horizontal-stud-core',
role: 'structure',
side: 'core',
thickness: 0.12,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
],
})
const horizontalWalls = [top, right, bottom, left, partition]
const horizontalNodes = Object.fromEntries(
horizontalWalls.map((candidate) => [candidate.id, candidate]),
) as Record<string, AnyNode>
const horizontalPlan = buildLevelWallConstructionDimensionPlan(
horizontalWalls,
horizontalNodes,
standard,
)
const horizontalReference = (wallId: string) =>
tierEntries(horizontalPlan.get(wallId) ?? [], 'partitions')[0]?.end
expect(horizontalReference('wall_horizontal_left')?.[1]).toBeCloseTo(-2.94)
expect(horizontalReference('wall_horizontal_right')?.[1]).toBeCloseTo(-2.94)
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
import { wallDefinition } from './definition'
describe('wallDefinition floor-plan extension', () => {
test('owns curve eligibility for hosted openings', () => {
const wall = wallDefinition.schema.parse({
id: 'wall_test',
children: ['door_test'],
start: [0, 0],
end: [4, 0],
})
const canCurve = getFloorplanNodeExtension(wallDefinition)?.actionMenu?.canCurve
const nodes = {
[wall.id]: wall,
door_test: {
object: 'node',
id: 'door_test',
type: 'door',
parentId: wall.id,
visible: true,
metadata: {},
} as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(canCurve?.({ node: wall, nodes })).toBe(false)
expect(canCurve?.({ node: { ...wall, children: [] }, nodes })).toBe(true)
})
})
+18 -2
View File
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import type { AnyNodeId, NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move'
@@ -32,10 +33,24 @@ import { wallSlots } from './slots'
export const wallDefinition: NodeDefinition<typeof WallNode> = {
kind: 'wall',
snapProfile: 'structural',
schemaVersion: 5,
schemaVersion: 6,
schema: WallNode,
category: 'structure',
surfaceRole: 'wall',
extensions: {
'pascal:editor/floorplan': {
actionMenu: {
canCurve: ({ node, nodes }) =>
!node.children.some((childId) => {
const child = nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type !== 'item') return false
return child.asset?.attachTo === 'wall' || child.asset?.attachTo === 'wall-side'
}),
},
} satisfies FloorplanNodeExtension<WallNode>,
},
defaults: () => ({
object: 'node',
@@ -43,6 +58,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start: [0, 0],
end: [3, 0],
frontSide: 'unknown',
@@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, ItemNode, WallNode } from '@pascal-app/core'
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
describe('wallFloorplanSiblingOverrides', () => {
test('projects live wall and opening positions without changing unrelated nodes', () => {
const wall = WallNode.parse({
id: 'wall_main',
parentId: 'level_main',
start: [0, 0],
end: [10, 0],
})
const door = DoorNode.parse({
id: 'door_main',
parentId: wall.id,
position: [2, 1, 0],
})
const item = ItemNode.parse({
id: 'item_main',
parentId: 'level_main',
position: [0, 0, 0],
asset: {
id: 'asset_item',
category: 'test',
name: 'Test item',
thumbnail: '/test.png',
src: '/test.glb',
},
})
const nodes = { [wall.id]: wall, [door.id]: door, [item.id]: item } as Record<string, AnyNode>
const result = wallFloorplanSiblingOverrides({
nodeId: wall.id,
nodes,
liveTransforms: new Map([[door.id, { position: [6, 1, 0], rotation: 0 }]]),
liveOverrides: new Map([
[wall.id, { end: [12, 0] }],
[door.id, { position: [5, 1, 0] }],
[item.id, { position: [3, 0, 0] }],
]),
})
expect(result).not.toBe(nodes)
expect(result[wall.id]).toMatchObject({ end: [12, 0] })
expect(result[door.id]).toMatchObject({ position: [6, 1, 0] })
expect(result[item.id]).toBe(item)
})
})
+24 -12
View File
@@ -1,34 +1,46 @@
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import type { AnyNode, AnyNodeId, LiveTransform } from '@pascal-app/core'
/**
* Project per-frame wall drag overrides (`{ start, end, curveOffset }`)
* from `useLiveNodeOverrides` into a fresh `nodes` snapshot. The 2D drag
* Project per-frame wall and opening drag overrides into a fresh `nodes`
* snapshot. Wall overrides keep shared miters current; door and window
* overrides keep associative construction dimensions current while an
* opening moves or changes host. The 2D drag
* handlers publish overrides for the moved wall plus its linked
* neighbours; the floor-plan layer hands the merged snapshot to
* `buildContext` so each wall's `ctx.siblings` (which feeds the
* miter calculation) reflects the live cursor positions instead of
* the last committed scene state.
*
* Only wall entries are touched; every other node is shared by
* reference. The allocation cost is one shallow object per overridden
* wall — the override map is small, so this is cheap. When the
* Other node types are shared by reference. The allocation cost is one
* shallow object per relevant override — the override map is small, so
* this is cheap. When the
* override map is empty (no live drag) the input is returned
* unchanged.
*/
export function wallFloorplanSiblingOverrides(args: {
nodeId: AnyNodeId
nodes: Record<AnyNodeId, AnyNode>
liveTransforms?: Map<string, LiveTransform>
liveOverrides: Map<string, Record<string, unknown>>
}): Record<AnyNodeId, AnyNode> {
const { nodes, liveOverrides } = args
if (liveOverrides.size === 0) return nodes
const { nodes, liveOverrides, liveTransforms } = args
if (liveOverrides.size === 0 && !liveTransforms?.size) return nodes
let out: Record<AnyNodeId, AnyNode> | null = null
for (const [id, override] of liveOverrides) {
const ids = new Set([...liveOverrides.keys(), ...(liveTransforms?.keys() ?? [])])
for (const id of ids) {
const existing = nodes[id as AnyNodeId]
if (existing?.type !== 'wall') continue
if (Object.keys(override).length === 0) continue
if (existing?.type !== 'wall' && existing?.type !== 'door' && existing?.type !== 'window') {
continue
}
const override = liveOverrides.get(id)
const liveTransform = liveTransforms?.get(id)
const livePosition =
liveTransform && (existing.type === 'door' || existing.type === 'window')
? { position: liveTransform.position }
: undefined
if ((!override || Object.keys(override).length === 0) && !livePosition) continue
if (!out) out = { ...nodes }
out[id as AnyNodeId] = { ...existing, ...override } as AnyNode
out[id as AnyNodeId] = { ...existing, ...override, ...livePosition } as AnyNode
}
return out ?? nodes
}
+284
View File
@@ -0,0 +1,284 @@
import { describe, expect, test } from 'bun:test'
import {
type FloorplanGeometry,
type FloorplanPalette,
type GeometryContext,
WallNode,
} from '@pascal-app/core'
import { createFloorplanContextExtensions, readFloorplanGeometryMetadata } from '@pascal-app/editor'
import { buildWallFloorplan } from './floorplan'
const palette: FloorplanPalette = {
selectedStroke: '#334155',
selectedFill: '#ffffff',
selectedHatch: '#334155',
wallHoverStroke: '#334155',
endpointHandleFill: '#ffffff',
endpointHandleStroke: '#334155',
endpointHandleHoverStroke: '#334155',
endpointHandleActiveFill: '#334155',
endpointHandleActiveStroke: '#334155',
curveHandleFill: '#ffffff',
curveHandleStroke: '#334155',
curveHandleHoverStroke: '#334155',
measurementStroke: '#334155',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#111827',
}
function context(
purpose: 'edit' | 'document',
selected = false,
metricNotation: 'meters' | 'millimeters' = 'meters',
wallDimensionReference: 'finished-faces' | 'centerline' | 'stud-faces' = 'finished-faces',
): GeometryContext {
return {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
viewState: {
selected,
unit: 'metric',
highlighted: false,
hovered: false,
moving: false,
palette,
},
extensions: createFloorplanContextExtensions({
metricNotation,
purpose,
wallDimensionReference,
}),
}
}
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
}
describe('buildWallFloorplan render purpose', () => {
const wall = WallNode.parse({
id: 'wall_main',
parentId: 'level_main',
start: [0, 0],
end: [4, 0],
thickness: 0.1,
frontSide: 'exterior',
backSide: 'interior',
})
test('keeps thin walls legible in edit mode but uses modeled thickness in documents', () => {
const edit = buildWallFloorplan(wall, context('edit'))
const document = buildWallFloorplan(wall, context('document'))
const editPolygon = edit && flatten(edit).find((entry) => entry.kind === 'polygon')
const documentPolygon = document && flatten(document).find((entry) => entry.kind === 'polygon')
expect(editPolygon?.kind).toBe('polygon')
expect(documentPolygon?.kind).toBe('polygon')
if (editPolygon?.kind !== 'polygon' || documentPolygon?.kind !== 'polygon') return
const editThickness =
Math.max(...editPolygon.points.map((point) => point[1])) -
Math.min(...editPolygon.points.map((point) => point[1]))
const documentThickness =
Math.max(...documentPolygon.points.map((point) => point[1])) -
Math.min(...documentPolygon.points.map((point) => point[1]))
expect(editThickness).toBeCloseTo(0.13)
expect(documentThickness).toBeCloseTo(0.1)
expect(readFloorplanGeometryMetadata(editPolygon).annotationObstacle).toBe('outline')
expect(readFloorplanGeometryMetadata(documentPolygon).annotationObstacle).toBe('outline')
})
test('uses document metric notation only for document output', () => {
const edit = buildWallFloorplan(wall, context('edit'))
const document = buildWallFloorplan(wall, context('document'))
const texts = (geometry: FloorplanGeometry | null) =>
geometry
? flatten(geometry).flatMap((entry) =>
entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [],
)
: []
expect(texts(edit)).toContain('4m')
expect(texts(document)).toContain('4000')
})
test('uses the live millimeter notation in edit mode', () => {
const edit = buildWallFloorplan(wall, context('edit', false, 'millimeters'))
const texts = edit
? flatten(edit).flatMap((entry) =>
entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [],
)
: []
expect(texts).toContain('4000')
})
test('keeps standalone wall witnesses on the stud face in every intersection mode', () => {
const assemblyWall = WallNode.parse({
...wall,
thickness: undefined,
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.1,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
{
id: 'interior-finish',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.03,
materialRef: 'library:cladding',
datumEligible: ['finish-face'],
},
],
})
const witnessY = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
const geometry = buildWallFloorplan(assemblyWall, context('edit', false, 'meters', reference))
const dimension = geometry
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
: undefined
return dimension?.kind === 'dimension-string' ? dimension.segments[0]?.start[1] : undefined
}
expect(witnessY('finished-faces')).toBeCloseTo(0.05)
expect(witnessY('centerline')).toBeCloseTo(0.05)
expect(witnessY('stud-faces')).toBeCloseTo(0.05)
})
test('uses total assembly thickness and emits construction graphics for modeled layers', () => {
const assemblyWall = WallNode.parse({
...wall,
thickness: undefined,
assemblyLayers: [
{
id: 'block-core',
role: 'concrete-block',
side: 'core',
thickness: 0.19,
materialRef: 'library:cmu',
datumEligible: ['structural-face'],
},
{
id: 'interior-furring',
role: 'furring',
side: 'interior',
thickness: 0.025,
materialRef: 'library:furring',
datumEligible: [],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-air-space',
role: 'air-space',
side: 'exterior',
thickness: 0.025,
materialRef: '',
datumEligible: [],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const document = buildWallFloorplan(assemblyWall, context('document'))
const entries = document ? flatten(document) : []
const polygons = entries.filter((entry) => entry.kind === 'polygon')
const mainPolygon = polygons[0]
expect(mainPolygon?.kind).toBe('polygon')
if (mainPolygon?.kind !== 'polygon') return
const documentThickness =
Math.max(...mainPolygon.points.map((point) => point[1])) -
Math.min(...mainPolygon.points.map((point) => point[1]))
expect(documentThickness).toBeCloseTo(0.346)
const layerPolygons = polygons.slice(1)
expect(layerPolygons).toHaveLength(5)
expect(
layerPolygons.every((entry) => entry.kind === 'polygon' && entry.pointerEvents === 'none'),
).toBe(true)
expect(
layerPolygons.map((entry) => (entry.kind === 'polygon' ? entry.fill : undefined)),
).toEqual(['#cbd5e1', '#fde68a', '#f8fafc', '#ffffff', '#fca5a5'])
const lines = entries.filter((entry) => entry.kind === 'line')
expect(lines.some((entry) => entry.kind === 'line' && entry.stroke === '#991b1b')).toBe(true)
expect(
lines.some(
(entry) =>
entry.kind === 'line' &&
entry.stroke === '#64748b' &&
entry.strokeDasharray === '0.035 0.025',
),
).toBe(true)
expect(
lines.some(
(entry) =>
entry.kind === 'line' &&
entry.stroke === '#92400e' &&
entry.strokeDasharray === '0.04 0.02',
),
).toBe(true)
expect(
lines.filter(
(entry) =>
entry.kind === 'line' && entry.stroke === '#111827' && entry.strokeWidth === 0.85,
),
).toHaveLength(2)
})
test('shows an orthogonal depth dimension for a curved wall without a radius leader', () => {
const curved = WallNode.parse({ ...wall, curveOffset: 1 })
const geometry = buildWallFloorplan(curved, context('edit'))
const entries = geometry ? flatten(geometry) : []
expect(entries.find((entry) => entry.kind === 'dimension-label')).toBeUndefined()
expect(entries.find((entry) => entry.kind === 'dimension-string')).toMatchObject({
kind: 'dimension-string',
segments: [{ text: '1m' }],
})
})
test('places selected move arrows on the curved wall midpoint', () => {
const curved = WallNode.parse({ ...wall, curveOffset: 1 })
const geometry = buildWallFloorplan(curved, context('edit', true))
const arrows = geometry ? flatten(geometry).filter((entry) => entry.kind === 'move-arrow') : []
expect(arrows).toHaveLength(2)
expect(arrows[0]).toMatchObject({ kind: 'move-arrow', angle: Math.PI / 2 })
expect(arrows[1]).toMatchObject({ kind: 'move-arrow', angle: -Math.PI / 2 })
if (arrows[0]?.kind !== 'move-arrow' || arrows[1]?.kind !== 'move-arrow') return
expect(arrows[0].point[0]).toBeCloseTo(2)
expect(arrows[0].point[1]).toBeCloseTo(-0.885)
expect(arrows[1].point[0]).toBeCloseTo(2)
expect(arrows[1].point[1]).toBeCloseTo(-1.115)
})
})
+540 -71
View File
@@ -4,13 +4,23 @@ import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getWallCurveLength,
getWallAssemblyThickness,
getWallMidpointHandlePoint,
getWallPlanFootprint,
isCurvedWall,
type WallAssemblyLayer,
type WallMiterData,
type WallNode,
} from '@pascal-app/core'
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
import { constructionDimensionStandard } from '../shared/construction-dimension-standards'
import {
buildCurvedWallConstructionDimensions,
buildLevelWallConstructionDimensionPlan,
buildWallConstructionDimensions,
renderPlannedConstructionDimensions,
type WallConstructionDimensionPlan,
} from './construction-dimensions'
// Same constants the legacy `getFloorplanWall` uses (editor/lib/floorplan/walls.ts).
// Slightly exaggerates thin walls so the 2D plan stays legible without
@@ -18,9 +28,13 @@ import {
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
const FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING = 0.06
const WALL_DIMENSION_REFERENCES = ['finished-faces', 'centerline', 'stud-faces'] as const
type WallDimensionReference = (typeof WALL_DIMENSION_REFERENCES)[number]
function floorplanWallThickness(wall: WallNode): number {
const baseThickness = wall.thickness ?? 0.1
const baseThickness = getWallAssemblyThickness(wall)
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
return Math.min(
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
@@ -32,17 +46,48 @@ function exaggerateWallThickness(wall: WallNode): WallNode {
return { ...wall, thickness: floorplanWallThickness(wall) }
}
function formatLengthMetric(meters: number): string {
return `${Number.parseFloat(meters.toFixed(2))}m`
function wallWithModeledAssemblyThickness(wall: WallNode): WallNode {
return { ...wall, thickness: getWallAssemblyThickness(wall) }
}
export type WallFloorplanLevelData = {
miters: WallMiterData
documentMiters: WallMiterData
constructionDimensionsByReference: Record<WallDimensionReference, WallConstructionDimensionPlan>
}
export function computeWallFloorplanLevelData({
siblings,
nodes,
}: {
siblings: ReadonlyArray<WallNode>
nodes: Record<string, AnyNode>
}): WallMiterData {
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
}): WallFloorplanLevelData {
const walls = siblings.map(exaggerateWallThickness)
return {
miters: calculateLevelMiters(walls),
documentMiters: calculateLevelMiters([...siblings]),
constructionDimensionsByReference: {
'finished-faces': buildLevelWallConstructionDimensionPlan(
siblings,
nodes,
constructionDimensionStandard({
datumPolicy: 'wall-face',
intersectionReferencePolicy: 'both-faces',
}),
),
centerline: buildLevelWallConstructionDimensionPlan(
siblings,
nodes,
constructionDimensionStandard({ datumPolicy: 'centerline' }),
),
'stud-faces': buildLevelWallConstructionDimensionPlan(
siblings,
nodes,
constructionDimensionStandard({ datumPolicy: 'structural-face' }),
),
},
}
}
/**
@@ -55,26 +100,29 @@ export function computeWallFloorplanLevelData({
* wall body easily.
* 4. Two endpoint handles (start + end) when selected — the registry
* layer hosts the 5-circle stack + hover transitions + 2D drag.
* 5. A small dimension label at the midpoint when selected.
* 5. Exterior facade strings plus interior wall spans and hosted-opening widths.
*
* `ctx.levelData` provides the shared level miter graph when the floor-plan
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
* direct builder callers.
*/
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
const self = exaggerateWallThickness(node)
const { metricNotation, purpose, wallDimensionReference } = readFloorplanContext(ctx)
const documentMode = purpose === 'document'
const wallForPurpose = (wall: WallNode) =>
documentMode ? wallWithModeledAssemblyThickness(wall) : exaggerateWallThickness(wall)
const self = wallForPurpose(node)
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
// a direct builder caller with no shared data — pays the O(N) exaggerate +
// level-wide miter calc per wall; the dispatcher path is O(1) here, which is
// what keeps a wall drag from being O(N²) across the level.
const levelData = ctx.levelData as WallFloorplanLevelData | undefined
const miters =
(ctx.levelData as WallMiterData | undefined) ??
(documentMode ? levelData?.documentMiters : levelData?.miters) ??
calculateLevelMiters([
self,
...ctx.siblings
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
.map(exaggerateWallThickness),
...ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall').map(wallForPurpose),
])
const polygon = getWallPlanFootprint(self, miters)
@@ -109,6 +157,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92,
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
// Once the wall is selected, the body keeps catching the pointer
// so the cursor stays neutral (no drag/pointer affordance from
// the slab below leaking through), but only the side-arrows and
@@ -118,6 +167,56 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
},
]
children.push(...buildWallAssemblyFloorplanGraphics(self))
const dimensionStroke =
isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155')
const dimensionStandard = constructionDimensionStandard({
datumPolicy: wallDimensionDatumPolicy(wallDimensionReference),
metricNotation,
})
const exteriorCornerDimensionStandard = constructionDimensionStandard({
datumPolicy: 'structural-face',
metricNotation,
})
if (isCurvedWall(node)) {
children.push(
...buildCurvedWallConstructionDimensions(self, {
unit: view?.unit ?? 'metric',
stroke: dimensionStroke,
profile: documentMode ? 'document' : 'editor',
standard: exteriorCornerDimensionStandard,
siblings: ctx.siblings.filter(
(sibling): sibling is AnyNode & WallNode => sibling.type === 'wall',
),
}),
)
} else {
const planned = levelData?.constructionDimensionsByReference[wallDimensionReference].get(
node.id,
)
if (planned) {
children.push(
...renderPlannedConstructionDimensions(
planned,
view?.unit ?? 'metric',
dimensionStroke,
documentMode ? 'document' : 'editor',
dimensionStandard,
),
)
} else if (!levelData) {
children.push(
...buildWallConstructionDimensions(self, ctx, {
unit: view?.unit ?? 'metric',
stroke: dimensionStroke,
profile: documentMode ? 'document' : 'editor',
standard: exteriorCornerDimensionStandard,
}),
)
}
}
// Selection hatch overlay — only when the wall is *the* selected item
// (not when it's just marquee-highlighted), matching the legacy.
if (isSelected && palette) {
@@ -172,19 +271,18 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
const dz = node.end[1] - node.start[1]
const wallLength = Math.hypot(dx, dz)
if (wallLength > 1e-6) {
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
const midpoint = getWallMidpointHandlePoint(node)
const nx = -dz / wallLength
const nz = dx / wallLength
const offset = floorplanWallThickness(node) / 2 + 0.05
children.push({
kind: 'move-arrow',
point: [midX + nx * offset, midZ + nz * offset],
point: [midpoint.x + nx * offset, midpoint.y + nz * offset],
angle: Math.atan2(nz, nx),
})
children.push({
kind: 'move-arrow',
point: [midX - nx * offset, midZ - nz * offset],
point: [midpoint.x - nx * offset, midpoint.y - nz * offset],
angle: Math.atan2(-nz, -nx),
})
}
@@ -206,67 +304,438 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
payload: { wallId: node.id },
})
}
// Length measurement. Curved walls use the simple rounded label
// (the chord-vs-arc thing is hard to express with a dimension line);
// straight walls get the full architect's overlay with extension
// marks + ticks, offset to the side facing away from the level
// centroid (matches the legacy `getWallMeasurementOverlay`).
const length = getWallCurveLength(node)
if (length >= 0.1) {
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const midX = (node.start[0] + node.end[0]) / 2
const midZ = (node.start[1] + node.end[1]) / 2
if (isCurvedWall(node)) {
children.push({
kind: 'dimension-label',
cx: midX,
cy: midZ,
text: formatLengthMetric(length),
angle: Math.atan2(dz, dx),
})
} else {
// Outward unit normal = perpendicular to (dx, dz), choose the
// side facing away from other walls' centroid so the dimension
// line sits outside the building.
const nx = -dz / length
const nz = dx / length
const wallSiblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
const centroid = wallCentroid([node, ...wallSiblings])
const cx = midX - centroid[0]
const cz = midZ - centroid[1]
const facingAway = cx * nx + cz * nz >= 0 ? 1 : -1
children.push({
kind: 'dimension',
start: [node.start[0], node.start[1]],
end: [node.end[0], node.end[1]],
offsetNormal: [nx * facingAway, nz * facingAway],
offsetDistance: 0.75,
extensionOvershoot: 0.12,
text: formatLengthMetric(length),
})
}
}
}
return { kind: 'group', children }
}
function wallCentroid(walls: WallNode[]): [number, number] {
// Mean of every wall endpoint — cheap approximation of "where the
// building lives" so we can offset the dimension line away from it.
let sumX = 0
let sumZ = 0
let count = 0
for (const wall of walls) {
sumX += wall.start[0] + wall.end[0]
sumZ += wall.start[1] + wall.end[1]
count += 2
function wallDimensionDatumPolicy(reference: WallDimensionReference) {
switch (reference) {
case 'centerline':
return 'centerline' as const
case 'stud-faces':
return 'structural-face' as const
case 'finished-faces':
return 'wall-face' as const
}
if (count === 0) return [0, 0]
return [sumX / count, sumZ / count]
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function buildWallAssemblyFloorplanGraphics(wall: WallNode): FloorplanGeometry[] {
if (isCurvedWall(wall)) return []
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const spans = getWallAssemblyLayerSpans(wall)
if (spans.length === 0) return []
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dy)
if (length <= 1e-6) return []
const tx = dx / length
const ty = dy / length
const nx = -ty
const ny = tx
const startX = wall.start[0]
const startY = wall.start[1]
const endX = wall.end[0]
const endY = wall.end[1]
const graphics: FloorplanGeometry[] = []
for (const span of spans) {
const style = wallAssemblyLayerGraphicStyle(span.layer)
const points = wallLayerPolygon(startX, startY, endX, endY, nx, ny, span)
graphics.push({
kind: 'polygon',
points,
fill: style.fill,
stroke: style.stroke,
strokeWidth: style.strokeWidth,
fillOpacity: style.fillOpacity,
opacity: style.opacity,
pointerEvents: 'none',
})
graphics.push(
...buildWallAssemblyLayerHatchLines({
span,
style,
startX,
startY,
endX,
endY,
tx,
ty,
nx,
ny,
length,
}),
)
}
graphics.push(...buildWallAssemblyFaceLines(startX, startY, endX, endY, nx, ny, spans))
return graphics
}
function getWallAssemblyLayerSpans(wall: WallNode): 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
}
type WallAssemblyLayerGraphicStyle = {
fill: string
stroke: string
strokeWidth: number
fillOpacity: number
opacity?: number
hatch?: 'diagonal' | 'cross' | 'brick' | 'air' | 'furring'
hatchStroke: string
hatchDasharray?: string
}
function wallAssemblyLayerGraphicStyle(layer: WallAssemblyLayer): WallAssemblyLayerGraphicStyle {
switch (layer.role) {
case 'structure':
return {
fill: '#475569',
stroke: '#111827',
strokeWidth: 0.006,
fillOpacity: 0.34,
hatch: 'diagonal',
hatchStroke: '#0f172a',
}
case 'concrete-block':
case 'structural-masonry':
return {
fill: '#cbd5e1',
stroke: '#334155',
strokeWidth: 0.006,
fillOpacity: 0.82,
hatch: 'cross',
hatchStroke: '#475569',
}
case 'solid-concrete':
return {
fill: '#94a3b8',
stroke: '#334155',
strokeWidth: 0.006,
fillOpacity: 0.78,
hatch: 'diagonal',
hatchStroke: '#64748b',
}
case 'masonry-veneer':
return {
fill: '#fca5a5',
stroke: '#7f1d1d',
strokeWidth: 0.004,
fillOpacity: 0.45,
hatch: 'brick',
hatchStroke: '#991b1b',
}
case 'air-space':
return {
fill: '#ffffff',
stroke: '#94a3b8',
strokeWidth: 0.004,
fillOpacity: 0.15,
hatch: 'air',
hatchStroke: '#64748b',
hatchDasharray: '0.035 0.025',
}
case 'furring':
return {
fill: '#fde68a',
stroke: '#92400e',
strokeWidth: 0.004,
fillOpacity: 0.42,
hatch: 'furring',
hatchStroke: '#92400e',
hatchDasharray: '0.04 0.02',
}
case 'interior-finish':
case 'exterior-finish':
case 'exterior-sheathing':
return {
fill: '#f8fafc',
stroke: '#94a3b8',
strokeWidth: 0.003,
fillOpacity: 0.72,
hatch: layer.role === 'exterior-sheathing' ? 'diagonal' : undefined,
hatchStroke: '#94a3b8',
}
}
}
function wallLayerPolygon(
startX: number,
startY: number,
endX: number,
endY: number,
nx: number,
ny: number,
span: WallAssemblyLayerSpan,
): FloorplanPoint[] {
return [
[startX + nx * span.interiorOffset, startY + ny * span.interiorOffset],
[endX + nx * span.interiorOffset, endY + ny * span.interiorOffset],
[endX + nx * span.exteriorOffset, endY + ny * span.exteriorOffset],
[startX + nx * span.exteriorOffset, startY + ny * span.exteriorOffset],
]
}
function buildWallAssemblyLayerHatchLines({
span,
style,
startX,
startY,
tx,
ty,
nx,
ny,
length,
}: {
span: WallAssemblyLayerSpan
style: WallAssemblyLayerGraphicStyle
startX: number
startY: number
endX: number
endY: number
tx: number
ty: number
nx: number
ny: number
length: number
}): FloorplanGeometry[] {
if (!style.hatch) return []
const layerWidth = span.exteriorOffset - span.interiorOffset
if (layerWidth <= 1e-6) return []
const interval = Math.max(FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING, layerWidth * 1.8)
const insetAlong = Math.min(0.035, length * 0.08)
const lines: FloorplanGeometry[] = []
if (style.hatch === 'air') {
const midOffset = (span.interiorOffset + span.exteriorOffset) / 2
lines.push(
wallAssemblyLine(
startX + tx * insetAlong,
startY + ty * insetAlong,
startX + tx * (length - insetAlong),
startY + ty * (length - insetAlong),
nx,
ny,
midOffset,
style.hatchStroke,
style.hatchDasharray,
),
)
return lines
}
if (style.hatch === 'brick') {
for (let along = interval; along < length; along += interval) {
lines.push(
wallCrossLine(startX, startY, tx, ty, nx, ny, along, span, style.hatchStroke, undefined),
)
}
const thirds = [
span.interiorOffset + layerWidth / 3,
span.interiorOffset + (layerWidth * 2) / 3,
]
for (const offset of thirds) {
lines.push(
wallAssemblyLine(
startX + tx * insetAlong,
startY + ty * insetAlong,
startX + tx * (length - insetAlong),
startY + ty * (length - insetAlong),
nx,
ny,
offset,
style.hatchStroke,
style.hatchDasharray,
),
)
}
return lines
}
if (style.hatch === 'furring') {
for (let along = interval; along < length; along += interval) {
lines.push(
wallCrossLine(
startX,
startY,
tx,
ty,
nx,
ny,
along,
span,
style.hatchStroke,
style.hatchDasharray,
),
)
}
return lines
}
const emitDiagonal = (flip: boolean) => {
for (let along = interval / 2; along < length; along += interval) {
const centerOffset = (span.interiorOffset + span.exteriorOffset) / 2
const halfAlong = Math.min(interval * 0.35, length * 0.08)
const halfAcross = layerWidth * 0.42
const sign = flip ? -1 : 1
lines.push({
kind: 'line',
x1: startX + tx * Math.max(0, along - halfAlong) + nx * (centerOffset - sign * halfAcross),
y1: startY + ty * Math.max(0, along - halfAlong) + ny * (centerOffset - sign * halfAcross),
x2:
startX +
tx * Math.min(length, along + halfAlong) +
nx * (centerOffset + sign * halfAcross),
y2:
startY +
ty * Math.min(length, along + halfAlong) +
ny * (centerOffset + sign * halfAcross),
stroke: style.hatchStroke,
strokeWidth: 0.55,
strokeDasharray: style.hatchDasharray,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
})
}
}
emitDiagonal(false)
if (style.hatch === 'cross') emitDiagonal(true)
return lines
}
function wallAssemblyLine(
x1: number,
y1: number,
x2: number,
y2: number,
nx: number,
ny: number,
offset: number,
stroke: string,
strokeDasharray: string | undefined,
): FloorplanGeometry {
return {
kind: 'line',
x1: x1 + nx * offset,
y1: y1 + ny * offset,
x2: x2 + nx * offset,
y2: y2 + ny * offset,
stroke,
strokeWidth: 0.5,
strokeDasharray,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
}
}
function wallCrossLine(
startX: number,
startY: number,
tx: number,
ty: number,
nx: number,
ny: number,
along: number,
span: WallAssemblyLayerSpan,
stroke: string,
strokeDasharray: string | undefined,
): FloorplanGeometry {
return {
kind: 'line',
x1: startX + tx * along + nx * span.interiorOffset,
y1: startY + ty * along + ny * span.interiorOffset,
x2: startX + tx * along + nx * span.exteriorOffset,
y2: startY + ty * along + ny * span.exteriorOffset,
stroke,
strokeWidth: 0.5,
strokeDasharray,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
}
}
function buildWallAssemblyFaceLines(
startX: number,
startY: number,
endX: number,
endY: number,
nx: number,
ny: number,
spans: WallAssemblyLayerSpan[],
): FloorplanGeometry[] {
const offsets = new Set<number>()
for (const span of spans) {
offsets.add(span.interiorOffset)
offsets.add(span.exteriorOffset)
}
const sortedOffsets = [...offsets].sort((a, b) => a - b)
const minOffset = sortedOffsets[0]
const maxOffset = sortedOffsets.at(-1)
return sortedOffsets.map((offset) => ({
kind: 'line',
x1: startX + nx * offset,
y1: startY + ny * offset,
x2: endX + nx * offset,
y2: endY + ny * offset,
stroke: offset === minOffset || offset === maxOffset ? '#111827' : '#64748b',
strokeWidth: offset === minOffset || offset === maxOffset ? 0.85 : 0.45,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
}))
}
/**
+31 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { WallNode } from '@pascal-app/core'
import { matchWallMeasurementFeature } from './measurement'
import {
matchWallMeasurementFeature,
resolveWallMeasurementFeature,
wallMeasurementFeatures,
} from './measurement'
describe('matchWallMeasurementFeature', () => {
test('keeps an exact plan corner bound to the wall endpoint instead of its face', () => {
@@ -17,4 +21,30 @@ describe('matchWallMeasurementFeature', () => {
expect(matchWallMeasurementFeature(wall, [4, 1, 0.1], 0.2)?.featureId).toBe('wall:face:left')
})
test('publishes a stable center feature only for curved walls', () => {
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
expect(
wallMeasurementFeatures(curved).find((feature) => feature.id === 'wall:curve:center'),
).toMatchObject({
snapKind: 'center',
geometry: { kind: 'point', point: [2, 0, 1.5] },
})
expect(
wallMeasurementFeatures(straight).find((feature) => feature.id === 'wall:curve:center'),
).toBeUndefined()
})
test('resolves the curved-wall center from the current wall shape', () => {
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 0.5 })
expect(
resolveWallMeasurementFeature(wall, {
nodeId: wall.id,
featureId: 'wall:curve:center',
}),
).toMatchObject({ geometry: { kind: 'point', point: [2, 0, 3.75] } })
})
})
+13
View File
@@ -1,4 +1,5 @@
import {
getWallArcData,
getWallCurveFrameAt,
getWallThickness,
type MeasurementFeature,
@@ -14,6 +15,7 @@ const point = (x: number, y: number, z: number) => [x, y, z] as [number, number,
export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes)
const arc = getWallArcData(wall)
const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y))
const midpoint = getWallCurveFrameAt(wall, 0.5).point
const halfThickness = getWallThickness(wall) / 2
@@ -63,6 +65,17 @@ export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
priority: 90,
geometry: { kind: 'point', point: point(midpoint.x, 0, midpoint.y) },
},
...(arc
? [
{
id: 'wall:curve:center',
label: 'Wall arc center',
snapKind: 'center' as const,
priority: 90,
geometry: { kind: 'point' as const, point: point(arc.center.x, 0, arc.center.y) },
},
]
: []),
{
id: 'wall:face:left',
label: 'Wall face',
+165 -1
View File
@@ -15,6 +15,9 @@ import {
WALL_CROWN_DEFAULT,
WALL_FACE_BAND_DEFAULT,
WALL_SKIRTING_DEFAULT,
type WallAssemblyLayer,
type WallAssemblyLayerRole,
type WallDimensionDatum,
type WallNode,
type WallTrimProfile,
} from '@pascal-app/core'
@@ -34,7 +37,7 @@ import {
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Spline } from 'lucide-react'
import { Plus, Spline, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useRef } from 'react'
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
@@ -298,6 +301,8 @@ export default function WallPanel() {
)}
</PanelSection>
<WallAssemblySection node={node} onUpdate={handleUpdate} unit={unit} unitLabel={unitLabel} />
<WallFaceBandSection
node={node}
onUpdate={handleUpdate}
@@ -352,6 +357,165 @@ export default function WallPanel() {
)
}
const WALL_ASSEMBLY_ROLE_OPTIONS: Array<{ label: string; value: WallAssemblyLayerRole }> = [
{ label: 'Structure', value: 'structure' },
{ label: 'Interior finish', value: 'interior-finish' },
{ label: 'Exterior sheathing', value: 'exterior-sheathing' },
{ label: 'Exterior finish', value: 'exterior-finish' },
{ label: 'Masonry veneer', value: 'masonry-veneer' },
{ label: 'Air space', value: 'air-space' },
{ label: 'Concrete block', value: 'concrete-block' },
{ label: 'Structural masonry', value: 'structural-masonry' },
{ label: 'Solid concrete', value: 'solid-concrete' },
{ label: 'Furring', value: 'furring' },
]
const WALL_DATUM_OPTIONS: Array<{ label: string; value: WallDimensionDatum }> = [
{ label: 'Structural', value: 'structural-face' },
{ label: 'Finish', value: 'finish-face' },
{ label: 'Veneer', value: 'veneer-face' },
]
function WallAssemblySection({
node,
onUpdate,
unit,
unitLabel,
}: {
node: WallNode
onUpdate: (updates: Partial<WallNode>) => void
unit: 'metric' | 'imperial'
unitLabel: string
}) {
const layers = node.assemblyLayers ?? []
const updateLayer = (index: number, patch: Partial<WallAssemblyLayer>) =>
onUpdate({
assemblyLayers: layers.map((layer, layerIndex) =>
layerIndex === index ? { ...layer, ...patch } : layer,
),
})
const addLayer = () => {
const number = layers.length + 1
onUpdate({
assemblyLayers: [
...layers,
{
id: `layer-${number}`,
role: layers.length === 0 ? 'structure' : 'exterior-finish',
side: layers.length === 0 ? 'core' : 'exterior',
thickness: 0.1,
materialRef: '',
datumEligible: layers.length === 0 ? ['structural-face'] : ['finish-face'],
},
],
})
}
return (
<PanelSection title="Wall assembly">
<div className="space-y-2 px-1 pb-1">
{layers.map((layer, index) => (
<div className="space-y-2 rounded-lg border border-border/50 p-2" key={layer.id}>
<div className="flex items-center gap-2">
<input
className="h-7 min-w-0 flex-1 rounded-md border border-border/50 bg-[#2C2C2E] px-2 text-xs outline-none"
maxLength={80}
onBlur={(event) => {
const id = event.currentTarget.value.trim()
if (id && id !== layer.id) updateLayer(index, { id })
}}
defaultValue={layer.id}
/>
<button
aria-label={`Remove ${layer.id}`}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() =>
onUpdate({
assemblyLayers: layers.filter((_, layerIndex) => layerIndex !== index),
})
}
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<select
className="h-7 rounded-md border border-border/50 bg-[#2C2C2E] px-1.5 text-xs outline-none"
onChange={(event) =>
updateLayer(index, { role: event.currentTarget.value as WallAssemblyLayerRole })
}
value={layer.role}
>
{WALL_ASSEMBLY_ROLE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
className="h-7 rounded-md border border-border/50 bg-[#2C2C2E] px-1.5 text-xs outline-none"
onChange={(event) =>
updateLayer(index, {
side: event.currentTarget.value as WallAssemblyLayer['side'],
})
}
value={layer.side}
>
<option value="core">Core</option>
<option value="interior">Interior</option>
<option value="exterior">Exterior</option>
</select>
</div>
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Thickness</span>
<input
className="h-7 min-w-0 flex-1 rounded-md border border-border/50 bg-[#2C2C2E] px-2 font-mono outline-none"
min={0.001}
onBlur={(event) => {
const parsed = Number.parseFloat(event.currentTarget.value)
if (Number.isFinite(parsed) && parsed > 0) {
updateLayer(index, { thickness: linearControlValueToMeters(parsed, unit) })
}
}}
step={unit === 'imperial' ? 0.01 : 0.001}
type="number"
defaultValue={Math.round(metersToLinearUnit(layer.thickness, unit) * 1000) / 1000}
/>
<span className="text-muted-foreground">{unitLabel}</span>
</label>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{WALL_DATUM_OPTIONS.map((option) => (
<label className="flex items-center gap-1 text-[10px]" key={option.value}>
<input
checked={layer.datumEligible.includes(option.value)}
onChange={(event) =>
updateLayer(index, {
datumEligible: event.currentTarget.checked
? [...layer.datumEligible, option.value]
: layer.datumEligible.filter((datum) => datum !== option.value),
})
}
type="checkbox"
/>
{option.label}
</label>
))}
</div>
</div>
))}
<button
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/50 text-xs hover:bg-muted"
onClick={addLayer}
type="button"
>
<Plus className="size-3.5" /> Add assembly layer
</button>
</div>
</PanelSection>
)
}
function WallFaceBandSection({
node,
onUpdate,
+17 -2
View File
@@ -8,5 +8,20 @@
* imports a single canonical type.
*/
export type { WallNode as WallNodeType } from '@pascal-app/core'
export { WallNode } from '@pascal-app/core'
export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallNode as WallNodeType,
} from '@pascal-app/core'
export {
getWallAssemblyDatumReferenceId,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode,
} from '@pascal-app/core'
+1
View File
@@ -234,6 +234,7 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode {
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start,
end,
thickness: DRAFT_WALL_THICKNESS,
+12 -1
View File
@@ -6,6 +6,11 @@ import type {
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import {
buildWindowFloorplanSchedule,
computeWindowFloorplanLevelData,
} 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'
@@ -161,9 +166,14 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
kind: 'window',
snapProfile: 'item',
facingIndicator: true,
schemaVersion: 1,
schemaVersion: 2,
schema: WindowNode,
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
schedule: buildWindowFloorplanSchedule,
} satisfies FloorplanNodeExtension<WindowNodeType>,
},
// Same schema-driven defaults trick as door: parse a stub, strip
// id/type. Window also has many fields with zod `.default()` set.
@@ -211,6 +221,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
// + thickness — same shape as door.
floorplan: buildWindowFloorplan,
computeFloorplanLevelData: computeWindowFloorplanLevelData,
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
+14
View File
@@ -5,6 +5,11 @@ import type {
WallNode,
WindowNode,
} from '@pascal-app/core'
import { floorplanGeometryMetadata } from '@pascal-app/editor'
import {
buildOpeningMarkAnnotation,
type OpeningFloorplanLevelData,
} from '../shared/opening-documentation'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/**
@@ -102,6 +107,7 @@ export function buildWindowFloorplan(
strokeWidth: showSelectedChrome ? 1.9 : 1.25,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
// Inset glass-pane outline.
{
@@ -169,5 +175,13 @@ export function buildWindowFloorplan(
}
}
const markAnnotation = buildOpeningMarkAnnotation(
node,
wall,
ctx.levelData as OpeningFloorplanLevelData | undefined,
{ stroke: showSelectedChrome ? '#f97316' : '#334155' },
)
if (markAnnotation) children.push(markAnnotation)
return { kind: 'group', children }
}
+18
View File
@@ -22,6 +22,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { OpeningDocumentationFields } from '../shared/opening-documentation-fields'
function isSameWindowValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
@@ -220,6 +221,8 @@ export default function WindowPanel() {
parentId: node.parentId,
width: node.width,
height: node.height,
roughOpeningWidth: node.roughOpeningWidth,
roughOpeningHeight: node.roughOpeningHeight,
windowType: node.windowType,
operationState: node.operationState,
awningDirection: node.awningDirection,
@@ -413,6 +416,21 @@ export default function WindowPanel() {
/>
</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>
{showWindowTypeSection && (
<PanelSection title="Window Type">
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
+5 -1
View File
@@ -1 +1,5 @@
export { WindowNode } from '@pascal-app/core'
export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
} from '@pascal-app/core'
+8 -1
View File
@@ -3,6 +3,7 @@ import {
resolveAutoZonePolygon,
ZoneNode as ZoneNodeSchema,
} from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
import { buildZoneFloorplan } from './floorplan'
import {
@@ -14,6 +15,7 @@ import {
import { zoneFloorplanMoveTarget } from './floorplan-move'
import { zoneParametrics } from './parametrics'
import { zoneQuickMeasurement } from './quick-measurement'
import { buildRoomFloorplanSchedule } from './room-documentation'
import { ZoneNode } from './schema'
/**
@@ -25,9 +27,14 @@ import { ZoneNode } from './schema'
export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
kind: 'zone',
snapProfile: 'structural',
schemaVersion: 1,
schemaVersion: 2,
schema: ZoneNode,
category: 'site',
extensions: {
'pascal:editor/floorplan': {
schedule: buildRoomFloorplanSchedule,
} satisfies FloorplanNodeExtension<ZoneNode>,
},
defaults: () => {
const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' })
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test'
import { type FloorplanGeometry, type GeometryContext, ZoneNode } from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
import { buildZoneFloorplan } from './floorplan'
const context = {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
} satisfies GeometryContext
function textChildren(geometry: FloorplanGeometry | null) {
if (geometry?.kind !== 'group') return []
return geometry.children.filter((child) => child.kind === 'text')
}
describe('buildZoneFloorplan room documentation', () => {
test('keeps a generic zone label unchanged', () => {
const zone = ZoneNode.parse({
id: 'zone_landscape',
name: 'Courtyard',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
expect(textChildren(buildZoneFloorplan(zone, context))).toEqual([
expect.objectContaining({ kind: 'text', text: 'Courtyard', upright: true }),
])
})
test('centers room name, number, finish, and height information as room annotations', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
spaceRole: 'room',
roomNumber: '101',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 2.7,
occupancy: 'Business',
})
const labels = textChildren(buildZoneFloorplan(room, context))
expect(labels.map((label) => ('text' in label ? label.text : ''))).toEqual([
'Office',
'101',
'FL: Timber · WL: Paint · CL: ACT',
'CH: 2.7m · Business',
])
expect(labels.every((label) => label.kind === 'text' && label.upright)).toBe(true)
expect(
labels.every((label) => readFloorplanGeometryMetadata(label).annotationRole === 'room-label'),
).toBe(true)
})
})
+79 -3
View File
@@ -5,6 +5,12 @@ import {
resolveAutoZonePolygon,
type ZoneNode,
} from '@pascal-app/core'
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
import {
type ConstructionLengthProfile,
formatConstructionLength,
} from '../shared/construction-length'
import { buildRoomClearDimensions } from './room-clear-dimensions'
/**
* Stage C floor-plan builder for zone. Zones are colored polygons —
@@ -21,6 +27,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
if (!ring || ring.length < 3) return null
const view = ctx.viewState
const floorplanContext = readFloorplanContext(ctx)
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
@@ -28,7 +35,8 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint)
const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color
const fillOpacity = isSelected ? 0.28 : 0.16
const isRoom = node.spaceRole === 'room'
const fillOpacity = isRoom ? (isSelected ? 0.12 : 0.04) : isSelected ? 0.28 : 0.16
const children: FloorplanGeometry[] = [
{
@@ -91,9 +99,22 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
// it). Mirrors the legacy `FloorplanZoneLabel` so the look is
// consistent. Centered on the polygon's area-weighted centroid; the
// bbox-center fallback handles degenerate rings without throwing.
const [cx, cy] = polygonCentroid(ring)
const name = node.name?.trim()
if (name) {
const [cx, cy] = polygonCentroid(ring)
if (isRoom) {
children.push(
...buildRoomLabels(
node,
cx,
cy,
view?.unit ?? 'metric',
floorplanContext.purpose === 'document' ? 'document' : 'editor',
floorplanContext.metricNotation,
stroke,
),
)
children.push(...buildRoomClearDimensions(node, ctx))
} else if (name) {
children.push({
kind: 'text',
x: cx,
@@ -119,6 +140,61 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
}
const ZONE_LABEL_FONT_SIZE = 0.2
const ROOM_NAME_FONT_SIZE = 0.2
const ROOM_NUMBER_FONT_SIZE = 0.16
const ROOM_DETAIL_FONT_SIZE = 0.11
const ROOM_LABEL_LINE_SPACING = 0.18
function buildRoomLabels(
node: ZoneNode,
x: number,
y: number,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: 'meters' | 'millimeters',
color: string,
): FloorplanGeometry[] {
const lines: Array<{ text: string; fontSize: number; fontWeight: number }> = []
const name = node.name.trim()
if (name) lines.push({ text: name, fontSize: ROOM_NAME_FONT_SIZE, fontWeight: 700 })
if (node.roomNumber) {
lines.push({ text: node.roomNumber, fontSize: ROOM_NUMBER_FONT_SIZE, fontWeight: 600 })
}
const finishes = [
node.floorFinish ? `FL: ${node.floorFinish}` : '',
node.wallFinish ? `WL: ${node.wallFinish}` : '',
node.ceilingFinish ? `CL: ${node.ceilingFinish}` : '',
].filter(Boolean)
if (finishes.length > 0) {
lines.push({ text: finishes.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 })
}
const roomDetails = [
`CH: ${formatConstructionLength(node.ceilingHeight, unit, profile, { metricNotation })}`,
]
if (node.occupancy) roomDetails.push(node.occupancy)
lines.push({ text: roomDetails.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 })
const startY = y - ((lines.length - 1) * ROOM_LABEL_LINE_SPACING) / 2
return lines.map((line, index) => ({
kind: 'text',
x,
y: startY + index * ROOM_LABEL_LINE_SPACING,
text: line.text,
fontSize: line.fontSize,
fill: color,
stroke: '#ffffff',
strokeWidth: line.fontSize * 0.18,
paintOrder: 'stroke',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: line.fontWeight,
textAnchor: 'middle',
dominantBaseline: 'central',
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
}))
}
/**
* Area-weighted centroid of a simple polygon (Shoelace formula). Falls
+210 -42
View File
@@ -12,10 +12,12 @@ import {
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
MetricControl,
PanelSection,
ToggleControl,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
type Point2D = readonly [number, number]
@@ -151,6 +153,167 @@ function QuantityRow({
)
}
function RoomTextField({
label,
onCommit,
value,
}: {
label: string
onCommit: (value: string) => void
value: string
}) {
const [draft, setDraft] = useState(value)
const cancelRef = useRef(false)
useEffect(() => setDraft(value), [value])
const commit = () => {
if (cancelRef.current) {
cancelRef.current = false
setDraft(value)
return
}
const next = draft.trim()
if (next !== value) onCommit(next)
else setDraft(value)
}
return (
<label className="flex h-10 items-center gap-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<input
className="min-w-0 flex-1 bg-transparent text-right text-foreground outline-none selection:bg-primary/30"
onBlur={commit}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur()
if (event.key === 'Escape') {
cancelRef.current = true
event.currentTarget.blur()
}
}}
type="text"
value={draft}
/>
</label>
)
}
function RoomSelect({
label,
onChange,
options,
value,
}: {
label: string
onChange: (value: string) => void
options: ReadonlyArray<{ label: string; value: string }>
value: string
}) {
return (
<label className="flex h-10 items-center justify-between gap-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<select
className="min-w-0 rounded-md border border-border/50 bg-[#232325] px-2 py-1 text-foreground text-xs outline-none"
onChange={(event) => onChange(event.target.value)}
value={value}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)
}
function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) {
const updateNode = useScene((state) => state.updateNode)
const update = (patch: Partial<ZoneNode>) => updateNode(zone.id, patch)
const isRoom = zone.spaceRole === 'room'
return (
<PanelSection title="Room documentation">
<ToggleControl
checked={isRoom}
label="Architectural room"
onChange={(checked) => update({ spaceRole: checked ? 'room' : 'generic' })}
/>
{isRoom ? (
<>
<RoomTextField
label="Room name"
onCommit={(name) => update({ name })}
value={zone.name}
/>
<RoomTextField
label="Room number"
onCommit={(roomNumber) => update({ roomNumber })}
value={zone.roomNumber}
/>
<RoomSelect
label="Enclosure"
onChange={(enclosureStatus) =>
update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] })
}
options={[
{ label: 'Auto-detect', value: 'auto' },
{ label: 'Enclosed', value: 'enclosed' },
{ label: 'Open', value: 'open' },
]}
value={zone.enclosureStatus}
/>
<RoomTextField
label="Occupancy / use"
onCommit={(occupancy) => update({ occupancy })}
value={zone.occupancy}
/>
<RoomTextField
label="Floor finish"
onCommit={(floorFinish) => update({ floorFinish })}
value={zone.floorFinish}
/>
<RoomTextField
label="Wall finish"
onCommit={(wallFinish) => update({ wallFinish })}
value={zone.wallFinish}
/>
<RoomTextField
label="Ceiling finish"
onCommit={(ceilingFinish) => update({ ceilingFinish })}
value={zone.ceilingFinish}
/>
<MetricControl
label="Ceiling height"
max={20}
min={0.1}
onChange={(ceilingHeight) => update({ ceilingHeight })}
precision={2}
step={0.05}
unit="m"
value={zone.ceilingHeight}
/>
<RoomSelect
label="Clear dimensions"
onChange={(clearDimensionPolicy) =>
update({
clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'],
})
}
options={[
{ label: 'None', value: 'none' },
{ label: 'Inside faces', value: 'inside-faces' },
{ label: 'Finish faces', value: 'finish-faces' },
]}
value={zone.clearDimensionPolicy}
/>
</>
) : null}
</PanelSection>
)
}
export default function ZoneQuantitiesPanel() {
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const unit = useViewer((state) => state.unit)
@@ -193,48 +356,53 @@ export default function ZoneQuantitiesPanel() {
if (!effectiveZone || !report) return null
return (
<PanelSection title="Zone quantities">
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
</span>
<>
<RoomDocumentationPanel zone={effectiveZone} />
<PanelSection
title={effectiveZone.spaceRole === 'room' ? 'Room quantities' : 'Zone quantities'}
>
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
</span>
</div>
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
<span className="text-cyan-800">A</span>
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
<span className="ml-auto text-slate-600">P</span>
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
</div>
</div>
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
<span className="text-cyan-800">A</span>
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
<span className="ml-auto text-slate-600">P</span>
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
<ZonePlanSketch
edgeLengths={report.edgeLengths}
polygon={effectiveZone.polygon}
unit={unit}
/>
<div className="flex flex-col gap-1.5">
<QuantityRow
abbreviation="Aw"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Wall surface"
quantity={report.wallSurface}
/>
<QuantityRow
abbreviation="Af"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Floor surface"
quantity={report.floorSurface}
/>
<QuantityRow
abbreviation="V"
format={(value) => formatVolumeLabel(value, unit, 2)}
label="Volume"
quantity={report.volume}
/>
</div>
</div>
<ZonePlanSketch
edgeLengths={report.edgeLengths}
polygon={effectiveZone.polygon}
unit={unit}
/>
<div className="flex flex-col gap-1.5">
<QuantityRow
abbreviation="Aw"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Wall surface"
quantity={report.wallSurface}
/>
<QuantityRow
abbreviation="Af"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Floor surface"
quantity={report.floorSurface}
/>
<QuantityRow
abbreviation="V"
format={(value) => formatVolumeLabel(value, unit, 2)}
label="Volume"
quantity={report.volume}
/>
</div>
</PanelSection>
</PanelSection>
</>
)
}
@@ -0,0 +1,293 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type FloorplanGeometry,
type GeometryContext,
WallNode,
ZoneNode,
} from '@pascal-app/core'
import { createFloorplanContextExtensions } from '@pascal-app/editor'
import { buildRoomClearDimensions } from './room-clear-dimensions'
function enclosure(points: Array<[number, number]>) {
const walls = points.map((start, index) =>
WallNode.parse({
id: `wall_${index}`,
parentId: 'level_main',
start,
end: points[(index + 1) % points.length],
thickness: 0.2,
}),
)
const zone = ZoneNode.parse({
id: 'zone_room',
parentId: 'level_main',
name: 'Office',
polygon: points,
autoFromWalls: true,
boundaryWallIds: walls.map((wall) => wall.id),
spaceRole: 'room',
clearDimensionPolicy: 'inside-faces',
})
const nodes = Object.fromEntries([...walls, zone].map((node) => [node.id, node])) as Record<
string,
AnyNode
>
const context = {
resolve: (id) => nodes[id],
children: [],
siblings: [],
parent: null,
viewState: {
selected: false,
highlighted: false,
hovered: false,
moving: false,
unit: 'metric',
palette: {
measurementStroke: '#123456',
} as NonNullable<GeometryContext['viewState']>['palette'],
},
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
} satisfies GeometryContext
return { context, nodes, walls, zone }
}
function withFinishAssembly(wall: WallNode): WallNode {
return WallNode.parse({
...wall,
thickness: undefined,
assemblyLayers: [
{
id: `${wall.id}_core`,
role: 'structure',
side: 'core',
thickness: 0.2,
datumEligible: ['structural-face'],
},
{
id: `${wall.id}_interior-finish`,
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
datumEligible: ['finish-face'],
},
{
id: `${wall.id}_exterior-finish`,
role: 'exterior-finish',
side: 'exterior',
thickness: 0.02,
datumEligible: ['finish-face'],
},
],
})
}
function dimensions(geometry: FloorplanGeometry[]) {
return geometry.filter(
(entry): entry is Extract<FloorplanGeometry, { kind: 'dimension' }> =>
entry.kind === 'dimension',
)
}
describe('buildRoomClearDimensions', () => {
test('dimensions the proven inside faces of a rectangular room', () => {
const { context, zone } = enclosure([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
const result = dimensions(buildRoomClearDimensions(zone, context))
expect(result).toHaveLength(2)
expect(result.map((entry) => entry.text).sort()).toEqual(['2.8m', '3.8m'])
expect(result.every((entry) => entry.stroke === '#123456')).toBe(true)
expect(result[0]?.start[0]).toBeCloseTo(1.316)
expect(result[0]?.start[1]).toBeCloseTo(0.1)
expect(result[0]?.end[0]).toBeCloseTo(1.316)
expect(result[0]?.end[1]).toBeCloseTo(2.9)
})
test('preserves clear spans when the room is rotated', () => {
const angle = Math.PI / 6
const rotate = ([x, y]: [number, number]): [number, number] => [
x * Math.cos(angle) - y * Math.sin(angle),
x * Math.sin(angle) + y * Math.cos(angle),
]
const { context, zone } = enclosure(
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
].map(rotate),
)
expect(
dimensions(buildRoomClearDimensions(zone, context))
.map((entry) => entry.text)
.sort(),
).toEqual(['2.8m', '3.8m'])
})
test('consolidates collinear wall segments before proving the clear rectangle', () => {
const { context, zone } = enclosure([
[0, 0],
[2, 0],
[4, 0],
[4, 3],
[0, 3],
])
expect(
dimensions(buildRoomClearDimensions(zone, context))
.map((entry) => entry.text)
.sort(),
).toEqual(['2.8m', '3.8m'])
})
test('dimensions finish faces when every boundary wall has assembly finish datums', () => {
const { context, nodes, walls, zone } = enclosure([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
const assembledWalls = walls.map(withFinishAssembly)
const assembledNodes = { ...nodes }
for (const wall of assembledWalls) assembledNodes[wall.id] = wall
const result = dimensions(
buildRoomClearDimensions(
{ ...zone, clearDimensionPolicy: 'finish-faces' },
{
...context,
resolve: (id) => assembledNodes[id],
},
),
)
expect(result).toHaveLength(2)
expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m'])
})
test('adds a room-to-room finish-face dimension for adjacent rectangular rooms', () => {
const walls = [
WallNode.parse({ id: 'wall_a_bottom', parentId: 'level_main', start: [0, 0], end: [4, 0] }),
WallNode.parse({ id: 'wall_shared', parentId: 'level_main', start: [4, 0], end: [4, 3] }),
WallNode.parse({ id: 'wall_a_top', parentId: 'level_main', start: [4, 3], end: [0, 3] }),
WallNode.parse({ id: 'wall_a_left', parentId: 'level_main', start: [0, 3], end: [0, 0] }),
WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }),
WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }),
WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }),
].map(withFinishAssembly)
const zoneA = ZoneNode.parse({
id: 'zone_a',
parentId: 'level_main',
name: 'A',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
autoFromWalls: true,
boundaryWallIds: ['wall_a_bottom', 'wall_shared', 'wall_a_top', 'wall_a_left'],
spaceRole: 'room',
clearDimensionPolicy: 'finish-faces',
})
const zoneB = ZoneNode.parse({
id: 'zone_b',
parentId: 'level_main',
name: 'B',
polygon: [
[4, 0],
[8, 0],
[8, 3],
[4, 3],
],
autoFromWalls: true,
boundaryWallIds: ['wall_b_bottom', 'wall_b_right', 'wall_b_top', 'wall_shared'],
spaceRole: 'room',
clearDimensionPolicy: 'finish-faces',
})
const nodes = Object.fromEntries(
[...walls, zoneA, zoneB].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const context = {
resolve: (id) => nodes[id],
children: [],
siblings: [zoneB],
parent: null,
viewState: {
selected: false,
highlighted: false,
hovered: false,
moving: false,
unit: 'metric',
palette: {
measurementStroke: '#123456',
} as NonNullable<GeometryContext['viewState']>['palette'],
},
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
} satisfies GeometryContext
const result = dimensions(buildRoomClearDimensions(zoneA, context))
expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m', 'R-R 0.24m'])
expect(result.find((entry) => entry.text.startsWith('R-R'))?.text).toBe('R-R 0.24m')
})
test('dimensions proven rectilinear room bays beyond simple rectangles', () => {
const { context, zone } = enclosure([
[0, 0],
[4, 0],
[4, 2],
[2, 2],
[2, 4],
[0, 4],
])
const result = dimensions(buildRoomClearDimensions(zone, context))
expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m'])
})
test('suppresses dimensions when the requested datum cannot be proven', () => {
const { context, nodes, walls, zone } = enclosure([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([])
expect(
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
).toEqual([])
expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([])
expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([])
const missingWallNodes = { ...nodes }
delete missingWallNodes[walls[0]!.id]
expect(
buildRoomClearDimensions(zone, {
...context,
resolve: (id) => missingWallNodes[id],
}),
).toEqual([])
})
test('suppresses dimensions for a proven enclosure that is not rectangular', () => {
const { context, zone } = enclosure([
[0, 0],
[4, 0],
[4, 2],
[2, 3],
[0, 2],
])
expect(buildRoomClearDimensions(zone, context)).toEqual([])
})
})
@@ -0,0 +1,609 @@
import {
detectSpacesForLevel,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getWallAssemblyFaceOffsets,
resolveWallAssemblyDatumReferences,
type SpaceBoundaryFace,
type WallNode,
type ZoneNode,
} from '@pascal-app/core'
import { readFloorplanContext } from '@pascal-app/editor'
import {
type ConstructionLengthProfile,
type ConstructionMetricNotation,
formatConstructionLength,
} from '../shared/construction-length'
const LINE_TOLERANCE = 1e-4
const ANGLE_TOLERANCE = 1e-3
const MIN_CLEAR_SPAN = 0.3
const MIN_ROOM_TO_ROOM_SPAN = 0.03
const FIRST_DIMENSION_POSITION = 0.32
const SECOND_DIMENSION_POSITION = 0.68
const EXTENSION_OVERSHOOT = 0.08
type FaceLine = {
start: FloorplanPoint
end: FloorplanPoint
}
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type ClearDimensionPolicy = Extract<
ZoneNode['clearDimensionPolicy'],
'inside-faces' | 'finish-faces'
>
export function buildRoomClearDimensions(
node: ZoneNode,
ctx: GeometryContext,
): FloorplanGeometry[] {
if (
node.spaceRole !== 'room' ||
(node.clearDimensionPolicy !== 'inside-faces' &&
node.clearDimensionPolicy !== 'finish-faces') ||
node.enclosureStatus === 'open' ||
!node.autoFromWalls ||
!node.parentId ||
node.boundaryWallIds.length < 3
) {
return []
}
const walls = node.boundaryWallIds.flatMap((id) => {
const resolved = ctx.resolve(id)
return resolved &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'wall'
? [resolved as WallNode]
: []
})
if (walls.length !== node.boundaryWallIds.length) return []
const boundaryIds = new Set(node.boundaryWallIds)
const space = detectSpacesForLevel(node.parentId, walls).spaces.find(
(candidate) =>
candidate.wallIds.length === boundaryIds.size &&
candidate.wallIds.every((id) => boundaryIds.has(id)),
)
if (!space) return []
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy)
if (!faceLines) return []
const unit = ctx.viewState?.unit ?? 'metric'
const floorplanContext = readFloorplanContext(ctx)
const profile: ConstructionLengthProfile =
floorplanContext.purpose === 'document' ? 'document' : 'editor'
const metricNotation = floorplanContext.metricNotation
const stroke = ctx.viewState?.palette.measurementStroke ?? '#475569'
const rectangle = resolveClearFaceRectangle(faceLines)
const dimensions = rectangle
? buildRectangleClearDimensions(rectangle, unit, profile, metricNotation, stroke)
: buildRectilinearClearDimensions(faceLines, unit, profile, metricNotation, stroke)
if (dimensions.length === 0) return []
return [
...dimensions,
...buildRoomToRoomClearDimensions(
node,
ctx,
space.boundaryFaces,
wallsById,
unit,
profile,
metricNotation,
stroke,
),
]
}
function resolveClearFaceLines(
boundaryFaces: readonly SpaceBoundaryFace[],
wallsById: ReadonlyMap<string, WallNode>,
policy: ClearDimensionPolicy,
): FaceLine[] | null {
const faceLines: FaceLine[] = []
for (const boundary of boundaryFaces) {
const wall = wallsById.get(boundary.wallId)
if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null
const line = offsetBoundaryFace(boundary, wall, policy)
if (!line) return null
faceLines.push(line)
}
const merged = mergeCollinearFaces(faceLines)
return merged.length >= 4 ? merged : null
}
function resolveClearFaceRectangle(
merged: readonly FaceLine[],
): [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] | null {
if (merged.length !== 4) return null
const vertices = merged.map((line, index) => {
const previous = merged[(index + merged.length - 1) % merged.length]!
return intersectLines(previous, line)
})
if (vertices.some((vertex) => vertex === null)) return null
const rectangle = vertices as [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint]
const directions = rectangle.map((start, index) =>
normalizedDirection(start, rectangle[(index + 1) % rectangle.length]!),
)
if (directions.some((direction) => direction === null)) return null
const [first, second, third, fourth] = directions as [
FloorplanPoint,
FloorplanPoint,
FloorplanPoint,
FloorplanPoint,
]
if (
Math.abs(dot(first, second)) > ANGLE_TOLERANCE ||
Math.abs(dot(second, third)) > ANGLE_TOLERANCE ||
Math.abs(dot(third, fourth)) > ANGLE_TOLERANCE ||
Math.abs(dot(fourth, first)) > ANGLE_TOLERANCE ||
dot(first, third) > -1 + ANGLE_TOLERANCE ||
dot(second, fourth) > -1 + ANGLE_TOLERANCE
) {
return null
}
return rectangle
}
function buildRectangleClearDimensions(
rectangle: [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint],
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry[] {
const first = dimensionAcrossOppositeFaces(
rectangle[0],
rectangle[1],
rectangle[3],
rectangle[2],
FIRST_DIMENSION_POSITION,
unit,
profile,
metricNotation,
stroke,
)
const second = dimensionAcrossOppositeFaces(
rectangle[1],
rectangle[2],
rectangle[0],
rectangle[3],
SECOND_DIMENSION_POSITION,
unit,
profile,
metricNotation,
stroke,
)
return first && second ? [first, second] : []
}
function buildRectilinearClearDimensions(
faceLines: readonly FaceLine[],
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry[] {
const vertices = clearFacePolygon(faceLines)
if (!vertices || !isRectilinearPolygon(vertices)) return []
const dimensions: FloorplanGeometry[] = []
const seen = new Set<string>()
for (let firstIndex = 0; firstIndex < faceLines.length; firstIndex++) {
const first = faceLines[firstIndex]!
const firstDirection = normalizedDirection(first.start, first.end)
if (!firstDirection) return []
for (let secondIndex = firstIndex + 1; secondIndex < faceLines.length; secondIndex++) {
const second = faceLines[secondIndex]!
const secondDirection = normalizedDirection(second.start, second.end)
if (!secondDirection) return []
if (Math.abs(dot(firstDirection, secondDirection)) < 1 - ANGLE_TOLERANCE) continue
const dimension = dimensionBetweenOverlappingParallelFaces(
first,
second,
firstDirection,
vertices,
unit,
profile,
metricNotation,
stroke,
)
if (!dimension) continue
const key = dimensionKey(dimension)
if (seen.has(key)) continue
seen.add(key)
dimensions.push(dimension)
}
}
return dimensions
}
function offsetBoundaryFace(
boundary: SpaceBoundaryFace,
wall: WallNode,
policy: ClearDimensionPolicy,
): FaceLine | null {
const first = boundary.points[0]
const last = boundary.points[boundary.points.length - 1]
if (!(first && last)) return null
const wallDirection = normalizedDirection(wall.start, wall.end)
if (!wallDirection) return null
const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]]
const side = boundary.face === 'front' ? 1 : -1
const faces = getWallAssemblyFaceOffsets(wall)
const offset =
policy === 'finish-faces'
? resolveFinishFaceOffset(wall, side)
: side > 0
? faces.exterior
: faces.interior
if (offset === null) return null
return {
start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset],
end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset],
}
}
function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null {
if ((wall.assemblyLayers ?? []).length === 0) return null
const references = resolveWallAssemblyDatumReferences(wall).filter(
(reference) => reference.datum === 'finish-face',
)
const matching = references
.filter((reference) => Math.sign(reference.offset) === side)
.map((reference) => reference.offset)
if (matching.length === 0) return null
return side > 0 ? Math.max(...matching) : Math.min(...matching)
}
function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null {
const vertices = faceLines.map((line, index) => {
const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]!
return intersectLines(previous, line)
})
return vertices.some((vertex) => vertex === null) ? null : (vertices as FloorplanPoint[])
}
function isRectilinearPolygon(vertices: readonly FloorplanPoint[]): boolean {
if (vertices.length < 4) return false
const directions = vertices.map((start, index) =>
normalizedDirection(start, vertices[(index + 1) % vertices.length]!),
)
if (directions.some((direction) => direction === null)) return false
for (let index = 0; index < directions.length; index++) {
const current = directions[index]!
const next = directions[(index + 1) % directions.length]!
if (Math.abs(dot(current, next)) > ANGLE_TOLERANCE) return false
}
return true
}
function dimensionBetweenOverlappingParallelFaces(
first: FaceLine,
second: FaceLine,
direction: FloorplanPoint,
polygon: readonly FloorplanPoint[],
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): DimensionGeometry | null {
const firstStart = dot(first.start, direction)
const firstEnd = dot(first.end, direction)
const secondStart = dot(second.start, direction)
const secondEnd = dot(second.end, direction)
const overlapStart = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd))
const overlapEnd = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd))
if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null
const projection = (overlapStart + overlapEnd) / 2
const start = projectPointToLineProjection(first, direction, projection)
const end = projectPointToLineProjection(second, direction, projection)
const midpoint: FloorplanPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
if (!pointInPolygon(midpoint, polygon)) return null
const axis = normalizedDirection(start, end)
if (!axis) return null
const length = distance(start, end)
if (length < MIN_CLEAR_SPAN) return null
return {
kind: 'dimension',
start,
end,
offsetNormal: [-axis[1], axis[0]],
offsetDistance: 0,
extensionOvershoot: EXTENSION_OVERSHOOT,
text: formatConstructionLength(length, unit, profile, { metricNotation }),
stroke,
}
}
function pointInPolygon(point: FloorplanPoint, polygon: readonly FloorplanPoint[]): boolean {
let inside = false
for (
let index = 0, previousIndex = polygon.length - 1;
index < polygon.length;
previousIndex = index++
) {
const current = polygon[index]!
const previous = polygon[previousIndex]!
const intersects =
current[1] > point[1] !== previous[1] > point[1] &&
point[0] <
((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) +
current[0]
if (intersects) inside = !inside
}
return inside
}
function dimensionKey(dimension: DimensionGeometry): string {
const first = `${roundKey(dimension.start[0])},${roundKey(dimension.start[1])}`
const second = `${roundKey(dimension.end[0])},${roundKey(dimension.end[1])}`
return first < second ? `${first}|${second}` : `${second}|${first}`
}
function roundKey(value: number): number {
return Math.round(value / LINE_TOLERANCE)
}
function buildRoomToRoomClearDimensions(
node: ZoneNode,
ctx: GeometryContext,
boundaryFaces: readonly SpaceBoundaryFace[],
wallsById: ReadonlyMap<string, WallNode>,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry[] {
if (node.clearDimensionPolicy !== 'finish-faces') return []
const neighboringRooms = ctx.siblings.filter(
(sibling): sibling is ZoneNode =>
sibling.type === 'zone' &&
sibling.id !== node.id &&
String(node.id) < String(sibling.id) &&
sibling.spaceRole === 'room' &&
sibling.clearDimensionPolicy === 'finish-faces' &&
sibling.enclosureStatus !== 'open' &&
sibling.autoFromWalls &&
sibling.parentId === node.parentId,
)
if (neighboringRooms.length === 0) return []
const dimensions: FloorplanGeometry[] = []
const currentBoundaryByWallId = new Map(
boundaryFaces.map((boundary) => [boundary.wallId, boundary]),
)
for (const neighbor of neighboringRooms) {
const sharedWallIds = neighbor.boundaryWallIds.filter((wallId) =>
currentBoundaryByWallId.has(wallId),
)
if (sharedWallIds.length === 0) continue
const neighborWalls = neighbor.boundaryWallIds.flatMap((id) => {
const resolved = ctx.resolve(id)
return resolved &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'wall'
? [resolved as WallNode]
: []
})
if (neighborWalls.length !== neighbor.boundaryWallIds.length) continue
const neighborWallIds = new Set(neighbor.boundaryWallIds)
const neighborSpace = detectSpacesForLevel(neighbor.parentId ?? '', neighborWalls).spaces.find(
(candidate) =>
candidate.wallIds.length === neighborWallIds.size &&
candidate.wallIds.every((id) => neighborWallIds.has(id)),
)
if (!neighborSpace) continue
const neighborBoundaryByWallId = new Map(
neighborSpace.boundaryFaces.map((boundary) => [boundary.wallId, boundary]),
)
for (const wallId of sharedWallIds) {
const wall = wallsById.get(wallId)
const currentBoundary = currentBoundaryByWallId.get(wallId)
const neighborBoundary = neighborBoundaryByWallId.get(wallId)
if (!(wall && currentBoundary && neighborBoundary)) continue
const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces')
const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces')
if (!(currentLine && neighborLine)) continue
const dimension = dimensionAcrossSharedRoomWall(
currentLine,
neighborLine,
unit,
profile,
metricNotation,
stroke,
)
if (dimension) dimensions.push(dimension)
}
}
return dimensions
}
function dimensionAcrossSharedRoomWall(
currentLine: FaceLine,
neighborLine: FaceLine,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): DimensionGeometry | null {
const direction = normalizedDirection(currentLine.start, currentLine.end)
if (!direction) return null
const neighborDirection = normalizedDirection(neighborLine.start, neighborLine.end)
if (!neighborDirection || Math.abs(dot(direction, neighborDirection)) < 1 - ANGLE_TOLERANCE) {
return null
}
const currentStart = dot(currentLine.start, direction)
const currentEnd = dot(currentLine.end, direction)
const neighborStart = dot(neighborLine.start, direction)
const neighborEnd = dot(neighborLine.end, direction)
const overlapStart = Math.max(
Math.min(currentStart, currentEnd),
Math.min(neighborStart, neighborEnd),
)
const overlapEnd = Math.min(
Math.max(currentStart, currentEnd),
Math.max(neighborStart, neighborEnd),
)
if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null
const projection = (overlapStart + overlapEnd) / 2
const start = projectPointToLineProjection(currentLine, direction, projection)
const end = projectPointToLineProjection(neighborLine, direction, projection)
const clear = distance(start, end)
if (clear < MIN_ROOM_TO_ROOM_SPAN) return null
const axis = normalizedDirection(start, end)
if (!axis) return null
return {
kind: 'dimension',
start,
end,
offsetNormal: [-axis[1], axis[0]],
offsetDistance: 0,
extensionOvershoot: EXTENSION_OVERSHOOT,
text: `R-R ${formatConstructionLength(clear, unit, profile, { metricNotation })}`,
stroke,
}
}
function projectPointToLineProjection(
line: FaceLine,
direction: FloorplanPoint,
projection: number,
): FloorplanPoint {
const originProjection = dot(line.start, direction)
return [
line.start[0] + direction[0] * (projection - originProjection),
line.start[1] + direction[1] * (projection - originProjection),
]
}
function mergeCollinearFaces(lines: readonly FaceLine[]): FaceLine[] {
const merged: FaceLine[] = []
for (const line of lines) {
const previous = merged[merged.length - 1]
if (previous && canMerge(previous, line)) previous.end = line.end
else merged.push({ ...line })
}
while (merged.length > 1) {
const first = merged[0]!
const last = merged[merged.length - 1]!
if (!canMerge(last, first)) break
first.start = last.start
merged.pop()
}
return merged
}
function canMerge(first: FaceLine, second: FaceLine): boolean {
const firstDirection = normalizedDirection(first.start, first.end)
const secondDirection = normalizedDirection(second.start, second.end)
if (!(firstDirection && secondDirection)) return false
return (
dot(firstDirection, secondDirection) > 1 - ANGLE_TOLERANCE &&
pointLineDistance(second.start, first) <= LINE_TOLERANCE
)
}
function intersectLines(first: FaceLine, second: FaceLine): FloorplanPoint | null {
const firstDirection: FloorplanPoint = [
first.end[0] - first.start[0],
first.end[1] - first.start[1],
]
const secondDirection: FloorplanPoint = [
second.end[0] - second.start[0],
second.end[1] - second.start[1],
]
const denominator = cross(firstDirection, secondDirection)
if (Math.abs(denominator) <= LINE_TOLERANCE) return null
const delta: FloorplanPoint = [second.start[0] - first.start[0], second.start[1] - first.start[1]]
const parameter = cross(delta, secondDirection) / denominator
return [
first.start[0] + firstDirection[0] * parameter,
first.start[1] + firstDirection[1] * parameter,
]
}
function dimensionAcrossOppositeFaces(
firstStart: FloorplanPoint,
firstEnd: FloorplanPoint,
oppositeStart: FloorplanPoint,
oppositeEnd: FloorplanPoint,
position: number,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
metricNotation: ConstructionMetricNotation,
stroke: string,
): FloorplanGeometry | null {
const start = interpolate(firstStart, firstEnd, position)
const end = interpolate(oppositeStart, oppositeEnd, position)
const direction = normalizedDirection(start, end)
if (!direction) return null
const length = distance(start, end)
if (length < MIN_CLEAR_SPAN) return null
return {
kind: 'dimension',
start,
end,
offsetNormal: [-direction[1], direction[0]],
offsetDistance: 0,
extensionOvershoot: EXTENSION_OVERSHOOT,
text: formatConstructionLength(length, unit, profile, { metricNotation }),
stroke,
}
}
function normalizedDirection(
start: readonly [number, number],
end: readonly [number, number],
): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const length = Math.hypot(dx, dy)
return length <= LINE_TOLERANCE ? null : [dx / length, dy / length]
}
function pointLineDistance(point: FloorplanPoint, line: FaceLine): number {
const direction = normalizedDirection(line.start, line.end)
if (!direction) return Number.POSITIVE_INFINITY
return Math.abs(cross(direction, [point[0] - line.start[0], point[1] - line.start[1]]))
}
function interpolate(start: FloorplanPoint, end: FloorplanPoint, t: number): FloorplanPoint {
return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t]
}
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
function dot(first: FloorplanPoint, second: FloorplanPoint): number {
return first[0] * second[0] + first[1] * second[1]
}
function cross(first: FloorplanPoint, second: FloorplanPoint): number {
return first[0] * second[1] - first[1] * second[0]
}
@@ -0,0 +1,144 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, LevelNode, ZoneNode } from '@pascal-app/core'
import { buildRoomFloorplanSchedule } from './room-documentation'
function room(overrides: Partial<ZoneNode> = {}) {
return ZoneNode.parse({
id: 'zone_room',
parentId: 'level_main',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
spaceRole: 'room',
roomNumber: '101',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 2.7,
occupancy: 'Business',
...overrides,
})
}
function nodesFor(zones: ZoneNode[]) {
const level = LevelNode.parse({
id: 'level_main',
children: zones.map((zone) => zone.id),
})
return Object.fromEntries([level, ...zones].map((node) => [node.id, node])) as Record<
string,
AnyNode
>
}
describe('buildRoomFloorplanSchedule', () => {
test('includes only architectural rooms and formats their documented values', () => {
const office = room({ id: 'zone_office', roomNumber: '102' })
const lobby = room({
id: 'zone_lobby',
name: 'Lobby',
roomNumber: '101',
polygon: [
[0, 0],
[5, 0],
[5, 2],
[0, 2],
],
floorFinish: '',
})
const courtyard = room({
id: 'zone_courtyard',
name: 'Courtyard',
roomNumber: '100',
spaceRole: 'generic',
})
const zones = [office, lobby, courtyard]
const schedule = buildRoomFloorplanSchedule({
siblings: zones,
nodes: nodesFor(zones),
levelId: 'level_main',
unit: 'metric',
})
expect(schedule?.title).toBe('ROOM SCHEDULE')
expect(schedule?.rows.map((row) => row.id)).toEqual(['zone_lobby', 'zone_office'])
expect(schedule?.rows[0]?.cells).toMatchObject({
number: '101',
name: 'Lobby',
area: '10.00 m²',
floorFinish: '—',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: '2700',
occupancy: 'Business',
enclosure: 'Open',
})
})
test('formats imperial schedule values', () => {
const office = room({
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
})
const schedule = buildRoomFloorplanSchedule({
siblings: [office],
nodes: nodesFor([office]),
levelId: 'level_main',
unit: 'imperial',
})
expect(schedule?.rows[0]?.cells).toMatchObject({
area: '10.8 ft²',
ceilingHeight: `8'-10 5/16"`,
})
})
test('reports missing and duplicate room numbers plus unproven enclosure claims', () => {
const unnumbered = room({
id: 'zone_unnumbered',
name: 'Storage',
roomNumber: '',
})
const duplicateA = room({ id: 'zone_a', roomNumber: 'A01' })
const duplicateB = room({
id: 'zone_b',
name: 'Meeting',
roomNumber: 'a01',
enclosureStatus: 'enclosed',
})
const zones = [unnumbered, duplicateA, duplicateB]
const schedule = buildRoomFloorplanSchedule({
siblings: zones,
nodes: nodesFor(zones),
levelId: 'level_main',
unit: 'metric',
})
expect(schedule?.issues).toEqual([
'Room Storage has no room number',
'Room a01 is marked enclosed but not proven',
'Duplicate room number A01 (2 rooms)',
])
})
test('returns no schedule when the level has no architectural rooms', () => {
const zone = room({ spaceRole: 'generic' })
expect(
buildRoomFloorplanSchedule({
siblings: [zone],
nodes: nodesFor([zone]),
levelId: 'level_main',
unit: 'metric',
}),
).toBeNull()
})
})
@@ -0,0 +1,125 @@
import {
type AnyNode,
deriveZoneQuantityReport,
resolveAutoZonePolygon,
type ZoneNode,
} from '@pascal-app/core'
import type { FloorplanSchedule } from '@pascal-app/editor'
import {
type ConstructionLengthProfile,
type ConstructionLinearUnit,
formatConstructionLength,
} from '../shared/construction-length'
const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671
const ROOM_NUMBER_COLLATOR = new Intl.Collator('en', { numeric: true, sensitivity: 'base' })
export function buildRoomFloorplanSchedule(args: {
siblings: ReadonlyArray<ZoneNode>
nodes: Readonly<Record<string, AnyNode>>
levelId: string
unit: ConstructionLinearUnit
profile?: ConstructionLengthProfile
}): FloorplanSchedule | null {
const rooms = args.siblings
.filter((zone) => zone.spaceRole === 'room')
.map((zone) => {
const polygon = resolveAutoZonePolygon(zone, (id) => args.nodes[id])
const resolvedZone = polygon === zone.polygon ? zone : { ...zone, polygon }
return { zone: resolvedZone, report: deriveZoneQuantityReport(resolvedZone, args.nodes) }
})
.sort((a, b) => compareRooms(a.zone, b.zone))
if (rooms.length === 0) return null
return {
id: 'rooms',
title: 'ROOM SCHEDULE',
columns: [
{ key: 'number', label: 'NO.', weight: 0.7 },
{ key: 'name', label: 'ROOM NAME', weight: 1.35 },
{ key: 'area', label: 'AREA', weight: 0.9 },
{ key: 'floorFinish', label: 'FLOOR FINISH', weight: 1.15 },
{ key: 'wallFinish', label: 'WALL FINISH', weight: 1.15 },
{ key: 'ceilingFinish', label: 'CEILING FINISH', weight: 1.15 },
{ key: 'ceilingHeight', label: 'CLG. HT.', weight: 0.9 },
{ key: 'occupancy', label: 'OCCUPANCY / USE', weight: 1.25 },
{ key: 'enclosure', label: 'ENCLOSURE', weight: 0.9 },
],
rows: rooms.map(({ zone, report }) => ({
id: zone.id,
cells: {
number: valueOrDash(zone.roomNumber),
name: valueOrDash(zone.name),
area: formatRoomArea(report.footprintArea, args.unit),
floorFinish: valueOrDash(zone.floorFinish),
wallFinish: valueOrDash(zone.wallFinish),
ceilingFinish: valueOrDash(zone.ceilingFinish),
ceilingHeight: formatConstructionLength(
zone.ceilingHeight,
args.unit,
args.profile ?? 'document',
),
occupancy: valueOrDash(zone.occupancy),
enclosure: resolveEnclosure(zone, report.classification),
},
})),
issues: collectRoomScheduleIssues(rooms),
}
}
function compareRooms(a: ZoneNode, b: ZoneNode): number {
const numberComparison = ROOM_NUMBER_COLLATOR.compare(a.roomNumber.trim(), b.roomNumber.trim())
if (numberComparison !== 0) return numberComparison
const nameComparison = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
return nameComparison !== 0 ? nameComparison : a.id.localeCompare(b.id)
}
function valueOrDash(value: string): string {
return value.trim() || '—'
}
function formatRoomArea(squareMeters: number, unit: ConstructionLinearUnit): string {
if (!Number.isFinite(squareMeters)) return '—'
if (unit === 'metric') return `${squareMeters.toFixed(2)}`
return `${(squareMeters * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²`
}
function resolveEnclosure(zone: ZoneNode, classification: 'footprint' | 'enclosed-room'): string {
if (zone.enclosureStatus === 'enclosed') return 'Enclosed'
if (zone.enclosureStatus === 'open') return 'Open'
return classification === 'enclosed-room' ? 'Enclosed' : 'Open'
}
function collectRoomScheduleIssues(
rooms: ReadonlyArray<{
zone: ZoneNode
report: { classification: 'footprint' | 'enclosed-room' }
}>,
): string[] {
const issues: string[] = []
const numberedRooms = new Map<string, ZoneNode[]>()
for (const { zone, report } of rooms) {
const number = zone.roomNumber.trim()
if (!number) {
issues.push(`Room ${zone.name.trim() || zone.id} has no room number`)
} else {
const normalized = number.toLocaleUpperCase()
const duplicates = numberedRooms.get(normalized)
if (duplicates) duplicates.push(zone)
else numberedRooms.set(normalized, [zone])
}
if (zone.enclosureStatus === 'enclosed' && report.classification !== 'enclosed-room') {
issues.push(`Room ${number || zone.name.trim() || zone.id} is marked enclosed but not proven`)
}
}
for (const [normalizedNumber, duplicateRooms] of numberedRooms) {
if (duplicateRooms.length < 2) continue
issues.push(`Duplicate room number ${normalizedNumber} (${duplicateRooms.length} rooms)`)
}
return issues
}