editor: complete floorplan construction documentation (#531)

* Add roof surface placement support for items

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

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

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

* fixed conflict

* feat(floorplan): add construction dimension strings

* feat(floorplan): coordinate opening dimensions

* feat(floorplan): add opening documentation

* feat(floorplan): add construction dimensions and notes

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

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

* feat(floorplan): harden construction document output

* feat(floorplan): add annotation collision diagnostics

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

* feat(floorplan): automatically separate overlapping labels

* fix(floorplan): resolve dense label overlaps

* fix(floorplan): remove stale collision warning overlays

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

* feat(floorplan): place short dimension values outside

* fix(floorplan): preserve dimension string order

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

* feat(floorplan): add dimension side fallback leaders

* docs(floorplan): update chapter 17 implementation status

* fix(floorplan): dimension subdivided interior walls

* feat: add associative floor plan dimensions

* feat: add continuous construction dimension strings

* feat: add structural floor plan grids

* feat: coordinate columns with structural grids

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

* feat: add architectural room documentation

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

* feat: generate architectural room schedules

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

* feat: add reliable room clear dimensions

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

* feat: add architectural stair documentation

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

* feat: add typed specialty construction notes

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

* feat: add curved and circular dimensions

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

* feat: coordinate floor plan drawing types

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

* feat: add associative curved wall dimensions

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

* fix: render automatic curved wall dimensions

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

* fix: use radius callout for curved walls

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

* Implement construction dimension string editing

* Add construction dimension standards controls

* Apply drawing standards to automatic dimensions

* Add floorplan overhead and reference visibility controls

* Add view-specific dimension segment suppression

* Add persistent drawing sheet model

* Plot floorplan exports at fixed scale

* Apply paper-space annotation profiles

* Compose floorplan PDF sheets

* Support sheet paper sizes and preflight

* Persist pinned annotation layout overrides

* Expand annotation collision obstacles

* Add floorplan annotation preflight surface

* Add reusable drawing sheet general notes

* Add drawing sheet keyed note instances

* Add drawing sheet document markers

* Expand construction note leader terminators

* Add wall assembly layer model

* Resolve wall assembly datum references

* Add wall assembly floorplan graphics

* Add opening documentation dimension policies

* Add finish-face room clear dimensions

* Extend room clear dimensions to rectilinear rooms

* Add construction module advisories

* Add clearance advisory profiles

* Add dimension completeness audit

* Expand dimension completeness audit

* Include preflight issues in completeness audit

* feat: complete floorplan construction documentation

* refactor: remove construction note node

* feat: refine floorplan documentation and unit display

* fix(editor): improve floorplan PDF dimensions

* fix(floorplan): refresh annotation collision layout

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

* feat(floorplan): refine construction dimension references

* fix(floorplan): align documentation tools with architecture

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-22 14:02:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2adb50a340
commit 77442861d9
181 changed files with 25002 additions and 800 deletions
+9
View File
@@ -9,10 +9,12 @@ import type {
CeilingNode,
ChimneyNode,
ColumnNode,
ConstructionDimensionNode,
CupolaNode,
DoorNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctFittingNode,
DuctSegmentNode,
DuctTerminalNode,
@@ -42,6 +44,7 @@ import type {
SpawnNode,
StairNode,
StairSegmentNode,
StructuralGridNode,
TurbineVentNode,
WallNode,
WindowNode,
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type ColumnEvent = NodeEvent<ColumnNode>
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
export type SkylightEvent = NodeEvent<SkylightNode>
export type DormerEvent = NodeEvent<DormerNode>
export type DownspoutEvent = NodeEvent<DownspoutNode>
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> &
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'structural-grid', StructuralGridEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
NodeEvents<'skylight', SkylightEvent> &
NodeEvents<'dormer', DormerEvent> &
NodeEvents<'downspout', DownspoutEvent> &
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
NodeEvents<'duct-segment', DuctSegmentEvent> &
NodeEvents<'duct-fitting', DuctFittingEvent> &
NodeEvents<'duct-terminal', DuctTerminalEvent> &
+6
View File
@@ -9,8 +9,10 @@ export type {
CeilingEvent,
ChimneyEvent,
ColumnEvent,
ConstructionDimensionEvent,
DoorEvent,
DormerEvent,
DrawingSheetEvent,
ElevatorEvent,
EventSuffix,
FenceEvent,
@@ -34,6 +36,7 @@ export type {
SpawnEvent,
StairEvent,
StairSegmentEvent,
StructuralGridEvent,
WallEvent,
WindowEvent,
ZoneEvent,
@@ -88,6 +91,7 @@ export {
closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -98,6 +102,7 @@ export {
measurementPerimeter,
measurementPrismVolume,
measurementReferenceNodeIds,
remapMeasurementAnchors,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export {
@@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallArcData,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement'
import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
import {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -12,6 +13,7 @@ import {
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
remapMeasurementAnchors,
} from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
})
test('remaps and collects references for arbitrary anchor strings', () => {
const anchors: MeasurementAnchor[] = [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [4, 0, 0],
},
]
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
const remapped = remapMeasurementAnchors(
anchors,
new Map([
['wall_a', 'wall_a_copy'],
['wall_b', 'wall_b_copy'],
]),
)
const first = remapped[0]!
const last = remapped[2]!
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
})
})
+40 -10
View File
@@ -1,4 +1,5 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
import type {
MeasurementAnchor,
MeasurementPayload,
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>,
): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
remapMeasurementAnchors([anchor], idMap)[0]!
switch (measurement.kind) {
case 'distance':
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
}
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
export function remapMeasurementAnchors(
anchors: readonly MeasurementAnchor[],
idMap: ReadonlyMap<string, string>,
): MeasurementAnchor[] {
return anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
})
}
export function remapConstructionDimensionReferences(
dimension: ConstructionDimensionNode,
idMap: ReadonlyMap<string, string>,
): ConstructionDimensionNode {
const controllingDimensionId = dimension.controllingDimensionId
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
dimension.controllingDimensionId)
: null
return {
...dimension,
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
controllingDimensionId,
}
}
export function measurementAnchorReferenceNodeIds(
anchors: readonly MeasurementAnchor[],
): AnyNodeId[] {
const ids = new Set<string>()
for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
return [...ids] as AnyNodeId[]
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
return measurementAnchorReferenceNodeIds(anchors)
}
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0]
+1 -1
View File
@@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport(
zone: ZoneNode,
sceneNodes: Record<string, AnyNode>,
sceneNodes: Readonly<Record<string, AnyNode>>,
): ZoneQuantityReport {
const levelId = zone.parentId
const levelNodes = levelId
+3
View File
@@ -65,6 +65,8 @@ export type {
Capabilities,
CapabilityCtx,
CuttableConfig,
DimensionTerminator,
DimensionTextPosition,
DistributionRole,
DragAction,
DuplicableConfig,
@@ -88,6 +90,7 @@ export type {
FloorplanPoint,
FloorplanStyle,
GeometryContext,
GroupMoveSnapArgs,
HostableConfig,
IconRef,
Issue,
+110 -1
View File
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
) {
const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
parentId: 'level_1',
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[1, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
fallback: [2, 0, 0],
},
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const result = cloneNodesInto([wall, dimension], {
rootId: 'wall_1' as AnyNodeId,
})
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
expect(clonedDimension?.type).toBe('construction-dimension')
if (clonedDimension?.type === 'construction-dimension') {
const anchor = clonedDimension.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
const lastAnchor = clonedDimension.anchors[2]
expect(Array.isArray(lastAnchor)).toBe(false)
if (lastAnchor && !Array.isArray(lastAnchor)) {
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps a construction dimension foundation controller when both are cloned', () => {
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
drawingType: 'foundation-plan',
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
controllingDimensionId: controller.id,
})
const result = cloneNodesInto([controller, dependent], {
rootId: controller.id as AnyNodeId,
})
const clonedDependent = result.nodes.find(
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
)
expect(clonedDependent?.type).toBe('construction-dimension')
if (clonedDependent?.type === 'construction-dimension') {
expect(clonedDependent.controllingDimensionId).toBe(
result.idMap.get(
controller.id as AnyNodeId,
) as typeof clonedDependent.controllingDimensionId,
)
}
})
test('regenerates drawing-sheet identities while preserving external level references', () => {
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
const cloned = nodes[0]
expect(cloned?.type).toBe('drawing-sheet')
if (cloned?.type === 'drawing-sheet') {
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
+12 -2
View File
@@ -1,5 +1,9 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import { generateId } from '../schema/base'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement
@@ -141,7 +145,7 @@ export function cloneNodesInto(
const out: AnyNode[] = []
let root: AnyNode | null = null
for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved
@@ -169,6 +173,12 @@ export function cloneNodesInto(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
if (original.id === opts.rootId) {
if (opts.position) {
+62 -1
View File
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs.
*/
materials?: Record<SceneMaterialId, SceneMaterial>
/** Opaque host/plugin context. Core never interprets extension values. */
extensions?: Readonly<Record<string, unknown>>
/**
* Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
export type FloorplanPoint = readonly [x: number, y: number]
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
export type DimensionTextPosition = 'above' | 'centered'
export type FloorplanStyle = {
stroke?: string
fill?: string
strokeWidth?: number
strokeDasharray?: string
opacity?: number
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
/**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
* of the floor-plan's scene rotation (default 90°).
*/
upright?: boolean
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number }
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Hatched fill overlay — same polygon shape as the kind's main fill but
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
kind: 'dimension'
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
/** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number
/** How far past the offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position relative to the baseline. Defaults above the line. */
textPosition?: DimensionTextPosition
text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
}
| {
kind: 'dimension-string'
segments: readonly {
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}[]
/** Outward-pointing unit normal shared by every segment in the string. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from each measured origin to its dimension line. */
offsetDistance: number
/** How far past each offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position shared by every segment. Defaults above the line. */
textPosition?: DimensionTextPosition
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
// ─── FloorplanAffordance ─────────────────────────────────────────────
//
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schemaVersion: number
schema: S
category: NodeCategory
/** Opaque host/plugin contributions. Core stores but never interprets them. */
extensions?: Readonly<Record<string, unknown>>
surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities
relations?: Relations
+64 -2
View File
@@ -51,8 +51,33 @@ export {
ColumnStyle,
ColumnSupportStyle,
} from './nodes/column'
export {
CONSTRUCTION_DRAWING_TYPES,
ConstructionDimensionBaseline,
ConstructionDimensionChainMode,
ConstructionDimensionDatumPolicy,
ConstructionDimensionDrawingOverride,
ConstructionDimensionDrawingPresentation,
ConstructionDimensionImperialPrecision,
ConstructionDimensionMetricNotation,
ConstructionDimensionMode,
ConstructionDimensionNode,
ConstructionDimensionTerminator,
ConstructionDimensionTextPosition,
ConstructionDrawingType,
constructionDimensionRequiredAnchorCount,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './nodes/construction-dimension'
export { CupolaNode } from './nodes/cupola'
export { DoorNode, DoorSegment } from './nodes/door'
export {
DoorNode,
DoorSegment,
OpeningConstructionType,
OpeningDimensionReference,
} from './nodes/door'
export {
DormerNode,
type DormerSurfaceMaterialRole,
@@ -60,6 +85,25 @@ export {
getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer'
export { DownspoutNode } from './nodes/downspout'
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
remapDrawingSheetReferences,
} from './nodes/drawing-sheet'
export { DuctFittingNode } from './nodes/duct-fitting'
export { DuctSegmentNode } from './nodes/duct-segment'
export { DuctTerminalNode } from './nodes/duct-terminal'
@@ -200,9 +244,13 @@ export {
StairType,
} from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent'
export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallBandSurfaceSlotId,
WallFaceBand,
WallFaceBandConfig,
@@ -215,11 +263,18 @@ export {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallBandSlotId,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
getWallFaceBandForHeight,
getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -230,11 +285,18 @@ export {
WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode,
WallTreatmentSide,
WallTrimProfile,
} from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window'
export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
WindowType,
} from './nodes/window'
export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types'
+5 -2
View File
@@ -1,13 +1,16 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { DrawingSheetNode } from './drawing-sheet'
import { ElevatorNode } from './elevator'
import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({
id: objectId('building'),
type: nodeType('building'),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
children: z
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
.default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe(
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes and building-level systems such as elevators
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
`,
)
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
ConstructionDimensionNode,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './construction-dimension'
describe('ConstructionDimensionNode', () => {
test('creates valid free-anchor defaults', () => {
const node = ConstructionDimensionNode.parse({})
expect(node.type).toBe('construction-dimension')
expect(node.id).toMatch(/^construction-dimension_/)
expect(node.anchors).toEqual([
[0, 0, 0],
[1, 0, 0],
])
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
expect(node.chainMode).toBe('point-to-point')
expect(node).toMatchObject({
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
})
})
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[3, 0, 0],
],
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({
baseline: { origin: [0, 0], direction: [0, 0] },
}).success,
).toBe(false)
})
test('accepts continuous strings with three or more anchors', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
chainMode: 'continuous',
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
.success,
).toBe(false)
})
test('accepts curved and circular notation settings', () => {
expect(
ConstructionDimensionNode.safeParse({
mode: 'diameter',
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
}).success,
).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
})
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
const node = ConstructionDimensionNode.parse({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(node).toMatchObject({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
})
test('coordinates one associative dimension across persistent drawing types', () => {
const node = ConstructionDimensionNode.parse({
drawingType: 'foundation-plan',
drawingOverrides: [
{ drawingType: 'floor-plan', presentation: 'controlled' },
{ drawingType: 'roof-plan', presentation: 'shown' },
],
controllingDimensionId: 'construction-dimension_foundation',
})
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
})
test('stores only drawing presentations that differ from the primary defaults', () => {
const node = ConstructionDimensionNode.parse({})
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
expect(shown).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
])
expect(
setConstructionDimensionDrawingPresentation(
{ ...node, drawingOverrides: shown },
'roof-plan',
'omit',
),
).toEqual([])
})
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
const node = ConstructionDimensionNode.parse({})
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
node,
'floor-plan',
[3, 1, 1, -1],
)
expect(drawingOverrides).toEqual([
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1, 3],
},
])
expect(
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
?.suppressedSegmentIndexes,
).toEqual([1, 3])
expect(
setConstructionDimensionDrawingSuppressedSegments(
{ ...node, drawingOverrides },
'floor-plan',
[],
),
).toEqual([])
})
})
@@ -0,0 +1,205 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MeasurementAnchor } from './measurement'
const FiniteCoordinate = z.number().finite()
export const ConstructionDimensionBaseline = z
.object({
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
})
.superRefine((baseline, ctx) => {
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['direction'],
message: 'Construction dimension baseline direction must be non-zero',
})
}
})
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
export const ConstructionDimensionMode = z.enum([
'linear',
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
])
export const ConstructionDrawingType = z.enum([
'floor-plan',
'foundation-plan',
'reflected-ceiling-plan',
'roof-plan',
'site-plan',
])
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
export const ConstructionDimensionDrawingOverride = z.object({
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
})
export const ConstructionDimensionDatumPolicy = z.enum([
'centerline',
'wall-face',
'structural-face',
'finish-face',
])
export const ConstructionDimensionTerminator = z.enum([
'architectural-tick',
'filled-arrow',
'open-arrow',
'dot',
])
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
export const ConstructionDimensionNode = BaseNode.extend({
id: objectId('construction-dimension'),
type: nodeType('construction-dimension'),
anchors: z
.array(MeasurementAnchor)
.min(2)
.default([
[0, 0, 0],
[1, 0, 0],
]),
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
mode: ConstructionDimensionMode.default('linear'),
featureCount: z.number().int().min(1).max(999).default(1),
showCenterMark: z.boolean().default(true),
prefix: z.string().max(40).default(''),
suffix: z.string().max(40).default(''),
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
textPosition: ConstructionDimensionTextPosition.default('above'),
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
}).describe(
dedent`
Construction dimension node - an associative floor-plan construction dimension
- anchors: two or more free or semantic feature anchors that supply the witness origins
- baseline.origin: a point on the independently placed dimension line
- baseline.direction: the fixed plan direction used to project the witness origins
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
- showCenterMark: displays the resolved circle/angle center where applicable
- prefix/suffix/textOverride: document notation overrides without changing geometry
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
- drawingType: the primary persistent drawing that owns the dimension
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
`,
)
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
export type ConstructionDimensionDrawingPresentation = z.infer<
typeof ConstructionDimensionDrawingPresentation
>
export type ConstructionDimensionDrawingOverride = z.infer<
typeof ConstructionDimensionDrawingOverride
>
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
export type ConstructionDimensionImperialPrecision = z.infer<
typeof ConstructionDimensionImperialPrecision
>
export type ConstructionDimensionMetricNotation = z.infer<
typeof ConstructionDimensionMetricNotation
>
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
export function resolveConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingPresentation {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
}
export function resolveConstructionDimensionDrawingOverride(
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingOverride | null {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override ?? null
}
export function setConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = {
drawingType,
presentation,
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
}
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function setConstructionDimensionDrawingSuppressedSegments(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
suppressedSegmentIndexes: readonly number[],
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const presentation = existing?.presentation ?? defaultPresentation
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
}
function isDefaultConstructionDimensionDrawingOverride(
override: ConstructionDimensionDrawingOverride,
defaultPresentation: ConstructionDimensionDrawingPresentation,
): boolean {
return (
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
)
}
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
(left, right) => left - right,
)
}
+23
View File
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment>
export const DoorCategory = z.enum(['interior', 'garage'])
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
export const OpeningDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export const DoorType = z.enum([
'hinged',
'double',
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
export type DoorCategory = z.infer<typeof DoorCategory>
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9),
height: z.number().default(2.1),
// Construction-document identity. `mark` overrides the deterministic
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
// because they are manufacturer/framing inputs, not safe derivations from
// the nominal modeled size.
mark: z.string().trim().max(16).optional(),
constructionType: OpeningConstructionType.default('framed'),
dimensionReference: OpeningDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Door family
doorCategory: DoorCategory.default('interior'),
doorType: DoorType.default('hinged'),
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode } from './building'
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
describe('DrawingSheetNode', () => {
test('creates persistent sheet defaults', () => {
const sheet = DrawingSheetNode.parse({})
expect(sheet.type).toBe('drawing-sheet')
expect(sheet.id).toMatch(/^drawing-sheet_/)
expect(sheet).toMatchObject({
sheetNumber: 'A1.0',
sheetTitle: 'Floor Plan',
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
annotationProfile: 'architectural-default',
placedViews: [],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [],
keyedNoteInstances: [],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
titleBlock: {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
},
})
})
test('stores placed views, notes, schedules, and title-block metadata', () => {
const sheet = DrawingSheetNode.parse({
sheetNumber: 'A2.1',
sheetTitle: 'Enlarged Plans',
paperSize: 'custom',
customPaperWidth: 24,
customPaperHeight: 36,
placedViews: [
{
id: 'drawing-view_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
levelId: 'level_main',
scale: '1/4"=1\'-0"',
viewport: { x: 1, y: 1, width: 12, height: 8 },
},
],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-slab-1',
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
},
{
id: 'keyed-note-instance_patch-slab-2',
definitionId: 'keyed-note_patch-slab',
position: [5, 4],
},
],
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
],
schedules: [
{
id: 'sheet-schedule_room',
scheduleType: 'room',
title: 'Room Schedule',
region: { x: 15, y: 1, width: 6, height: 5 },
},
],
titleBlock: {
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
},
})
expect(sheet.placedViews[0]).toMatchObject({
drawingType: 'floor-plan',
levelId: 'level_main',
annotationProfile: 'architectural-default',
showNorthArrow: true,
showGraphicScale: true,
})
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
expect(sheet.generalNoteSets[0]).toMatchObject({
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ text: 'COORDINATE WITH OWNER.' }],
})
expect(sheet.keyedNoteLegend[0]).toEqual({
key: 'A',
text: 'ALIGN WITH EXISTING WALL.',
})
expect(sheet.keyedNoteDefinitions[0]).toEqual({
id: 'keyed-note_patch-slab',
key: 'A',
text: 'PATCH EXISTING SLAB.',
})
expect(sheet.keyedNoteInstances).toHaveLength(2)
expect(sheet.keyedNoteInstances[0]).toMatchObject({
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
})
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
expect(sheet.documentMarkers).toHaveLength(2)
expect(sheet.documentMarkers[0]).toMatchObject({
kind: 'wall-tag',
label: 'W1',
position: [2, 3],
})
expect(sheet.documentMarkers[1]).toMatchObject({
kind: 'revision-cloud',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
})
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
expect(sheet.titleBlock).toMatchObject({
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
drawnBy: '',
})
})
test('can live under a building instead of a level', () => {
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
'level_main',
sheet.id,
])
})
test('remaps sheet-local identities and their references together', () => {
const sheet = DrawingSheetNode.parse({
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a1',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
},
],
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
schedules: [{ id: 'sheet-schedule_a' }],
})
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
sheet.generalNoteSets[0]?.notes[0]?.id,
)
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
})
})
@@ -0,0 +1,263 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, generateId, nodeType, objectId } from '../base'
import { ConstructionDrawingType } from './construction-dimension'
const PositiveFinite = z.number().finite().positive()
const SheetCoordinate = z.number().finite().min(0)
export const DrawingSheetPaperSize = z.enum([
'letter',
'tabloid',
'arch-a',
'arch-b',
'arch-c',
'a4',
'a3',
'custom',
])
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
export const DrawingSheetScale = z.enum([
'1:20',
'1:25',
'1:50',
'1:75',
'1:100',
'1/8"=1\'-0"',
'1/4"=1\'-0"',
'1/2"=1\'-0"',
'1"=1\'-0"',
])
export const DrawingSheetAnnotationProfile = z.enum([
'architectural-default',
'presentation',
'permit',
])
export const DrawingSheetRect = z.object({
x: SheetCoordinate.default(0),
y: SheetCoordinate.default(0),
width: PositiveFinite.default(1),
height: PositiveFinite.default(1),
})
export const DrawingSheetPlacedView = z.object({
id: objectId('drawing-view'),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingNumber: z.string().trim().min(1).max(24).default('1'),
title: z.string().trim().min(1).max(80).default('Floor Plan'),
levelId: objectId('level').nullable().default(null),
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
showNorthArrow: z.boolean().default(true),
showGraphicScale: z.boolean().default(true),
})
export const DrawingSheetGeneralNote = z.object({
id: objectId('sheet-note'),
number: z.number().int().positive().default(1),
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
})
export const DrawingSheetGeneralNoteSet = z.object({
id: objectId('sheet-note-set'),
name: z.string().trim().min(1).max(80).default('General Notes'),
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
})
export const DrawingSheetKeyedNote = z.object({
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteDefinition = z.object({
id: objectId('keyed-note'),
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteInstance = z.object({
id: objectId('keyed-note-instance'),
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
})
export const DrawingSheetDocumentMarkerKind = z.enum([
'wall-tag',
'glazing-tag',
'assembly-tag',
'section-callout',
'elevation-callout',
'detail-reference',
'delta-marker',
'revision-cloud',
])
export const DrawingSheetDocumentMarker = z.object({
id: objectId('sheet-marker'),
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
label: z.string().trim().min(1).max(32).default('1'),
title: z.string().trim().max(120).default(''),
sheetReference: z.string().trim().max(24).default(''),
drawingReference: z.string().trim().max(24).default(''),
revisionId: z.string().trim().max(16).default(''),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
points: z
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
.max(64)
.default([]),
})
export const DrawingSheetSchedulePlacement = z.object({
id: objectId('sheet-schedule'),
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
title: z.string().trim().min(1).max(80).default('Room Schedule'),
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
})
export const DrawingSheetTitleBlock = z.object({
projectName: z.string().trim().max(120).default(''),
projectNumber: z.string().trim().max(40).default(''),
clientName: z.string().trim().max(120).default(''),
drawnBy: z.string().trim().max(40).default(''),
checkedBy: z.string().trim().max(40).default(''),
issueDate: z.string().trim().max(40).default(''),
revision: z.string().trim().max(20).default(''),
})
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
}
export const DrawingSheetNode = BaseNode.extend({
id: objectId('drawing-sheet'),
type: nodeType('drawing-sheet'),
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
paperSize: DrawingSheetPaperSize.default('arch-b'),
orientation: DrawingSheetOrientation.default('landscape'),
customPaperWidth: PositiveFinite.nullable().default(null),
customPaperHeight: PositiveFinite.nullable().default(null),
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
}).describe(
dedent`
Drawing sheet node - persistent construction-document sheet metadata
- sheetNumber/sheetTitle: sheet identity in the drawing set
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
- schedules/titleBlock: sheet-level documentation content and title-block metadata
`,
)
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
/**
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
* External scene references are preserved when they are not present in
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
*/
export function remapDrawingSheetReferences(
sheet: DrawingSheetNode,
sceneIdMap: ReadonlyMap<string, string>,
): DrawingSheetNode {
const placedViewIds = new Map(
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
)
const noteSetIds = new Map(
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
)
const noteIds = new Map(
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
(note) => [note.id, generateId('sheet-note')] as const,
),
)
const keyedDefinitionIds = new Map(
sheet.keyedNoteDefinitions.map(
(definition) => [definition.id, generateId('keyed-note')] as const,
),
)
return {
...sheet,
placedViews: sheet.placedViews.map((view) => ({
...view,
id: placedViewIds.get(view.id)!,
levelId: view.levelId
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
: null,
})),
generalNoteSetIds: sheet.generalNoteSetIds.map(
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
),
generalNoteSets: sheet.generalNoteSets.map((set) => ({
...set,
id: noteSetIds.get(set.id)!,
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
})),
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
...definition,
id: keyedDefinitionIds.get(definition.id)!,
})),
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
...instance,
id: generateId('keyed-note-instance'),
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
instance.definitionId) as typeof instance.definitionId,
placedViewId: instance.placedViewId
? ((placedViewIds.get(instance.placedViewId) ??
instance.placedViewId) as typeof instance.placedViewId)
: null,
})),
documentMarkers: sheet.documentMarkers.map((marker) => ({
...marker,
id: generateId('sheet-marker'),
placedViewId: marker.placedViewId
? ((placedViewIds.get(marker.placedViewId) ??
marker.placedViewId) as typeof marker.placedViewId)
: null,
})),
schedules: sheet.schedules.map((schedule) => ({
...schedule,
id: generateId('sheet-schedule'),
})),
}
}
+4
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { ColumnNode } from './column'
import { ConstructionDimensionNode } from './construction-dimension'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import { ShelfNode } from './shelf'
import { SlabNode } from './slab'
import { SpawnNode } from './spawn'
import { StairNode } from './stair'
import { StructuralGridNode } from './structural-grid'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({
WallNode.shape.id,
FenceNode.shape.id,
ColumnNode.shape.id,
ConstructionDimensionNode.shape.id,
StructuralGridNode.shape.id,
ItemNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { LevelNode } from './level'
import { StructuralGridNode } from './structural-grid'
describe('StructuralGridNode', () => {
test('fills stable construction-document defaults', () => {
const grid = StructuralGridNode.parse({})
expect(grid.id).toStartWith('structural-grid_')
expect(grid).toMatchObject({
type: 'structural-grid',
start: [0, 0],
end: [0, 5],
label: '1',
showStartBubble: true,
showEndBubble: true,
})
})
test('is accepted as a level child', () => {
expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([
'structural-grid_axis-1',
])
})
test('rejects empty labels and zero-length concerns stay in authoring', () => {
expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow()
expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({
start: [1, 1],
end: [1, 1],
})
})
})
@@ -0,0 +1,22 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const StructuralGridNode = BaseNode.extend({
id: objectId('structural-grid'),
type: nodeType('structural-grid'),
start: z.tuple([z.number(), z.number()]).default([0, 0]),
end: z.tuple([z.number(), z.number()]).default([0, 5]),
label: z.string().trim().min(1).max(12).default('1'),
showStartBubble: z.boolean().default(true),
showEndBubble: z.boolean().default(true),
}).describe(
dedent`
Structural grid node - a persistent floor-plan datum axis with identification bubbles
- start/end: level-local plan coordinates defining the grid axis extent
- label: axis identifier, commonly numeric in one direction and alphabetic in the other
- showStartBubble/showEndBubble: independently control the two endpoint identifiers
`,
)
export type StructuralGridNode = z.infer<typeof StructuralGridNode>
+216 -6
View File
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
import {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -13,7 +19,8 @@ import {
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig,
type WallNode,
WallNode,
type WallNode as WallNodeType,
WallTrimConfig,
} from './wall'
@@ -99,7 +106,7 @@ describe('wall face bands', () => {
lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({
enabled: true,
@@ -135,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -159,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
@@ -187,7 +194,7 @@ describe('wall face bands', () => {
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -219,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
4,
)
@@ -260,3 +267,206 @@ describe('wall trim profiles', () => {
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
})
})
describe('wall assembly layers', () => {
test('defaults to legacy thickness when no assembly layers are modeled', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(wall.assemblyLayers).toEqual([])
expect(getWallAssemblyThickness(wall)).toBe(0.14)
})
test('stores role, side, thickness, material reference, and datum eligibility', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face', 'finish-face'],
},
],
})
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
'interior-gwb',
'brick-veneer',
])
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
{ id: 'stud-core', role: 'structure', side: 'core' },
])
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
interior: -0.061,
exterior: 0.135,
})
})
test('resolves stable datum references for legacy single-thickness walls', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
{
id: 'wall:structural-face:interior',
datum: 'structural-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:structural-face:exterior',
datum: 'structural-face',
side: 'exterior',
offset: 0.07,
},
{
id: 'wall:finish-face:interior',
datum: 'finish-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:finish-face:exterior',
datum: 'finish-face',
side: 'exterior',
offset: 0.07,
},
])
})
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-sheathing',
role: 'exterior-sheathing',
side: 'exterior',
thickness: 0.012,
materialRef: 'library:sheathing',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const references = resolveWallAssemblyDatumReferences(wall)
expect(references).toContainEqual({
id: 'wall:centerline:center',
datum: 'centerline',
side: 'center',
offset: 0,
})
expect(references).toContainEqual({
id: 'wall:structural-face:interior:stud-core',
datum: 'structural-face',
side: 'interior',
layerId: 'stud-core',
offset: -0.045,
})
expect(references).toContainEqual({
id: 'wall:structural-face:exterior:stud-core',
datum: 'structural-face',
side: 'exterior',
layerId: 'stud-core',
offset: 0.045,
})
expect(references).toContainEqual({
id: 'wall:finish-face:interior:interior-gwb',
datum: 'finish-face',
side: 'interior',
layerId: 'interior-gwb',
offset: -0.061,
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
),
).toMatchObject({
datum: 'finish-face',
side: 'exterior',
layerId: 'exterior-sheathing',
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
)?.offset,
).toBeCloseTo(0.057)
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
})
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
?.offset,
).toBeCloseTo(0.147)
expect(
resolveWallAssemblyDatumReference(
wall,
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
offset: 0.147,
})
})
})
+260
View File
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallAssemblyLayerRole = z.enum([
'structure',
'interior-finish',
'exterior-sheathing',
'exterior-finish',
'masonry-veneer',
'air-space',
'concrete-block',
'structural-masonry',
'solid-concrete',
'furring',
])
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
export const WallDimensionDatum = z.enum([
'centerline',
'structural-face',
'finish-face',
'veneer-face',
])
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
export const WallAssemblyLayer = z.object({
id: z.string().trim().min(1).max(80).default('structure'),
role: WallAssemblyLayerRole.default('structure'),
side: z.enum(['core', 'interior', 'exterior']).default('core'),
thickness: z.number().finite().positive().default(0.1),
materialRef: z.string().trim().max(120).default(''),
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
})
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
export type WallAssemblyDatumReference = {
id: string
datum: WallDimensionDatum
side: WallAssemblyDatumSide
layerId?: string
offset: number
}
export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
@@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({
// in a follow-up once migrated scenes are the norm.
slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(),
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
height: z.number().optional(),
curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
@@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({
dedent`
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
- height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system
@@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId =
| 'upperExterior'
| 'topExterior'
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
return wall.assemblyLayers ?? []
}
export function getWallAssemblyThickness(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): number {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return wall.thickness ?? 0.1
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
}
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
interior: number
exterior: number
} {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return { interior: -halfThickness, exterior: halfThickness }
}
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const interiorFinishThickness = layers
.filter((layer) => layer.side === 'interior')
.reduce((sum, layer) => sum + layer.thickness, 0)
const exteriorFinishThickness = layers
.filter((layer) => layer.side === 'exterior')
.reduce((sum, layer) => sum + layer.thickness, 0)
return {
interior: -coreThickness / 2 - interiorFinishThickness,
exterior: coreThickness / 2 + exteriorFinishThickness,
}
}
export function getWallDatumEligibleLayers(
wall: Pick<WallNode, 'assemblyLayers'>,
datum: WallDimensionDatum,
): WallAssemblyLayer[] {
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
}
export function getWallAssemblyDatumReferenceId(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
layerId?: string,
): string {
return ['wall', datum, side, layerId].filter(Boolean).join(':')
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function getWallAssemblyLayerSpans(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyLayerSpan[] {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const coreInteriorFace = -coreThickness / 2
const coreExteriorFace = coreThickness / 2
const spans: WallAssemblyLayerSpan[] = []
let coreOffset = coreInteriorFace
for (const layer of coreLayers) {
const interiorOffset = coreOffset
const exteriorOffset = coreOffset + layer.thickness
spans.push({ layer, interiorOffset, exteriorOffset })
coreOffset = exteriorOffset
}
let interiorOffset = coreInteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
const exteriorOffset = interiorOffset
const nextInteriorOffset = exteriorOffset - layer.thickness
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
interiorOffset = nextInteriorOffset
}
let exteriorOffset = coreExteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
const interiorFaceOffset = exteriorOffset
const nextExteriorOffset = interiorFaceOffset + layer.thickness
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
exteriorOffset = nextExteriorOffset
}
return spans
}
function createWallAssemblyDatumReference(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
offset: number,
layerId?: string,
): WallAssemblyDatumReference {
return {
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
datum,
side,
...(layerId ? { layerId } : {}),
offset,
}
}
export function resolveWallAssemblyDatumReferences(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyDatumReference[] {
const layers = wall.assemblyLayers ?? []
const references: WallAssemblyDatumReference[] = [
createWallAssemblyDatumReference('centerline', 'center', 0),
]
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return [
...references,
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
]
}
const spans = getWallAssemblyLayerSpans(wall)
for (const span of spans) {
if (span.layer.datumEligible.includes('structural-face')) {
if (span.layer.side === 'core') {
references.push(
createWallAssemblyDatumReference(
'structural-face',
'interior',
span.interiorOffset,
span.layer.id,
),
createWallAssemblyDatumReference(
'structural-face',
'exterior',
span.exteriorOffset,
span.layer.id,
),
)
} else {
const side = span.layer.side
references.push(
createWallAssemblyDatumReference(
'structural-face',
side,
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
span.layer.id,
),
)
}
}
if (span.layer.datumEligible.includes('finish-face')) {
const side = span.layer.side === 'core' ? 'center' : span.layer.side
const offset =
span.layer.side === 'interior'
? span.interiorOffset
: span.layer.side === 'exterior'
? span.exteriorOffset
: (span.interiorOffset + span.exteriorOffset) / 2
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
}
if (span.layer.datumEligible.includes('veneer-face')) {
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
}
}
if (!references.some((reference) => reference.datum === 'structural-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
)
}
if (!references.some((reference) => reference.datum === 'finish-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
)
}
return references
}
export function resolveWallAssemblyDatumReference(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
referenceId: string,
): WallAssemblyDatumReference | null {
return (
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
null
)
}
// Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
// slot declaration (nodes) and the material resolver (viewer) share one value.
+22
View File
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
])
export type WindowType = z.infer<typeof WindowType>
export const WindowConstructionType = z.enum(['framed', 'masonry'])
export const WindowDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
export const WindowNode = BaseNode.extend({
id: objectId('window'),
type: nodeType('window'),
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5),
height: z.number().default(1.5),
// Construction-document identity and optional manufacturer rough opening.
// Legacy scenes omit these fields and continue to parse unchanged.
mark: z.string().trim().max(16).optional(),
constructionType: WindowConstructionType.default('framed'),
dimensionReference: WindowDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'),
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { ZoneNode } from './zone'
describe('ZoneNode architectural room data', () => {
test('keeps legacy zones generic while supplying room-safe defaults', () => {
const zone = ZoneNode.parse({
id: 'zone_legacy',
name: 'Landscape area',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
})
expect(zone).toMatchObject({
spaceRole: 'generic',
roomNumber: '',
enclosureStatus: 'auto',
floorFinish: '',
wallFinish: '',
ceilingFinish: '',
ceilingHeight: 2.7,
occupancy: '',
clearDimensionPolicy: 'none',
})
})
test('persists a complete architectural room profile', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
spaceRole: 'room',
roomNumber: '101',
enclosureStatus: 'enclosed',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 3,
occupancy: 'Business',
clearDimensionPolicy: 'inside-faces',
})
expect(room.spaceRole).toBe('room')
expect(room.roomNumber).toBe('101')
expect(room.clearDimensionPolicy).toBe('inside-faces')
})
})
+15
View File
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
// stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]),
// Generic zones remain available for sites and analysis. Architectural
// room documentation is opt-in so legacy zone behavior is unchanged.
spaceRole: z.enum(['generic', 'room']).default('generic'),
roomNumber: z.string().trim().max(32).default(''),
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
floorFinish: z.string().trim().max(120).default(''),
wallFinish: z.string().trim().max(120).default(''),
ceilingFinish: z.string().trim().max(120).default(''),
ceilingHeight: z.number().min(0.1).default(2.7),
occupancy: z.string().trim().max(80).default(''),
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
- polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure
- spaceRole: generic site/analysis zone or architectural room
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
- enclosureStatus: auto-detected, explicitly enclosed, or open
- clearDimensionPolicy: optional room clear-dimension datum preference
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
+6
View File
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column'
import { ConstructionDimensionNode } from './nodes/construction-dimension'
import { CupolaNode } from './nodes/cupola'
import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer'
import { DownspoutNode } from './nodes/downspout'
import { DrawingSheetNode } from './nodes/drawing-sheet'
import { DuctFittingNode } from './nodes/duct-fitting'
import { DuctSegmentNode } from './nodes/duct-segment'
import { DuctTerminalNode } from './nodes/duct-terminal'
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { StructuralGridNode } from './nodes/structural-grid'
import { TurbineVentNode } from './nodes/turbine-vent'
import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ElevatorNode,
LevelNode,
ColumnNode,
ConstructionDimensionNode,
StructuralGridNode,
WallNode,
FenceNode,
CabinetNode,
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctSegmentNode,
DuctFittingNode,
DuctTerminalNode,
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene construction-dimension migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('normalizes the legacy reference presentation before parsing', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['construction-dimension_test'],
level: 0,
},
'construction-dimension_test': {
object: 'node',
id: 'construction-dimension_test',
type: 'construction-dimension',
parentId: 'level_test',
visible: true,
metadata: {},
reference: true,
referenceStyle: 'suffix',
drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }],
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
Record<string, unknown>
expect(dimension.reference).toBeUndefined()
expect(dimension.referenceStyle).toBeUndefined()
expect(dimension.drawingOverrides).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown' },
])
})
})
+25
View File
@@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next
}
function migrateConstructionDimension(node: Record<string, any>) {
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
const hasLegacyDrawingOverride = drawingOverrides.some(
(entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
entry.presentation === 'reference',
)
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
return {
...dimension,
drawingOverrides: drawingOverrides.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
}),
}
}
// Walls whose top lands within this of the storey plane become plane-bound;
// ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both.
@@ -687,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
if (node.type === 'construction-dimension') {
patchedNodes[id] = migrateConstructionDimension(node)
}
if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {
+1 -1
View File
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
}
}
function getWallArcData(wall: WallCurveLike) {
export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall)
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true,
parentId: 'level_test',
children: [],
assemblyLayers: [],
start,
end,
thickness: 0.1,
@@ -77,6 +77,119 @@ describe('forkSceneGraph', () => {
})
})
describe('construction-dimension clone references', () => {
function sceneWithControlledDimensions(): SceneGraph {
const site = makeNode('site_1', 'site', { children: ['level_1'] })
const level = makeNode('level_1', 'level', {
parentId: 'site_1',
children: ['construction-dimension_foundation', 'construction-dimension_floor'],
})
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
name: 'Foundation controller',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: null,
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
name: 'Floor dependent',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: controller.id,
})
return {
nodes: {
[site.id]: site,
[level.id]: level,
[controller.id]: controller,
[dependent.id]: dependent,
},
rootNodeIds: [site.id],
}
}
test('remaps controller IDs in whole-scene clones', () => {
const cloned = cloneSceneGraph(sceneWithControlledDimensions())
const dimensions = Object.values(cloned.nodes).filter(
(node) => node.type === 'construction-dimension',
)
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
test('remaps controller IDs in level-subtree clones', () => {
const scene = sceneWithControlledDimensions()
const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
})
describe('drawing-sheet clone references', () => {
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
const level = makeNode('level_main', 'level')
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const cloned = cloneSceneGraph({
nodes: { [level.id]: level, [sheet.id]: sheet },
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
})
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
expect(clonedLevel).toBeDefined()
expect(clonedSheet?.type).toBe('drawing-sheet')
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
clonedSheet.keyedNoteDefinitions[0]?.id,
)
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
}
})
})
describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
+19 -3
View File
@@ -1,8 +1,12 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode>
@@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
if (clonedNode.type === 'construction-dimension') {
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
}
if (clonedNode.type === 'drawing-sheet') {
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
}
clonedNodes[newId] = clonedNode
}
@@ -221,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
@@ -274,6 +284,12 @@ export function cloneLevelSubtree(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
clonedNodes.push(cloned)
}