Files
editor/packages/nodes/src/wall/construction-dimensions.test.ts
T
77442861d9 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>
2026-07-22 14:02:17 -04:00

1327 lines
39 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
ColumnNode,
DoorNode,
type FloorplanGeometry,
type GeometryContext,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { constructionDimensionStandard } from '../shared/construction-dimension-standards'
import {
buildCurvedWallConstructionDimensions,
buildLevelWallConstructionDimensionPlan,
buildWallConstructionDimensions,
formatConstructionLength,
renderPlannedConstructionDimensions,
} from './construction-dimensions'
function wall(overrides: Partial<WallNode> = {}) {
return WallNode.parse({
id: 'wall_main',
parentId: 'level_main',
start: [0, 0],
end: [10, 0],
thickness: 0.2,
frontSide: 'exterior',
backSide: 'interior',
...overrides,
})
}
function context(
children: GeometryContext['children'] = [],
siblings: GeometryContext['siblings'] = [],
) {
return {
resolve: () => undefined,
children,
siblings,
parent: null,
} satisfies GeometryContext
}
function dimensionTexts(entries: readonly FloorplanGeometry[]): string[] {
return entries.flatMap((entry) => {
if (entry.kind === 'dimension') return [entry.text]
if (entry.kind === 'dimension-string') return entry.segments.map((segment) => segment.text)
return []
})
}
function firstDimensionSegment(entry: FloorplanGeometry) {
if (entry.kind === 'dimension') return entry
if (entry.kind === 'dimension-string') return entry.segments[0]
return null
}
describe('formatConstructionLength', () => {
test('formats U.S. architectural feet, inches, and reduced sixteenths', () => {
expect(formatConstructionLength(12 * 0.3048, 'imperial')).toBe(`12'-0"`)
expect(formatConstructionLength((7 * 12 + 5.5) * 0.0254, 'imperial')).toBe(`7'-5 1/2"`)
expect(formatConstructionLength((2 * 12 + 3.1875) * 0.0254, 'imperial')).toBe(`2'-3 3/16"`)
})
test('rounds to the nearest sixteenth and carries into the next foot', () => {
expect(formatConstructionLength((11 + 0.99) * 0.0254, 'imperial')).toBe(`1'-0"`)
expect(formatConstructionLength(-0.5 * 0.0254, 'imperial')).toBe(`-0 1/2"`)
})
test('keeps metric output concise', () => {
expect(formatConstructionLength(3.456, 'metric')).toBe('3.46m')
expect(formatConstructionLength(Number.NaN, 'metric')).toBe('--')
})
})
describe('buildWallConstructionDimensions', () => {
test('dimensions curved-wall depth orthogonally without a radius leader', () => {
const curved = wall({ curveOffset: 1 })
const extension = wall({ id: 'wall_extension', start: [10, 0], end: [14, 0] })
const geometry = buildCurvedWallConstructionDimensions(curved, {
unit: 'metric',
siblings: [extension],
})[0]
expect(geometry).toMatchObject({
kind: 'dimension-string',
offsetNormal: [1, 0],
segments: [
{
end: [10, -0.1],
text: '1m',
},
],
})
if (geometry?.kind !== 'dimension-string') return
const segment = geometry.segments[0]
expect(segment?.start[0]).toBeCloseTo(5)
expect(segment?.start[1]).toBeCloseTo(-1.1)
expect(segment?.dimensionStart[0]).toBeCloseTo(14.55)
expect(segment?.dimensionEnd[0]).toBeCloseTo(14.55)
})
test('builds a jamb-by-jamb chain plus a farther wall span', () => {
const door = DoorNode.parse({
id: 'door_entry',
parentId: 'wall_main',
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_front',
parentId: 'wall_main',
position: [6, 1.5, 0],
width: 2,
})
const dimensions = buildWallConstructionDimensions(wall(), context([door, window]), {
unit: 'metric',
})
expect(dimensions).toHaveLength(6)
expect(dimensions.map((entry) => entry.kind)).toEqual(Array(6).fill('dimension-string'))
expect(dimensionTexts(dimensions)).toEqual(['1.5m', '1m', '2.5m', '2m', '3m', '10m'])
expect(dimensions.at(-1)).toMatchObject({
kind: 'dimension-string',
offsetNormal: [0, 1],
offsetDistance: 1.05,
})
})
test('applies drawing dimension standards to automatic wall strings', () => {
const door = DoorNode.parse({
id: 'door_entry',
parentId: 'wall_main',
position: [2, 1.05, 0],
width: 1,
})
const standard = constructionDimensionStandard({
openingChainOffset: 0.7,
wallSpanOffset: 1.4,
extensionStartGap: 0.03,
extensionOvershoot: 0.18,
terminator: 'dot',
textPosition: 'centered',
metricNotation: 'millimeters',
})
const dimensions = buildWallConstructionDimensions(wall(), context([door]), {
unit: 'metric',
standard,
})
expect(dimensionTexts(dimensions)).toEqual(['1500', '1000', '7500', '10000'])
expect(dimensions[0]).toMatchObject({
kind: 'dimension-string',
offsetDistance: 0.7,
extensionStartGap: 0.03,
extensionOvershoot: 0.18,
terminator: 'dot',
textPosition: 'centered',
})
expect(dimensions.at(-1)).toMatchObject({
kind: 'dimension-string',
offsetDistance: 1.4,
})
})
test('uses the exterior wall face as the dimension side', () => {
const dimensions = buildWallConstructionDimensions(
wall({ frontSide: 'interior', backSide: 'exterior' }),
context(),
{ unit: 'metric' },
)
expect(dimensions).toHaveLength(1)
expect(dimensions[0]).toMatchObject({
kind: 'dimension-string',
offsetNormal: [0, -1],
})
expect(firstDimensionSegment(dimensions[0]!)).toMatchObject({
start: [0, -0.1],
end: [10, -0.1],
})
})
test('places witness origins on centerline, structural, finish, or assembly faces', () => {
const assemblyWall = wall({
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.1,
datumEligible: ['structural-face'],
},
{
id: 'interior-finish',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
datumEligible: ['finish-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.03,
datumEligible: ['finish-face'],
},
],
})
const witnessY = (
datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face',
) => {
const entry = buildWallConstructionDimensions(assemblyWall, context(), {
unit: 'metric',
standard: constructionDimensionStandard({ datumPolicy }),
})[0]
return entry ? firstDimensionSegment(entry)?.start[1] : Number.NaN
}
expect(witnessY('centerline')).toBe(0)
expect(witnessY('structural-face')).toBeCloseTo(0.05)
expect(witnessY('finish-face')).toBeCloseTo(0.08)
expect(witnessY('wall-face')).toBeCloseTo(0.08)
})
test('never dimensions a classified interior wall', () => {
const interior = wall({ frontSide: 'interior', backSide: 'interior' })
expect(buildWallConstructionDimensions(interior, context(), { unit: 'metric' })).toEqual([])
})
test('keeps curved walls out of the straight-string builder', () => {
expect(
buildWallConstructionDimensions(wall({ curveOffset: 1 }), context(), { unit: 'metric' }),
).toEqual([])
})
})
describe('buildLevelWallConstructionDimensionPlan', () => {
test('chains straight facade runs across a curved-wall opening', () => {
const upper = wall({
id: 'wall_left_upper',
start: [0, 0],
end: [0, -6],
frontSide: 'interior',
backSide: 'exterior',
})
const curved = wall({
id: 'wall_left_curve',
start: [0, -6],
end: [0, -18],
curveOffset: 6,
frontSide: 'interior',
backSide: 'exterior',
})
const lower = wall({
id: 'wall_left_lower',
start: [0, -18],
end: [0, -24],
frontSide: 'interior',
backSide: 'exterior',
})
const top = wall({ id: 'wall_top', start: [0, 0], end: [12, 0] })
const right = wall({
id: 'wall_right',
start: [12, 0],
end: [12, -24],
frontSide: 'exterior',
backSide: 'interior',
})
const bottom = wall({
id: 'wall_bottom',
start: [12, -24],
end: [0, -24],
frontSide: 'exterior',
backSide: 'interior',
})
const plan = buildLevelWallConstructionDimensionPlan(
[upper, curved, lower, top, right, bottom],
{},
)
const leftFacade = plan.get(lower.id) ?? []
expect(leftFacade.map((entry) => entry.tier)).toEqual(['jogs', 'jogs', 'jogs', 'overall'])
expect(dimensionTexts(renderPlannedConstructionDimensions(leftFacade, 'metric'))).toEqual([
'6m',
'12m',
'6m',
'24.2m',
])
const jogs = leftFacade.filter((entry) => entry.tier === 'jogs')
const overall = leftFacade.find((entry) => entry.tier === 'overall')
expect(jogs[0]?.dimensionStart?.[0]).toBeCloseTo(-6.65)
expect(overall?.dimensionStart?.[0]).toBeCloseTo(-7.27)
expect(
leftFacade.every(
(entry) =>
(entry.dimensionStart?.[0] ?? Number.POSITIVE_INFINITY) < -6.1 &&
(entry.dimensionEnd?.[0] ?? Number.POSITIVE_INFINITY) < -6.1,
),
).toBe(true)
})
test('coordinates opening widths, centers, partition references, and overall extent', () => {
const exterior = wall()
const partition = wall({
id: 'wall_partition',
start: [4, 0],
end: [4, -4],
frontSide: 'interior',
backSide: 'interior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exterior.id,
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_front',
parentId: exterior.id,
position: [6, 1.5, 0],
width: 2,
})
const nodes = { [door.id]: door, [window.id]: window } satisfies Record<string, AnyNode>
const plan = buildLevelWallConstructionDimensionPlan([exterior, partition], nodes)
const planned = plan.get(exterior.id)
const exteriorPlanned = planned ?? []
expect(exteriorPlanned.map((entry) => entry.tier)).toEqual([
'opening-widths',
'opening-widths',
'openings',
'openings',
'openings',
'partitions',
'partitions',
'overall',
])
expect(exteriorPlanned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([
0.62, 0.62, 1.24, 1.24, 1.24, 1.86, 1.86, 2.48,
])
expect(dimensionTexts(renderPlannedConstructionDimensions(exteriorPlanned, 'metric'))).toEqual([
'1m',
'2m',
'2m',
'4m',
'4m',
'3.9m',
'6.1m',
'10m',
])
expect(exteriorPlanned.at(-1)).toMatchObject({
start: [0, 0.1],
end: [10, 0.1],
offsetNormal: [0, 1],
})
})
test('spaces the opening-width tier from the wall like the following dimension tiers', () => {
const exterior = wall()
const door = DoorNode.parse({
id: 'door_entry',
parentId: exterior.id,
position: [2, 1.05, 0],
width: 1,
})
const standard = constructionDimensionStandard()
const planned =
buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }, standard).get(
exterior.id,
) ?? []
const tierOffsets = planned.reduce<number[]>((offsets, entry) => {
if (!offsets.includes(entry.offsetDistance)) offsets.push(entry.offsetDistance)
return offsets
}, [])
expect(tierOffsets[0]).toBeCloseTo(standard.tierSpacing)
for (const [index, offset] of tierOffsets.slice(1).entries()) {
expect(offset - tierOffsets[index]!).toBeCloseTo(standard.tierSpacing)
}
})
test('labels verified rough openings while retaining framed centerline locations', () => {
const exterior = wall()
const door = DoorNode.parse({
id: 'door_ro',
parentId: exterior.id,
position: [2, 1.05, 0],
width: 1,
dimensionReference: 'rough-opening',
roughOpeningWidth: 1.2,
roughOpeningHeight: 2.2,
})
const planned =
buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }).get(exterior.id) ??
[]
expect(planned.map((entry) => entry.tier)).toEqual([
'opening-widths',
'openings',
'openings',
'overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'RO 1.2m',
'2m',
'8m',
'10m',
])
})
test('uses masonry openings as edge-to-edge dimensions without framed centerline strings', () => {
const exterior = wall()
const window = WindowNode.parse({
id: 'window_mo',
parentId: exterior.id,
position: [6, 1.5, 0],
width: 1,
constructionType: 'masonry',
masonryOpeningWidth: 1.4,
masonryOpeningHeight: 1.6,
})
const planned =
buildLevelWallConstructionDimensionPlan([exterior], { [window.id]: window }).get(
exterior.id,
) ?? []
expect(planned.map((entry) => entry.tier)).toEqual(['opening-widths', 'overall'])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'MO 1.4m',
'10m',
])
})
test('skips unverified rough-opening widths instead of deriving them from nominal size', () => {
const exterior = wall()
const door = DoorNode.parse({
id: 'door_missing_ro',
parentId: exterior.id,
position: [2, 1.05, 0],
width: 1,
dimensionReference: 'rough-opening',
})
const planned =
buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }).get(exterior.id) ??
[]
expect(planned.map((entry) => entry.tier)).toEqual(['openings', 'openings', 'overall'])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'2m',
'8m',
'10m',
])
})
test('labels optional finish-opening reference dimensions when explicitly verified', () => {
const exterior = wall()
const window = WindowNode.parse({
id: 'window_fo',
parentId: exterior.id,
position: [5, 1.5, 0],
width: 1.2,
dimensionReference: 'finish-opening',
finishOpeningWidth: 0.95,
finishOpeningHeight: 1.2,
})
const planned =
buildLevelWallConstructionDimensionPlan([exterior], { [window.id]: window }).get(
exterior.id,
) ?? []
expect(planned.map((entry) => entry.tier)).toEqual([
'opening-widths',
'openings',
'openings',
'overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'FO 0.95m',
'5m',
'5m',
'10m',
])
})
test('uses drawing standard tier spacing for coordinated facade dimensions', () => {
const exterior = wall()
const partition = wall({
id: 'wall_partition',
start: [4, 0],
end: [4, -4],
frontSide: 'interior',
backSide: 'interior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exterior.id,
position: [2, 1.05, 0],
width: 1,
})
const standard = constructionDimensionStandard({
firstOpeningWidthOffset: 0.4,
firstGeneralTierOffset: 0.6,
tierSpacing: 0.5,
extensionOvershoot: 0.2,
terminator: 'open-arrow',
})
const planned =
buildLevelWallConstructionDimensionPlan(
[exterior, partition],
{ [door.id]: door },
standard,
).get(exterior.id) ?? []
const rendered = renderPlannedConstructionDimensions(
planned,
'metric',
undefined,
'editor',
standard,
)
expect(planned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([
0.4, 0.9, 0.9, 1.4, 1.4, 1.9,
])
expect(rendered[0]).toMatchObject({
kind: 'dimension-string',
extensionOvershoot: 0.2,
terminator: 'open-arrow',
})
})
test('combines collinear exterior wall segments into one facade run', () => {
const first = wall({ id: 'wall_a', end: [5, 0] })
const second = wall({ id: 'wall_b', start: [5, 0] })
const opening = WindowNode.parse({
id: 'window_b',
parentId: second.id,
position: [2, 1.5, 0],
width: 1,
})
const plan = buildLevelWallConstructionDimensionPlan([second, first], { [opening.id]: opening })
const exteriorPlanned = plan.get(first.id) ?? []
expect([...plan.keys()]).toEqual([first.id])
expect(dimensionTexts(renderPlannedConstructionDimensions(exteriorPlanned, 'metric'))).toEqual([
'1m',
'7m',
'3m',
'10m',
])
})
test('adds a wall-local overall dimension for a classified interior partition', () => {
const exterior = wall()
const partition = wall({
id: 'wall_partition',
start: [4, 0],
end: [4, -4],
frontSide: 'interior',
backSide: 'interior',
})
const plan = buildLevelWallConstructionDimensionPlan([exterior, partition], {})
const exteriorPlanned = plan.get(exterior.id) ?? []
const interiorPlanned = plan.get(partition.id) ?? []
expect(exteriorPlanned.map((entry) => entry.tier)).toEqual([
'partitions',
'partitions',
'overall',
])
expect(interiorPlanned).toEqual([
expect.objectContaining({
tier: 'interior-overall',
offsetDistance: 0.55,
}),
])
expect(exteriorPlanned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([
0.55, 0.55, 1.17,
])
})
test('dimensions interior wall segments and hosted door and window widths in the larger room', () => {
const partition = wall({
id: 'wall_partition',
start: [0, 4],
end: [10, 4],
frontSide: 'interior',
backSide: 'interior',
})
const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] })
const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] })
const door = DoorNode.parse({
id: 'door_entry',
parentId: partition.id,
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_internal',
parentId: partition.id,
position: [6, 1.5, 0],
width: 2,
})
const planned =
buildLevelWallConstructionDimensionPlan([partition, lowerBoundary, upperBoundary], {
[door.id]: door,
[window.id]: window,
}).get(partition.id) ?? []
expect(planned.map((entry) => entry.tier)).toEqual([
'interior',
'interior',
'interior',
'interior',
'interior',
'interior-overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'1.5m',
'1m',
'2.5m',
'2m',
'3m',
'10m',
])
expect(planned[0]).toMatchObject({
start: [0, 4.1],
offsetNormal: [0, 1],
offsetDistance: 0.55,
})
expect(planned.at(-1)).toMatchObject({
offsetNormal: [0, 1],
offsetDistance: 1.05,
})
for (const geometry of renderPlannedConstructionDimensions(planned, 'metric')) {
expect(geometry.kind).toBe('dimension-string')
if (geometry.kind !== 'dimension-string') continue
for (const segment of geometry.segments) {
const dimensionStart = segment.dimensionStart ?? [
segment.start[0] + geometry.offsetNormal[0] * geometry.offsetDistance,
segment.start[1] + geometry.offsetNormal[1] * geometry.offsetDistance,
]
const clearance =
(dimensionStart[0] - segment.start[0]) * geometry.offsetNormal[0] +
(dimensionStart[1] - segment.start[1]) * geometry.offsetNormal[1]
expect(clearance).toBeCloseTo(geometry.offsetDistance)
}
}
})
test('dimensions perimeter door widths on the room side in every wall orientation', () => {
const top = wall({ id: 'wall_top', end: [6, 0] })
const right = wall({
id: 'wall_right',
start: [6, 0],
end: [6, -6],
frontSide: 'exterior',
backSide: 'interior',
})
const bottom = wall({
id: 'wall_bottom',
start: [6, -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 cases = [
{ wall: top, normal: [0, -1] as const, width: 1.2 },
{ wall: right, normal: [-1, 0] as const, width: 1.3 },
{ wall: bottom, normal: [0, 1] as const, width: 1.4 },
{ wall: left, normal: [1, 0] as const, width: 1.5 },
]
const doors = cases.map(({ wall: host, width }, index) =>
DoorNode.parse({
id: `door_${index}`,
parentId: host.id,
position: [3, 1.05, 0],
width,
}),
)
const plan = buildLevelWallConstructionDimensionPlan(
[top, right, bottom, left],
Object.fromEntries(doors.map((door) => [door.id, door])),
)
for (const { wall: host, normal, width } of cases) {
const roomSideDimensions = (plan.get(host.id) ?? []).filter(
(entry) =>
(entry.tier === 'interior' || entry.tier === 'interior-overall') &&
entry.offsetNormal[0] * normal[0] + entry.offsetNormal[1] * normal[1] > 0.99,
)
expect(roomSideDimensions.length).toBeGreaterThan(0)
expect(
dimensionTexts(renderPlannedConstructionDimensions(roomSideDimensions, 'metric')),
).toContain(`${width}m`)
}
})
test('keeps room-side door and window dimensions when the opposite boundary is curved', () => {
const top = wall({ id: 'wall_top', end: [6, 0] })
const right = wall({
id: 'wall_right',
start: [6, 0],
end: [6, -8],
frontSide: 'exterior',
backSide: 'interior',
})
const bottom = wall({
id: 'wall_bottom',
start: [6, -8],
end: [0, -8],
frontSide: 'exterior',
backSide: 'interior',
})
const curved = wall({
id: 'wall_curved',
start: [0, -8],
end: [0, 0],
curveOffset: 2,
frontSide: 'exterior',
backSide: 'interior',
})
const door = DoorNode.parse({
id: 'door_right',
parentId: right.id,
position: [2, 1.05, 0],
width: 1.2,
})
const window = WindowNode.parse({
id: 'window_right',
parentId: right.id,
position: [6, 1.2, 0],
width: 1.5,
})
const straight = WallNode.parse({ ...curved, id: 'wall_straight', curveOffset: 0 })
const reverseCurve = WallNode.parse({ ...curved, id: 'wall_reverse_curve', curveOffset: -2 })
const roomSideTexts = (oppositeBoundary: WallNode) => {
const planned =
buildLevelWallConstructionDimensionPlan([top, right, bottom, oppositeBoundary], {
[door.id]: door,
[window.id]: window,
}).get(right.id) ?? []
const roomSideDimensions = planned.filter(
(entry) =>
(entry.tier === 'interior' || entry.tier === 'interior-overall') &&
entry.offsetNormal[0] < -0.99,
)
return dimensionTexts(renderPlannedConstructionDimensions(roomSideDimensions, 'metric'))
}
expect(roomSideTexts(straight)).toEqual(expect.arrayContaining(['1.2m', '1.5m']))
expect(roomSideTexts(curved)).toEqual(expect.arrayContaining(['1.2m', '1.5m']))
expect(roomSideTexts(reverseCurve)).toEqual(expect.arrayContaining(['1.2m', '1.5m']))
})
test('starts and ends an interior opening chain at the adjacent wall faces', () => {
const partition = wall({
id: 'wall_partition_clear_span',
start: [0, 4],
end: [10, 4],
frontSide: 'interior',
backSide: 'interior',
})
const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] })
const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] })
const leftBoundary = wall({
id: 'wall_left',
start: [0, 10],
end: [0, 0],
thickness: 0.4,
})
const rightBoundary = wall({
id: 'wall_right',
start: [10, 0],
end: [10, 10],
thickness: 0.2,
})
const door = DoorNode.parse({
id: 'door_clear_span',
parentId: partition.id,
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_clear_span',
parentId: partition.id,
position: [6, 1.5, 0],
width: 2,
})
const planned =
buildLevelWallConstructionDimensionPlan(
[partition, lowerBoundary, upperBoundary, leftBoundary, rightBoundary],
{ [door.id]: door, [window.id]: window },
).get(partition.id) ?? []
expect(planned[0]).toMatchObject({ start: [0.2, 4.1] })
expect(planned.at(-1)).toMatchObject({
start: [0.2, 4.1],
end: [9.9, 4.1],
})
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'1.3m',
'1m',
'2.5m',
'2m',
'2.9m',
'9.7m',
])
})
test('dimensions hosted openings on a bounded partition with incomplete side metadata', () => {
const partition = wall({
id: 'wall_unclassified_partition',
start: [0, 4],
end: [10, 4],
frontSide: 'unknown',
backSide: 'unknown',
})
const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] })
const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] })
const door = DoorNode.parse({
id: 'door_unclassified_partition',
wallId: partition.id,
parentId: partition.id,
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_unclassified_partition',
parentId: partition.id,
position: [6, 1.5, 0],
width: 2,
})
const planned =
buildLevelWallConstructionDimensionPlan([partition, lowerBoundary, upperBoundary], {
[door.id]: door,
[window.id]: window,
}).get(partition.id) ?? []
expect(planned.map((entry) => entry.tier)).toEqual([
'interior',
'interior',
'interior',
'interior',
'interior',
'interior-overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'1.5m',
'1m',
'2.5m',
'2m',
'3m',
'10m',
])
})
test('dimensions hosted openings on a bounded partition with stale exterior metadata', () => {
const top = wall({ id: 'wall_top' })
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_stale_partition',
start: [0, -3],
end: [10, -3],
frontSide: 'interior',
backSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_stale_partition',
parentId: partition.id,
position: [2, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_stale_partition',
parentId: partition.id,
position: [6, 1.5, 0],
width: 2,
})
const planned =
buildLevelWallConstructionDimensionPlan([top, right, bottom, left, partition], {
[door.id]: door,
[window.id]: window,
}).get(partition.id) ?? []
expect(planned.map((entry) => entry.tier)).toEqual([
'interior',
'interior',
'interior',
'interior',
'interior',
'interior-overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'1.4m',
'1m',
'2.5m',
'2m',
'2.9m',
'9.8m',
])
})
test('does not treat an unbounded unknown wall with an opening as an interior partition', () => {
const unknownWall = wall({
id: 'wall_unbounded_unknown',
frontSide: 'unknown',
backSide: 'unknown',
})
const door = DoorNode.parse({
id: 'door_unbounded_unknown',
parentId: unknownWall.id,
position: [2, 1.05, 0],
width: 1,
})
const plan = buildLevelWallConstructionDimensionPlan([unknownWall], { [door.id]: door })
expect(plan.get(unknownWall.id)).toBeUndefined()
})
test('locates partitions from a consistent face of stud', () => {
const exterior = wall()
const thinPartition = wall({
id: 'wall_partition_thin',
start: [3, 0],
end: [3, -3],
thickness: 0.1,
frontSide: 'interior',
backSide: 'interior',
})
const thickPartition = wall({
id: 'wall_partition_thick',
start: [7, 0],
end: [7, -3],
thickness: 0.4,
frontSide: 'interior',
backSide: 'interior',
})
const planned =
buildLevelWallConstructionDimensionPlan([exterior, thinPartition, thickPartition], {}).get(
exterior.id,
) ?? []
expect(
dimensionTexts(
renderPlannedConstructionDimensions(
planned.filter((entry) => entry.tier === 'partitions'),
'metric',
),
),
).toEqual(['2.95m', '3.85m', '3.2m'])
})
test('chains stepped facade projections on one exterior baseline', () => {
const lower = wall({ id: 'wall_lower', end: [4, 0] })
const step = wall({
id: 'wall_step',
start: [4, 0],
end: [4, 1],
frontSide: 'interior',
backSide: 'exterior',
})
const upper = wall({ id: 'wall_upper', start: [4, 1], end: [10, 1] })
const planned =
buildLevelWallConstructionDimensionPlan([lower, step, upper], {}).get(lower.id) ?? []
const rendered = renderPlannedConstructionDimensions(planned, 'metric')
expect(planned.map((entry) => entry.tier)).toEqual(['jogs', 'jogs', 'overall'])
expect(dimensionTexts(rendered)).toEqual(['4m', '6m', '10m'])
const jogs = planned.filter((entry) => entry.tier === 'jogs')
expect(jogs).toEqual([
expect.objectContaining({
start: [0, 0.1],
end: [4, 1.1],
}),
expect.objectContaining({
start: [4, 1.1],
end: [10, 1.1],
}),
])
expect(jogs[0]?.dimensionStart?.[1]).toBeCloseTo(1.65)
expect(jogs[0]?.dimensionEnd?.[1]).toBeCloseTo(1.65)
expect(jogs[1]?.dimensionStart?.[1]).toBeCloseTo(1.65)
expect(jogs[1]?.dimensionEnd?.[1]).toBeCloseTo(1.65)
})
test('dimensions an exterior column row by structural centerline', () => {
const top = wall({ id: 'wall_top' })
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 columns = [-1, 5, 11].map((x, index) =>
ColumnNode.parse({
id: `column_${index}`,
parentId: 'level_main',
position: [x, 0, 2],
crossSection: 'square',
}),
)
const nodes = Object.fromEntries(columns.map((column) => [column.id, column]))
const plan = buildLevelWallConstructionDimensionPlan([top, right, bottom, left], nodes)
const planned = plan.get(top.id) ?? []
expect(plan.size).toBe(4)
expect(planned.map((entry) => entry.tier)).toEqual([
'structure',
'structure',
'overall',
'structural-overall',
])
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([
'6m',
'6m',
'10.2m',
'12m',
])
expect(planned[0]).toMatchObject({
start: [-1, 2],
end: [5, 2],
})
expect(planned[0]?.dimensionStart?.[0]).toBe(-1)
expect(planned[0]?.dimensionEnd?.[0]).toBe(5)
expect(planned[0]?.dimensionStart?.[1]).toBeCloseTo(2.8712)
expect(planned[0]?.dimensionEnd?.[1]).toBeCloseTo(2.8712)
})
test('does not stretch interior column references to an exterior dimension string', () => {
const top = wall({ id: 'wall_top' })
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 columns = [2, 8].map((x, index) =>
ColumnNode.parse({
id: `column_interior_${index}`,
parentId: 'level_main',
position: [x, 0, -2],
crossSection: 'square',
}),
)
const nodes = Object.fromEntries(columns.map((column) => [column.id, column]))
const planned =
buildLevelWallConstructionDimensionPlan([top, right, bottom, left], nodes).get(top.id) ?? []
expect(planned.map((entry) => entry.tier)).toEqual(['overall'])
})
test('keeps internal openings off exterior strings and dimensions them locally', () => {
const top = wall({ id: 'wall_top' })
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 interiorDoorWall = wall({
id: 'wall_interior_door',
start: [4, -2],
end: [10, -2],
frontSide: 'interior',
backSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_interior',
parentId: interiorDoorWall.id,
position: [3, 1.05, 0],
width: 1,
})
const window = WindowNode.parse({
id: 'window_interior',
parentId: interiorDoorWall.id,
position: [1.5, 1.2, 0],
width: 1,
})
const plan = buildLevelWallConstructionDimensionPlan(
[top, right, bottom, left, interiorDoorWall],
{ [door.id]: door, [window.id]: window },
)
const bottomDimensions = plan.get(bottom.id) ?? []
const interiorDimensions = plan.get(interiorDoorWall.id) ?? []
expect(bottomDimensions.map((entry) => entry.tier)).toEqual(['overall'])
expect(interiorDimensions.map((entry) => entry.tier)).toEqual([
'interior',
'interior',
'interior',
'interior',
'interior',
'interior-overall',
])
expect(
dimensionTexts(renderPlannedConstructionDimensions(interiorDimensions, 'metric')),
).toEqual(['1m', '1m', '0.5m', '1m', '2.4m', '5.9m'])
})
test('keeps disconnected collinear facade runs independent', () => {
const first = wall({ id: 'wall_a', end: [4, 0] })
const second = wall({ id: 'wall_b', start: [8, 0], end: [12, 0] })
const firstPartition = wall({
id: 'wall_partition_a',
start: [2, 0],
end: [2, -2],
frontSide: 'interior',
backSide: 'interior',
})
const secondPartition = wall({
id: 'wall_partition_b',
start: [10, 0],
end: [10, -2],
frontSide: 'interior',
backSide: 'interior',
})
const plan = buildLevelWallConstructionDimensionPlan(
[first, second, firstPartition, secondPartition],
{},
)
expect([...plan.keys()]).toEqual([first.id, second.id, firstPartition.id, secondPartition.id])
expect(plan.get(first.id)?.find((entry) => entry.tier === 'overall')).toMatchObject({
start: [0, 0.1],
end: [4, 0.1],
})
expect(plan.get(second.id)?.find((entry) => entry.tier === 'overall')).toMatchObject({
start: [8, 0.1],
end: [12, 0.1],
})
})
test('places a back-side exterior facade beyond the back face', () => {
const exterior = wall({ frontSide: 'interior', backSide: 'exterior' })
const planned = buildLevelWallConstructionDimensionPlan([exterior], {}).get(exterior.id)
const overall = planned?.find((entry) => entry.tier === 'overall')
expect(overall).toMatchObject({
start: [10, -0.1],
end: [0, -0.1],
offsetNormal: [0, -1],
dimensionStart: [10, -0.65],
dimensionEnd: [0, -0.65],
})
expect(overall?.offsetDistance).toBeCloseTo(0.55)
})
test('keeps angled exterior dimensions aligned and reports their true length', () => {
const angled = wall({ end: [3, 4] })
const planned = buildLevelWallConstructionDimensionPlan([angled], {}).get(angled.id) ?? []
expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'imperial'))).toEqual([
`16'-4 7/8"`,
])
expect(planned[0]).toMatchObject({
tier: 'overall',
offsetNormal: [-0.8, 0.6],
})
expect(
Math.hypot(
planned[0]!.dimensionEnd![0] - planned[0]!.dimensionStart![0],
planned[0]!.dimensionEnd![1] - planned[0]!.dimensionStart![1],
),
).toBeCloseTo(5)
})
test('dimensions every exterior side and interior run in a subdivided rectangular plan', () => {
const topLeft = wall({ id: 'wall_top_left', end: [2.5, 0] })
const topRight = wall({ id: 'wall_top_right', start: [2.5, 0], end: [6, 0] })
const rightTop = wall({ id: 'wall_right_top', start: [6, 0], end: [6, -1.5] })
const rightBottom = wall({ id: 'wall_right_bottom', start: [6, -1.5], end: [6, -3] })
const bottomRight = wall({ id: 'wall_bottom_right', start: [6, -3], end: [4, -3] })
const bottomMiddle = wall({ id: 'wall_bottom_middle', start: [4, -3], end: [2.5, -3] })
const bottomLeft = wall({ id: 'wall_bottom_left', start: [2.5, -3], end: [0, -3] })
const leftBottom = wall({ id: 'wall_left_bottom', start: [0, -3], end: [0, -1.5] })
const leftTop = wall({ id: 'wall_left_top', start: [0, -1.5], end: [0, 0] })
const interior = (id: string, start: [number, number], end: [number, number]) =>
wall({ id, start, end })
const middleLeft = interior('wall_middle_left', [0, -1.5], [2.5, -1.5])
const middleCenter = interior('wall_middle_center', [2.5, -1.5], [4, -1.5])
const middleRight = interior('wall_middle_right', [4, -1.5], [6, -1.5])
const centerTop = interior('wall_center_top', [2.5, 0], [2.5, -1.5])
const centerBottom = interior('wall_center_bottom', [2.5, -1.5], [2.5, -3])
const lowerRight = interior('wall_lower_right', [4, -1.5], [4, -3])
const walls = [
topLeft,
topRight,
rightTop,
rightBottom,
bottomRight,
bottomMiddle,
bottomLeft,
leftBottom,
leftTop,
middleLeft,
middleCenter,
middleRight,
centerTop,
centerBottom,
lowerRight,
]
const plan = buildLevelWallConstructionDimensionPlan(walls, {})
const exteriorFacades = [
[topLeft.id, topRight.id],
[rightTop.id, rightBottom.id],
[bottomRight.id, bottomMiddle.id, bottomLeft.id],
[leftBottom.id, leftTop.id],
]
expect(
exteriorFacades.map((ids) =>
ids.some((id) => plan.get(id)?.some((entry) => entry.tier === 'partitions')),
),
).toEqual([true, true, true, true])
expect(
[middleLeft, middleCenter, middleRight, centerTop, centerBottom, lowerRight].map((entry) =>
plan.get(entry.id)?.some((dimension) => dimension.tier === 'interior-overall'),
),
).toEqual([true, true, true, true, true, true])
})
test('does not automatically dimension walls without a side classification', () => {
const plan = buildLevelWallConstructionDimensionPlan(
[wall({ frontSide: 'unknown', backSide: 'unknown' })],
{},
)
expect(plan.size).toBe(0)
})
})