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
+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
}