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:
co-authored by
Claude Opus 4.6
parent
2adb50a340
commit
77442861d9
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)} m²`
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user