editor: complete floorplan construction documentation (#531)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(floorplan): add construction dimension strings * feat(floorplan): coordinate opening dimensions * feat(floorplan): add opening documentation * feat(floorplan): add construction dimensions and notes * feat(floorplan): add interior dimensions and curved note leaders * feat(floorplan): improve construction dimensions and document plan * feat(floorplan): harden construction document output * feat(floorplan): add annotation collision diagnostics * feat(floorplan): size export annotations in paper space * feat(floorplan): automatically separate overlapping labels * fix(floorplan): resolve dense label overlaps * fix(floorplan): remove stale collision warning overlays * fix(floorplan): treat mark pills as collision obstacles * feat(floorplan): place short dimension values outside * fix(floorplan): preserve dimension string order * fix(floorplan): avoid architectural geometry in label layout * feat(floorplan): add dimension side fallback leaders * docs(floorplan): update chapter 17 implementation status * fix(floorplan): dimension subdivided interior walls * feat: add associative floor plan dimensions * feat: add continuous construction dimension strings * feat: add structural floor plan grids * feat: coordinate columns with structural grids Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer. * feat: add architectural room documentation Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior. * feat: generate architectural room schedules Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings. * feat: add reliable room clear dimensions Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums. * feat: add architectural stair documentation Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility. * feat: add typed specialty construction notes Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls. * feat: add curved and circular dimensions Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls. * feat: coordinate floor plan drawing types Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output. * feat: add associative curved wall dimensions Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes. * fix: render automatic curved wall dimensions The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility. * fix: use radius callout for curved walls Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth. * Implement construction dimension string editing * Add construction dimension standards controls * Apply drawing standards to automatic dimensions * Add floorplan overhead and reference visibility controls * Add view-specific dimension segment suppression * Add persistent drawing sheet model * Plot floorplan exports at fixed scale * Apply paper-space annotation profiles * Compose floorplan PDF sheets * Support sheet paper sizes and preflight * Persist pinned annotation layout overrides * Expand annotation collision obstacles * Add floorplan annotation preflight surface * Add reusable drawing sheet general notes * Add drawing sheet keyed note instances * Add drawing sheet document markers * Expand construction note leader terminators * Add wall assembly layer model * Resolve wall assembly datum references * Add wall assembly floorplan graphics * Add opening documentation dimension policies * Add finish-face room clear dimensions * Extend room clear dimensions to rectilinear rooms * Add construction module advisories * Add clearance advisory profiles * Add dimension completeness audit * Expand dimension completeness audit * Include preflight issues in completeness audit * feat: complete floorplan construction documentation * refactor: remove construction note node * feat: refine floorplan documentation and unit display * fix(editor): improve floorplan PDF dimensions * fix(floorplan): refresh annotation collision layout * fix(floorplan): keep annotations clear and restore registry boundaries * feat(floorplan): refine construction dimension references * fix(floorplan): align documentation tools with architecture --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2adb50a340
commit
77442861d9
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildingDefinition } from './definition'
|
||||
|
||||
describe('buildingDefinition', () => {
|
||||
test('tracks drawing-sheet child support in the schema version', () => {
|
||||
expect(buildingDefinition.kind).toBe('building')
|
||||
expect(buildingDefinition.schemaVersion).toBe(2)
|
||||
expect(
|
||||
buildingDefinition.schema.safeParse({
|
||||
id: 'building_default',
|
||||
type: 'building',
|
||||
...buildingDefinition.defaults(),
|
||||
children: ['level_main', 'drawing-sheet_a101'],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
|
||||
*/
|
||||
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
kind: 'building',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: BuildingNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
type GroupMoveSnapArgs,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
|
||||
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
|
||||
import { columnFloorplanMoveTarget } from './floorplan-move'
|
||||
import { columnPaint } from './paint'
|
||||
@@ -295,6 +300,18 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
|
||||
return handles
|
||||
}
|
||||
|
||||
function resolveColumnStructuralGridMoveSnap({
|
||||
candidatePosition,
|
||||
nodes,
|
||||
levelId,
|
||||
}: GroupMoveSnapArgs): [number, number, number] | null {
|
||||
const snap = resolveStructuralGridSnap(
|
||||
[candidatePosition[0], candidatePosition[2]],
|
||||
collectStructuralGridAxes(nodes, levelId),
|
||||
)
|
||||
return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Column — Stage A registration. Wrap-export of the legacy
|
||||
* `ColumnRenderer` (no system — column geometry is computed inline in
|
||||
@@ -334,7 +351,11 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
|
||||
// mode-driven snapping the overhaul standardised). 2D move keeps using
|
||||
// `floorplanMoveTarget`, which wins the 2D move dispatch.
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
movable: {
|
||||
axes: ['x', 'z'],
|
||||
gridSnap: true,
|
||||
groupMoveSnap: resolveColumnStructuralGridMoveSnap,
|
||||
},
|
||||
slots: (node) => columnSlots(node as ColumnNodeType),
|
||||
paint: columnPaint,
|
||||
// Slab elevation lift via the generic `<FloorElevationSystem>` + the
|
||||
@@ -374,6 +395,8 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
{ key: 'Left click', label: 'Place column' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
computeFloorplanLevelData: computeColumnFloorplanLevelData,
|
||||
floorplanDependsOnSiblings: true,
|
||||
floorplan: buildColumnFloorplan,
|
||||
// 2D body move routes through this kind-specific target so the column
|
||||
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for column. Columns need the same footprint-edge
|
||||
@@ -65,10 +69,15 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
||||
candidates,
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(snapped, collectStructuralGridAxes(nodes, node.parentId))
|
||||
: null
|
||||
const coordinated = structuralSnap?.point ?? snapped
|
||||
const next: [number, number, number] = [coordinated[0], originalPosition[1], coordinated[1]]
|
||||
lastPosition = next
|
||||
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
const snapKey = `${coordinated[0]},${coordinated[1]}`
|
||||
if (snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { ColumnNode, type GeometryContext, StructuralGridNode } from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
|
||||
|
||||
const context = {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
} satisfies GeometryContext
|
||||
|
||||
describe('buildColumnFloorplan', () => {
|
||||
test('marks the structural center of the column footprint', () => {
|
||||
const column = ColumnNode.parse({
|
||||
id: 'column_main',
|
||||
parentId: 'level_main',
|
||||
position: [2, 0, 3],
|
||||
crossSection: 'square',
|
||||
width: 0.4,
|
||||
depth: 0.4,
|
||||
})
|
||||
|
||||
const geometry = buildColumnFloorplan(column, context)
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
|
||||
expect(geometry.children[0]?.kind).toBe('polygon')
|
||||
expect(readFloorplanGeometryMetadata(geometry.children[0]!)).toMatchObject({
|
||||
annotationObstacle: 'bounds',
|
||||
})
|
||||
|
||||
expect(geometry.children.filter((child) => child.kind === 'line')).toEqual([
|
||||
expect.objectContaining({
|
||||
x1: 1.91,
|
||||
y1: 2.91,
|
||||
x2: 2.09,
|
||||
y2: 3.09,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
x1: 1.91,
|
||||
y1: 3.09,
|
||||
x2: 2.09,
|
||||
y2: 2.91,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
])
|
||||
expect(
|
||||
geometry.children
|
||||
.filter((child) => child.kind === 'line')
|
||||
.every((child) => readFloorplanGeometryMetadata(child).annotationRole === 'column-center'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('labels a column with its associative structural-grid reference', () => {
|
||||
const column = ColumnNode.parse({
|
||||
id: 'column_main',
|
||||
parentId: 'level_main',
|
||||
position: [2, 0, 3],
|
||||
crossSection: 'square',
|
||||
width: 0.4,
|
||||
depth: 0.4,
|
||||
})
|
||||
const vertical = StructuralGridNode.parse({
|
||||
id: 'structural-grid_2',
|
||||
parentId: 'level_main',
|
||||
start: [2, 0],
|
||||
end: [2, 6],
|
||||
label: '2',
|
||||
})
|
||||
const horizontal = StructuralGridNode.parse({
|
||||
id: 'structural-grid_b',
|
||||
parentId: 'level_main',
|
||||
start: [0, 3],
|
||||
end: [6, 3],
|
||||
label: 'B',
|
||||
})
|
||||
const levelData = computeColumnFloorplanLevelData({
|
||||
siblings: [column],
|
||||
nodes: {
|
||||
[column.id]: column,
|
||||
[vertical.id]: vertical,
|
||||
[horizontal.id]: horizontal,
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildColumnFloorplan(column, { ...context, levelData })
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
|
||||
const label = geometry.children.find((child) => child.kind === 'text' && child.text === 'B-2')
|
||||
expect(label).toMatchObject({ kind: 'text', text: 'B-2', upright: true })
|
||||
expect(label && readFloorplanGeometryMetadata(label).annotationRole).toBe('column-center')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,16 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
ColumnNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
StructuralGridNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridReference,
|
||||
} from '../structural-grid/coordination'
|
||||
import type { ColumnResizePayload } from './floorplan-affordances'
|
||||
|
||||
// Offsets for the floor-plan selection arrows. Resize chevrons hug the
|
||||
@@ -11,6 +18,8 @@ import type { ColumnResizePayload } from './floorplan-affordances'
|
||||
// further out so it doesn't crowd the resize arrows.
|
||||
const RESIZE_ARROW_OFFSET = 0.12
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
const GRID_REFERENCE_OFFSET = 0.16
|
||||
const GRID_REFERENCE_FONT_SIZE = 0.13
|
||||
|
||||
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
||||
'round',
|
||||
@@ -18,6 +27,22 @@ const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
||||
'sixteen-sided',
|
||||
])
|
||||
|
||||
export type ColumnFloorplanLevelData = {
|
||||
structuralGrids: StructuralGridNode[]
|
||||
}
|
||||
|
||||
export function computeColumnFloorplanLevelData({
|
||||
siblings,
|
||||
nodes,
|
||||
}: {
|
||||
siblings: readonly ColumnNode[]
|
||||
nodes: Record<string, AnyNode>
|
||||
}): ColumnFloorplanLevelData {
|
||||
return {
|
||||
structuralGrids: collectStructuralGridAxes(nodes, siblings[0]?.parentId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for column. Inlined from the legacy
|
||||
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
|
||||
@@ -34,8 +59,8 @@ export function buildColumnFloorplan(
|
||||
node: ColumnNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = getColumnPlanFootprint(node)
|
||||
if (polygon.length < 3) return null
|
||||
const points = getColumnFloorplanFootprint(node)
|
||||
if (points.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
@@ -46,8 +71,6 @@ export function buildColumnFloorplan(
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
|
||||
|
||||
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
@@ -56,8 +79,60 @@ export function buildColumnFloorplan(
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
|
||||
},
|
||||
]
|
||||
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
||||
const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45))
|
||||
const centerX = node.position[0]
|
||||
const centerZ = node.position[2]
|
||||
children.push(
|
||||
{
|
||||
kind: 'line',
|
||||
x1: centerX - centerMarkHalf,
|
||||
y1: centerZ - centerMarkHalf,
|
||||
x2: centerX + centerMarkHalf,
|
||||
y2: centerZ + centerMarkHalf,
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: centerX - centerMarkHalf,
|
||||
y1: centerZ + centerMarkHalf,
|
||||
x2: centerX + centerMarkHalf,
|
||||
y2: centerZ - centerMarkHalf,
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
},
|
||||
)
|
||||
|
||||
const levelData = ctx.levelData as ColumnFloorplanLevelData | undefined
|
||||
const gridReference = resolveStructuralGridReference(
|
||||
[centerX, centerZ],
|
||||
levelData?.structuralGrids ?? [],
|
||||
)
|
||||
if (gridReference) {
|
||||
children.push({
|
||||
kind: 'text',
|
||||
x: centerX,
|
||||
y: centerZ + halfZ + GRID_REFERENCE_OFFSET,
|
||||
text: gridReference,
|
||||
fontSize: GRID_REFERENCE_FONT_SIZE,
|
||||
fill: stroke,
|
||||
fontWeight: 700,
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'middle',
|
||||
upright: true,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
})
|
||||
}
|
||||
|
||||
// Hatch overlay on selected — same `<defs>` pattern as the wall.
|
||||
if (isSelected && palette) {
|
||||
@@ -146,7 +221,6 @@ export function buildColumnFloorplan(
|
||||
// Rotate-arrow at the +X / +Z corner — matches the 3D
|
||||
// `columnRotateHandle` corner placement so users see the rotation
|
||||
// affordance in the same quadrant across views.
|
||||
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
||||
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
|
||||
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
|
||||
@@ -163,6 +237,10 @@ export function buildColumnFloorplan(
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] {
|
||||
return getColumnPlanFootprint(node).map((point) => [point.x, point.y])
|
||||
}
|
||||
|
||||
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
|
||||
|
||||
type PlanPoint = { x: number; y: number }
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
stopPlacementCommitPropagation,
|
||||
subscribeFloorPlacementClicks,
|
||||
} from '../shared/floor-placement'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
import { ColumnPreview } from './renderer'
|
||||
|
||||
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||
@@ -87,7 +91,7 @@ const ColumnTool = () => {
|
||||
setCursorVisible(true)
|
||||
}
|
||||
|
||||
const { position, guides } = resolveAlignedFloorPlacement({
|
||||
const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({
|
||||
node: previewNode,
|
||||
rawX: event.localPosition[0],
|
||||
rawZ: event.localPosition[2],
|
||||
@@ -97,7 +101,18 @@ const ColumnTool = () => {
|
||||
applyAlignmentSnap: isMagneticSnapActive(),
|
||||
bypassGrid: !isGridSnapActive(),
|
||||
})
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(
|
||||
[alignedPosition[0], alignedPosition[2]],
|
||||
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
|
||||
)
|
||||
: null
|
||||
const position: [number, number, number] = structuralSnap
|
||||
? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]]
|
||||
: alignedPosition
|
||||
if (structuralSnap) useAlignmentGuides.getState().clear()
|
||||
else useAlignmentGuides.getState().set(guides)
|
||||
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
@@ -134,7 +149,7 @@ const ColumnTool = () => {
|
||||
}
|
||||
|
||||
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
|
||||
const position =
|
||||
const fallbackPosition =
|
||||
lastCursorRef.current ??
|
||||
getLevelLocalSnappedPosition(
|
||||
activeLevelId,
|
||||
@@ -142,6 +157,16 @@ const ColumnTool = () => {
|
||||
useEditor.getState().gridSnapStep,
|
||||
!isGridSnapActive(),
|
||||
)
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(
|
||||
[fallbackPosition[0], fallbackPosition[2]],
|
||||
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
|
||||
)
|
||||
: null
|
||||
const position: [number, number, number] = structuralSnap
|
||||
? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]]
|
||||
: fallbackPosition
|
||||
|
||||
const column = ColumnNode.parse({
|
||||
...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { constructionDimensionDefinition } from './definition'
|
||||
|
||||
describe('constructionDimensionDefinition', () => {
|
||||
test('registers a selectable floor-plan construction annotation', () => {
|
||||
expect(constructionDimensionDefinition.kind).toBe('construction-dimension')
|
||||
expect(constructionDimensionDefinition.category).toBe('analysis')
|
||||
expect(constructionDimensionDefinition.bake).toBe('strip')
|
||||
expect(constructionDimensionDefinition.schemaVersion).toBe(7)
|
||||
expect(constructionDimensionDefinition.dirtyTracking).toBe(false)
|
||||
expect(constructionDimensionDefinition.capabilities).toMatchObject({
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
|
||||
'move-construction-dimension-baseline',
|
||||
)
|
||||
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
|
||||
'move-construction-dimension-witness',
|
||||
)
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
constructionDimensionDefinition.schema.safeParse({
|
||||
id: 'construction-dimension_default',
|
||||
type: 'construction-dimension',
|
||||
...constructionDimensionDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { measurementAnchorReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
|
||||
import { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
import {
|
||||
moveConstructionDimensionBaselineAffordance,
|
||||
moveConstructionDimensionWitnessAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { constructionDimensionParametrics } from './parametrics'
|
||||
import { ConstructionDimensionNode } from './schema'
|
||||
|
||||
export const constructionDimensionDefinition: NodeDefinition<typeof ConstructionDimensionNode> = {
|
||||
kind: 'construction-dimension',
|
||||
bake: 'strip',
|
||||
schemaVersion: 7,
|
||||
schema: ConstructionDimensionNode,
|
||||
category: 'analysis',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
resolveForDrawing: resolveConstructionDimensionForDrawing,
|
||||
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
|
||||
},
|
||||
snapProfile: 'item',
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 0.6], direction: [1, 0] },
|
||||
chainMode: 'point-to-point',
|
||||
mode: 'linear',
|
||||
featureCount: 1,
|
||||
showCenterMark: true,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
textOverride: null,
|
||||
datumPolicy: 'centerline',
|
||||
terminator: 'architectural-tick',
|
||||
textPosition: 'above',
|
||||
imperialPrecision: '1/16',
|
||||
metricNotation: 'meters',
|
||||
extensionStartGap: 0.075,
|
||||
extensionOvershoot: 0.12,
|
||||
drawingType: 'floor-plan',
|
||||
drawingOverrides: [],
|
||||
controllingDimensionId: null,
|
||||
}),
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
parametrics: constructionDimensionParametrics,
|
||||
floorplan: buildConstructionDimensionFloorplan,
|
||||
floorplanDependencies: (node) => [
|
||||
...measurementAnchorReferenceNodeIds(node.anchors),
|
||||
...(node.controllingDimensionId ? [node.controllingDimensionId] : []),
|
||||
],
|
||||
floorplanAffordances: {
|
||||
'move-construction-dimension-baseline': moveConstructionDimensionBaselineAffordance,
|
||||
'move-construction-dimension-witness': moveConstructionDimensionWitnessAffordance,
|
||||
},
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Pick witness point' },
|
||||
{ key: 'Enter', label: 'Finish multi-point witnesses' },
|
||||
{ key: 'Left click', label: 'Place dimension line when needed' },
|
||||
{ key: 'Backspace', label: 'Remove last witness' },
|
||||
{ key: 'Esc', label: 'Step back or cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Construction Dimension',
|
||||
description: 'Associative linear, curved, circular, angular, or coordinate plan dimension.',
|
||||
icon: { kind: 'iconify', name: 'lucide:ruler-dimension-line' },
|
||||
hidden: true,
|
||||
actionMenu: false,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'An associative construction dimension with linear, curved, circular, angular, and coordinate modes, semantic witness anchors, document notation overrides, and coordinated plan-view presentation.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, ConstructionDimensionNode } from '@pascal-app/core'
|
||||
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
|
||||
|
||||
const foundation = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_foundation',
|
||||
drawingType: 'foundation-plan',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[6, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 2], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const resolve = (
|
||||
node: ConstructionDimensionNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
drawingType: 'floor-plan' | 'foundation-plan',
|
||||
) => resolveConstructionDimensionForDrawing({ node, nodes, drawingType })
|
||||
|
||||
describe('resolveConstructionDimensionForDrawing', () => {
|
||||
test('omits a dimension outside its primary drawing by default', () => {
|
||||
expect(resolve(foundation, { [foundation.id]: foundation }, 'floor-plan')).toBeNull()
|
||||
expect(resolve(foundation, { [foundation.id]: foundation }, 'foundation-plan')).toBe(foundation)
|
||||
})
|
||||
|
||||
test('applies view-specific suppressed segments without changing physical anchors', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
],
|
||||
drawingOverrides: [
|
||||
{
|
||||
drawingType: 'floor-plan',
|
||||
presentation: 'shown',
|
||||
suppressedSegmentIndexes: [1],
|
||||
},
|
||||
],
|
||||
})
|
||||
const resolved = resolve(node, { [node.id]: node }, 'floor-plan')
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
id: node.id,
|
||||
anchors: node.anchors,
|
||||
metadata: { suppressedDimensionSegmentIndexes: [1] },
|
||||
})
|
||||
expect(node.metadata).toEqual({})
|
||||
})
|
||||
|
||||
test('derives linked floor-plan geometry from a controlling foundation dimension', () => {
|
||||
const floor = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_floor',
|
||||
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
|
||||
controllingDimensionId: foundation.id,
|
||||
anchors: [
|
||||
[1, 0, 1],
|
||||
[2, 0, 1],
|
||||
],
|
||||
})
|
||||
const nodes = { [floor.id]: floor, [foundation.id]: foundation } as Record<string, AnyNode>
|
||||
const resolved = resolve(floor, nodes, 'floor-plan')
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
id: floor.id,
|
||||
anchors: foundation.anchors,
|
||||
baseline: foundation.baseline,
|
||||
metadata: { drawingCoordinationLocked: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('marks a missing foundation controller as unlinked', () => {
|
||||
const floor = ConstructionDimensionNode.parse({
|
||||
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
|
||||
controllingDimensionId: 'construction-dimension_missing',
|
||||
prefix: 'TYP · ',
|
||||
})
|
||||
expect(resolve(floor, { [floor.id]: floor }, 'floor-plan')).toMatchObject({
|
||||
prefix: 'UNLINKED CONTROL · TYP · ',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type ConstructionDimensionNode,
|
||||
type ConstructionDrawingType,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function resolveConstructionDimensionForDrawing(args: {
|
||||
node: ConstructionDimensionNode
|
||||
nodes: Record<string, AnyNode>
|
||||
drawingType: ConstructionDrawingType
|
||||
}): ConstructionDimensionNode | null {
|
||||
const { node, nodes, drawingType } = args
|
||||
const presentation = resolveConstructionDimensionDrawingPresentation(node, drawingType)
|
||||
if (presentation === 'omit') return null
|
||||
if (presentation === 'shown') return applyDrawingOverride(node, drawingType)
|
||||
|
||||
const controller = node.controllingDimensionId ? nodes[node.controllingDimensionId] : undefined
|
||||
if (
|
||||
controller?.type !== 'construction-dimension' ||
|
||||
controller.id === node.id ||
|
||||
controller.drawingType !== 'foundation-plan'
|
||||
) {
|
||||
return {
|
||||
...node,
|
||||
metadata: lockedMetadata(node),
|
||||
prefix: `UNLINKED CONTROL · ${node.prefix}`,
|
||||
}
|
||||
}
|
||||
|
||||
return resolveControlledDimension(node, controller)
|
||||
}
|
||||
|
||||
function resolveControlledDimension(
|
||||
node: ConstructionDimensionNode,
|
||||
controller: ConstructionDimensionNode,
|
||||
): ConstructionDimensionNode {
|
||||
const overridden = applyDrawingOverride(node, 'floor-plan')
|
||||
return {
|
||||
...overridden,
|
||||
metadata: lockedMetadata(overridden),
|
||||
anchors: controller.anchors,
|
||||
baseline: controller.baseline,
|
||||
chainMode: controller.chainMode,
|
||||
mode: controller.mode,
|
||||
showCenterMark: controller.showCenterMark,
|
||||
}
|
||||
}
|
||||
|
||||
function applyDrawingOverride(
|
||||
node: ConstructionDimensionNode,
|
||||
drawingType: ConstructionDrawingType,
|
||||
): ConstructionDimensionNode {
|
||||
const override = resolveConstructionDimensionDrawingOverride(node, drawingType)
|
||||
if (!override || override.suppressedSegmentIndexes.length === 0) return node
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...(typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata)
|
||||
? node.metadata
|
||||
: {}),
|
||||
suppressedDimensionSegmentIndexes: override.suppressedSegmentIndexes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function lockedMetadata(node: ConstructionDimensionNode): ConstructionDimensionNode['metadata'] {
|
||||
const metadata =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? node.metadata
|
||||
: {}
|
||||
return { ...metadata, drawingCoordinationLocked: true }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { moveConstructionDimensionWitnessAffordance } from './floorplan-affordances'
|
||||
|
||||
type RafFn = (cb: (t: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||
cb: (t: number) => void,
|
||||
) => {
|
||||
cb(0)
|
||||
return 0
|
||||
}) as RafFn
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
|
||||
|
||||
function seedScene() {
|
||||
const levelId = 'level_construction-dimension-affordance' as AnyNodeId
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_dimension-target',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
parentId: levelId,
|
||||
})
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_drag-witness',
|
||||
parentId: levelId,
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:centerline',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [1, 0, 0],
|
||||
},
|
||||
[4, 0, 0],
|
||||
],
|
||||
})
|
||||
const level = {
|
||||
id: levelId,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: '',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
children: [wall.id, dimension.id],
|
||||
} as unknown as AnyNode
|
||||
const nodes = { [levelId]: level, [wall.id]: wall, [dimension.id]: dimension } as Record<
|
||||
AnyNodeId,
|
||||
AnyNode
|
||||
>
|
||||
|
||||
useScene.setState({ nodes: nodes as never })
|
||||
return { dimension, nodes, wall }
|
||||
}
|
||||
|
||||
describe('moveConstructionDimensionWitnessAffordance', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('reassociates a dragged witness to a nearby semantic wall feature', () => {
|
||||
const { dimension, nodes, wall } = seedScene()
|
||||
const session = moveConstructionDimensionWitnessAffordance.start({
|
||||
node: dimension,
|
||||
payload: { witnessIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 0.04], modifiers: MODIFIERS })
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
|
||||
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
|
||||
const anchor = updated.anchors[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (!Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(wall.id)
|
||||
expect(anchor.reference.featureId).toMatch(/^wall:/)
|
||||
expect(anchor.fallback[0]).toBeCloseTo(3)
|
||||
}
|
||||
})
|
||||
|
||||
test('detaches a dragged witness as an explicit free point when Alt bypasses association', () => {
|
||||
const { dimension, nodes } = seedScene()
|
||||
const session = moveConstructionDimensionWitnessAffordance.start({
|
||||
node: dimension,
|
||||
payload: { witnessIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 2], modifiers: { ...MODIFIERS, altKey: true } })
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
|
||||
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
|
||||
expect(updated.anchors[0]).toEqual([3, 0, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
ConstructionDimensionNode,
|
||||
type ConstructionDimensionNode as ConstructionDimensionNodeType,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
resolveSurfacePlanPointSnap,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { matchMeasurementFeatureForNode, resolveMeasurementAnchor } from '../measurement/resolve'
|
||||
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
|
||||
|
||||
function semanticWitnessAnchor(
|
||||
point: MeasurementPoint,
|
||||
wallIds: readonly string[],
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
maxDistance: number,
|
||||
): MeasurementAnchor {
|
||||
const matches = wallIds.flatMap((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return []
|
||||
const match = matchMeasurementFeatureForNode(
|
||||
node,
|
||||
(nodeId) => nodes[nodeId],
|
||||
point,
|
||||
maxDistance,
|
||||
)
|
||||
return match ? [{ match, node }] : []
|
||||
})
|
||||
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
|
||||
if (!closest) return point
|
||||
return {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: closest.node.id,
|
||||
featureId: closest.match.feature.id,
|
||||
parameters: closest.match.parameters,
|
||||
},
|
||||
fallback: closest.match.point,
|
||||
}
|
||||
}
|
||||
|
||||
function withRefreshedFallbacks(
|
||||
node: ConstructionDimensionNodeType,
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
): ConstructionDimensionNodeType['anchors'] {
|
||||
return node.anchors.map((anchor) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const resolved = resolveMeasurementAnchor(anchor, (id) => nodes[id])
|
||||
return { ...anchor, fallback: resolved.point }
|
||||
})
|
||||
}
|
||||
|
||||
export const moveConstructionDimensionWitnessAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
|
||||
{
|
||||
start({ node, nodes, payload }): FloorplanAffordanceSession {
|
||||
const witnessIndex = (payload as { witnessIndex?: unknown }).witnessIndex
|
||||
const originalAnchors = withRefreshedFallbacks(node, nodes)
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
let latest: ConstructionDimensionNodeType['anchors'] | null = null
|
||||
|
||||
if (!Number.isInteger(witnessIndex)) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const forceFree = modifiers.altKey === true
|
||||
const gridStep = !forceFree && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||
const fallbackPoint: [number, number] =
|
||||
gridStep > 0
|
||||
? [
|
||||
Math.round(planPoint[0] / gridStep) * gridStep,
|
||||
Math.round(planPoint[1] / gridStep) * gridStep,
|
||||
]
|
||||
: [planPoint[0], planPoint[1]]
|
||||
const magnetic = !forceFree && isMagneticSnapActive()
|
||||
const snapped = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [planPoint[0], planPoint[1]],
|
||||
fallbackPoint,
|
||||
excludeId: node.id,
|
||||
levelId,
|
||||
movingId: node.id,
|
||||
nodes,
|
||||
magnetic,
|
||||
})
|
||||
const point: MeasurementPoint = [snapped.point[0], 0, snapped.point[1]]
|
||||
const nextAnchor = semanticWitnessAnchor(
|
||||
point,
|
||||
snapped.wallIds,
|
||||
nodes,
|
||||
magnetic ? SEMANTIC_FEATURE_SNAP_DISTANCE : SEMANTIC_FEATURE_BYPASS_DISTANCE,
|
||||
)
|
||||
const anchors = originalAnchors.map((anchor, index) =>
|
||||
index === witnessIndex ? nextAnchor : anchor,
|
||||
)
|
||||
if (!ConstructionDimensionNode.safeParse({ ...node, anchors }).success) {
|
||||
latest = null
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
return
|
||||
}
|
||||
latest = anchors
|
||||
useLiveNodeOverrides.getState().set(node.id, { anchors })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
const anchors = latest
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (anchors) useScene.getState().updateNode(node.id, { anchors })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const moveConstructionDimensionBaselineAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
|
||||
{
|
||||
start({ node }) {
|
||||
let latest: [number, number] | null = null
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint }) {
|
||||
const origin: [number, number] = [planPoint[0], planPoint[1]]
|
||||
const baseline = { ...node.baseline, origin }
|
||||
if (!ConstructionDimensionNode.safeParse({ ...node, baseline }).success) return
|
||||
latest = origin
|
||||
useLiveNodeOverrides.getState().set(node.id, { baseline })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (latest) {
|
||||
useScene.getState().updateNode(node.id, {
|
||||
baseline: { ...node.baseline, origin: latest },
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type MeasurementPoint, WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
buildConstructionDimensionPreviewGeometries,
|
||||
buildCurvedWallConstructionDimensionDraft,
|
||||
constructionDimensionUsesBaseline,
|
||||
normalizeConstructionDimensionChainMode,
|
||||
normalizeConstructionDimensionMode,
|
||||
resolveConstructionDimensionDraftDirection,
|
||||
} from './floorplan-tool'
|
||||
|
||||
describe('continuous construction-dimension drafting', () => {
|
||||
test('derives a stable baseline direction from the first witness pair', () => {
|
||||
expect(
|
||||
resolveConstructionDimensionDraftDirection([
|
||||
[1, 0, 2],
|
||||
[4, 0, 6],
|
||||
[8, 0, 7],
|
||||
]),
|
||||
).toEqual([0.6, 0.8])
|
||||
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
|
||||
})
|
||||
|
||||
test('previews one adjacent dimension for every witness interval', () => {
|
||||
const geometry = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
[0, 0, 2],
|
||||
'metric',
|
||||
)
|
||||
|
||||
expect(geometry).toHaveLength(3)
|
||||
expect(geometry.map((segment) => segment.text)).toEqual(['2m', '3m', '4m'])
|
||||
expect(geometry[1]).toMatchObject({
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 2],
|
||||
dimensionEnd: [5, 2],
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes unknown tool defaults to the point-to-point workflow', () => {
|
||||
expect(normalizeConstructionDimensionChainMode('continuous')).toBe('continuous')
|
||||
expect(normalizeConstructionDimensionChainMode('unknown')).toBe('point-to-point')
|
||||
})
|
||||
|
||||
test('normalizes curved and circular construction-dimension modes', () => {
|
||||
expect(normalizeConstructionDimensionMode('radius')).toBe('radius')
|
||||
expect(normalizeConstructionDimensionMode('arc-length')).toBe('arc-length')
|
||||
expect(normalizeConstructionDimensionMode('unknown')).toBe('linear')
|
||||
})
|
||||
|
||||
test('previews radius and diameter notation before commit', () => {
|
||||
const points: MeasurementPoint[] = [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
]
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
|
||||
).toMatchObject({ text: 'R 2m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
|
||||
).toMatchObject({ text: 'Ø 2m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'angular'),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('previews the arc value leader while placing the fourth point', () => {
|
||||
const preview = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
[3, 0, 3],
|
||||
'metric',
|
||||
'arc-length',
|
||||
)
|
||||
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]).toMatchObject({
|
||||
kind: 'group',
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'path' }),
|
||||
expect.objectContaining({ kind: 'line', x2: 3, y2: 3 }),
|
||||
expect.objectContaining({ kind: 'dimension-label', text: 'ARC 3.14m' }),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
test('previews the angular arc and value while placing the fourth point', () => {
|
||||
const preview = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
[1.5, 0, 0.5],
|
||||
'metric',
|
||||
'angular',
|
||||
)
|
||||
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]).toMatchObject({
|
||||
kind: 'group',
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'path' }),
|
||||
expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }),
|
||||
expect.objectContaining({
|
||||
kind: 'dimension-label',
|
||||
cx: 1.5,
|
||||
cy: 0.5,
|
||||
text: '∠ 90°',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
test('only requests a label baseline for modes that use one', () => {
|
||||
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
|
||||
})
|
||||
|
||||
test('derives associative radius, chord, and center drafts from one curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
|
||||
anchors: [
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
|
||||
],
|
||||
points: [
|
||||
[2, 0, 1.5],
|
||||
[2, 0, -1],
|
||||
],
|
||||
})
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
|
||||
{ reference: { featureId: 'wall:start' } },
|
||||
{ reference: { featureId: 'wall:end' } },
|
||||
])
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'center-mark')?.anchors).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps arc length in the manual start-center-end and baseline workflow', () => {
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'arc-length')).toBeNull()
|
||||
expect(constructionDimensionUsesBaseline('arc-length')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps angular dimensions in the manual ray-center-ray and baseline workflow', () => {
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'angular')).toBeNull()
|
||||
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps manual point drafting for straight walls and unsupported modes', () => {
|
||||
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(straight, 'radius')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,726 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionChainMode,
|
||||
type ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
closestMeasurementFeatureBinding,
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
getWallArcData,
|
||||
getWallCurveFrameAt,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementFeatureAnchor,
|
||||
type MeasurementPoint,
|
||||
nodeRegistry,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildSvgArcPath,
|
||||
clearSurfacePlanSnapFeedback,
|
||||
FloorplanGeometryRenderer,
|
||||
type FloorplanToolContext,
|
||||
formatLinearMeasurement,
|
||||
getArcPlanPoint,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
resolveSurfacePlanPointSnap,
|
||||
triggerSFX,
|
||||
useDrawingView,
|
||||
useFloorplanRender,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { resolveCircularConstructionDimensionLayout } from './geometry'
|
||||
|
||||
const SEMANTIC_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_BYPASS_DISTANCE = 0.012
|
||||
const MIN_DIMENSION_LENGTH = 0.001
|
||||
const MIN_ARC_SWEEP = 1e-6
|
||||
|
||||
type Draft = {
|
||||
anchors: MeasurementAnchor[]
|
||||
points: MeasurementPoint[]
|
||||
stage: 'witnesses' | 'baseline'
|
||||
}
|
||||
|
||||
type AssociatedPoint = {
|
||||
anchor: MeasurementAnchor
|
||||
point: MeasurementPoint
|
||||
semantic: boolean
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
const emptyDraft = (): Draft => ({ anchors: [], points: [], stage: 'witnesses' })
|
||||
|
||||
function geometryContext(node: AnyNode, nodes: Record<AnyNodeId, AnyNode>): GeometryContext {
|
||||
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
||||
nodes[id] as N | undefined
|
||||
const childIds =
|
||||
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
||||
const children = childIds
|
||||
.map((id) => nodes[id])
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
|
||||
const siblings =
|
||||
parent && 'children' in parent && Array.isArray(parent.children)
|
||||
? (parent.children as AnyNodeId[])
|
||||
.map((id) => nodes[id])
|
||||
.filter(
|
||||
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
||||
)
|
||||
: []
|
||||
return { resolve, children, parent, siblings }
|
||||
}
|
||||
|
||||
function associatePoint(
|
||||
point: MeasurementPoint,
|
||||
targetNodeId: string | null,
|
||||
maxDistance: number,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): AssociatedPoint {
|
||||
if (!targetNodeId) return { anchor: point, point, semantic: false, targetNodeId: null }
|
||||
const node = nodes[targetNodeId as AnyNodeId]
|
||||
const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
|
||||
if (!(node && contribution)) return { anchor: point, point, semantic: false, targetNodeId }
|
||||
const context = geometryContext(node, nodes)
|
||||
const features = contribution.features(node, context)
|
||||
const match =
|
||||
contribution.match?.(node, context, point, maxDistance) ??
|
||||
closestMeasurementFeatureBinding(features, point, maxDistance)
|
||||
if (!match) return { anchor: point, point, semantic: false, targetNodeId }
|
||||
|
||||
const reference = {
|
||||
nodeId: node.id,
|
||||
featureId: match.featureId,
|
||||
parameters: match.parameters,
|
||||
}
|
||||
const anchor: MeasurementFeatureAnchor = {
|
||||
kind: 'feature',
|
||||
reference,
|
||||
fallback: match.point,
|
||||
}
|
||||
return { anchor, point: match.point, semantic: true, targetNodeId }
|
||||
}
|
||||
|
||||
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number) {
|
||||
const matrix = group.getScreenCTM()
|
||||
if (!matrix) return null
|
||||
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
|
||||
return [local.x, 0, local.y] satisfies MeasurementPoint
|
||||
}
|
||||
|
||||
function registryTargetNodeId(target: EventTarget | null): string | null {
|
||||
if (!(target instanceof Element)) return null
|
||||
return (
|
||||
target.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')?.dataset.nodeId ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveConstructionDimensionDraftDirection(
|
||||
points: readonly MeasurementPoint[],
|
||||
): [number, number] | null {
|
||||
if (points.length < 2) return null
|
||||
const dx = points[1]![0] - points[0]![0]
|
||||
const dz = points[1]![2] - points[0]![2]
|
||||
const magnitude = Math.hypot(dx, dz)
|
||||
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
|
||||
}
|
||||
|
||||
export function buildConstructionDimensionPreviewGeometries(
|
||||
points: readonly MeasurementPoint[],
|
||||
baselinePoint: MeasurementPoint,
|
||||
unit: 'metric' | 'imperial',
|
||||
mode: ConstructionDimensionMode = 'linear',
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
): FloorplanGeometry[] {
|
||||
if (mode === 'arc-length' || mode === 'angular') {
|
||||
const layout = resolveCircularConstructionDimensionLayout(mode, points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > MIN_ARC_SWEEP)) return []
|
||||
const center = { x: layout.center[0], y: layout.center[1] }
|
||||
const end = getArcPlanPoint(center, layout.radius, layout.endAngle)
|
||||
const arcMid = getArcPlanPoint(center, layout.radius, layout.startAngle + layout.sweep / 2)
|
||||
const stroke = '#06b6d4'
|
||||
const lineStyle = {
|
||||
fill: 'none',
|
||||
pointerEvents: 'none' as const,
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke' as const,
|
||||
}
|
||||
if (mode === 'angular') {
|
||||
const endRadius = Math.hypot(layout.end[0] - center.x, layout.end[1] - center.y)
|
||||
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
|
||||
const requestedRadius = Math.hypot(baselinePoint[0] - center.x, baselinePoint[2] - center.y)
|
||||
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = getArcPlanPoint(center, arcRadius, midAngle)
|
||||
const startRayEnd = getArcPlanPoint(
|
||||
center,
|
||||
Math.max(layout.radius, arcRadius + 0.12),
|
||||
layout.startAngle,
|
||||
)
|
||||
const endRayEnd = getArcPlanPoint(
|
||||
center,
|
||||
Math.max(endRadius, arcRadius + 0.12),
|
||||
layout.endAngle,
|
||||
)
|
||||
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
|
||||
const formattedDegrees = Number.parseFloat(degrees.toFixed(degrees < 10 ? 1 : 0))
|
||||
return [
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'line',
|
||||
x1: center.x,
|
||||
y1: center.y,
|
||||
x2: startRayEnd.x,
|
||||
y2: startRayEnd.y,
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: center.x,
|
||||
y1: center.y,
|
||||
x2: endRayEnd.x,
|
||||
y2: endRayEnd.y,
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'path',
|
||||
d: buildSvgArcPath(
|
||||
center,
|
||||
arcRadius,
|
||||
layout.startAngle,
|
||||
layout.startAngle + layout.sweep,
|
||||
),
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: arcMid.x,
|
||||
y1: arcMid.y,
|
||||
x2: baselinePoint[0],
|
||||
y2: baselinePoint[2],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
cx: baselinePoint[0],
|
||||
cy: baselinePoint[2],
|
||||
text: `∠ ${formattedDegrees}°`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
appearance: 'outlined',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'path',
|
||||
d: buildSvgArcPath(
|
||||
center,
|
||||
layout.radius,
|
||||
layout.startAngle,
|
||||
layout.startAngle + layout.sweep,
|
||||
),
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: layout.center[0],
|
||||
y1: layout.center[1],
|
||||
x2: layout.start[0],
|
||||
y2: layout.start[1],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: layout.center[0],
|
||||
y1: layout.center[1],
|
||||
x2: end.x,
|
||||
y2: end.y,
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: arcMid.x,
|
||||
y1: arcMid.y,
|
||||
x2: baselinePoint[0],
|
||||
y2: baselinePoint[2],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
cx: baselinePoint[0],
|
||||
cy: baselinePoint[2],
|
||||
text: `ARC ${formatLinearMeasurement(layout.arcLength, unit, metricNotation)}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
appearance: 'outlined',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(points)
|
||||
if (!direction) return []
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const project = (point: MeasurementPoint): [number, number] => {
|
||||
const along =
|
||||
(point[0] - baselinePoint[0]) * direction[0] + (point[2] - baselinePoint[2]) * direction[1]
|
||||
return [baselinePoint[0] + along * direction[0], baselinePoint[2] + along * direction[1]]
|
||||
}
|
||||
const dimensionPoints = points.map(project)
|
||||
return points.slice(0, -1).map((start, index) => {
|
||||
const end = points[index + 1]!
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[2] - start[2]
|
||||
const value = Math.abs(dx * direction[0] + dz * direction[1])
|
||||
const rawText = formatLinearMeasurement(value, unit, metricNotation)
|
||||
const text =
|
||||
mode === 'radius'
|
||||
? `R ${rawText}`
|
||||
: mode === 'diameter'
|
||||
? `Ø ${rawText}`
|
||||
: mode === 'chord'
|
||||
? `CH ${rawText}`
|
||||
: rawText
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: [start[0], start[2]],
|
||||
end: [end[0], end[2]],
|
||||
dimensionStart: dimensionPoints[index]!,
|
||||
dimensionEnd: dimensionPoints[index + 1]!,
|
||||
offsetNormal: normal,
|
||||
offsetDistance: 0,
|
||||
extensionOvershoot: 0.12,
|
||||
text,
|
||||
stroke: '#06b6d4',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeConstructionDimensionChainMode(
|
||||
value: unknown,
|
||||
): ConstructionDimensionChainMode {
|
||||
return value === 'continuous' ? 'continuous' : 'point-to-point'
|
||||
}
|
||||
|
||||
export function normalizeConstructionDimensionMode(value: unknown): ConstructionDimensionMode {
|
||||
return [
|
||||
'radius',
|
||||
'diameter',
|
||||
'center-mark',
|
||||
'chord',
|
||||
'arc-length',
|
||||
'angular',
|
||||
'coordinate',
|
||||
].includes(value as string)
|
||||
? (value as ConstructionDimensionMode)
|
||||
: 'linear'
|
||||
}
|
||||
|
||||
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
|
||||
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
}
|
||||
|
||||
function wallFeatureAnchor(
|
||||
wall: WallNode,
|
||||
featureId: string,
|
||||
fallback: MeasurementPoint,
|
||||
): MeasurementFeatureAnchor {
|
||||
return {
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId },
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCurvedWallConstructionDimensionDraft(
|
||||
wall: WallNode,
|
||||
mode: ConstructionDimensionMode,
|
||||
): Pick<Draft, 'anchors' | 'points'> | null {
|
||||
const arc = getWallArcData(wall)
|
||||
if (!arc) return null
|
||||
|
||||
const center: MeasurementPoint = [arc.center.x, 0, arc.center.y]
|
||||
const start: MeasurementPoint = [wall.start[0], 0, wall.start[1]]
|
||||
const end: MeasurementPoint = [wall.end[0], 0, wall.end[1]]
|
||||
const midpointFrame = getWallCurveFrameAt(wall, 0.5)
|
||||
const midpoint: MeasurementPoint = [midpointFrame.point.x, 0, midpointFrame.point.y]
|
||||
const feature = (featureId: string, fallback: MeasurementPoint) =>
|
||||
wallFeatureAnchor(wall, featureId, fallback)
|
||||
|
||||
switch (mode) {
|
||||
case 'radius':
|
||||
case 'center-mark':
|
||||
return {
|
||||
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
|
||||
points: [center, midpoint],
|
||||
}
|
||||
case 'chord':
|
||||
return {
|
||||
anchors: [feature('wall:start', start), feature('wall:end', end)],
|
||||
points: [start, end],
|
||||
}
|
||||
case 'arc-length':
|
||||
case 'angular':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function FloorplanConstructionDimensionToolLayer({
|
||||
activeLevelId,
|
||||
finishTool,
|
||||
gridSnapStep,
|
||||
metricNotation,
|
||||
sceneApi,
|
||||
selectNode,
|
||||
toolDefaults,
|
||||
unit,
|
||||
}: FloorplanToolContext) {
|
||||
const groupRef = useRef<SVGGElement>(null)
|
||||
const draftRef = useRef<Draft>(emptyDraft())
|
||||
const [draft, setDraft] = useState<Draft>(draftRef.current)
|
||||
const [hover, setHover] = useState<AssociatedPoint | null>(null)
|
||||
const chainMode = normalizeConstructionDimensionChainMode(toolDefaults?.chainMode)
|
||||
const dimensionMode = normalizeConstructionDimensionMode(toolDefaults?.mode)
|
||||
const collectsMany =
|
||||
dimensionMode === 'coordinate' || (dimensionMode === 'linear' && chainMode === 'continuous')
|
||||
const usesBaseline = constructionDimensionUsesBaseline(dimensionMode)
|
||||
const renderContext = useFloorplanRender()
|
||||
const drawingType = useDrawingView((state) => state.drawingType)
|
||||
|
||||
useEffect(() => {
|
||||
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'construction-dimension' })
|
||||
return () =>
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'construction-dimension')
|
||||
}, [])
|
||||
|
||||
const updateDraft = useCallback((next: Draft) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateDraft(emptyDraft())
|
||||
setHover(null)
|
||||
const group = groupRef.current
|
||||
const svg = group?.ownerSVGElement
|
||||
if (!(activeLevelId && group && svg)) return
|
||||
|
||||
const consume = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const resolveEvent = (event: MouseEvent | PointerEvent): AssociatedPoint | null => {
|
||||
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
|
||||
if (!raw) return null
|
||||
const forceFree = event.altKey
|
||||
const gridStep = !forceFree && isGridSnapActive() ? gridSnapStep : 0
|
||||
const fallbackPoint: [number, number] =
|
||||
gridStep > 0
|
||||
? [Math.round(raw[0] / gridStep) * gridStep, Math.round(raw[2] / gridStep) * gridStep]
|
||||
: [raw[0], raw[2]]
|
||||
const magnetic = !forceFree && isMagneticSnapActive()
|
||||
const surface = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [raw[0], raw[2]],
|
||||
fallbackPoint,
|
||||
levelId: activeLevelId,
|
||||
align: false,
|
||||
magnetic,
|
||||
})
|
||||
const point: MeasurementPoint = [surface.point[0], 0, surface.point[1]]
|
||||
const targetNodeId = surface.wallIds[0] ?? registryTargetNodeId(event.target)
|
||||
return associatePoint(
|
||||
point,
|
||||
targetNodeId,
|
||||
magnetic ? SEMANTIC_SNAP_DISTANCE : SEMANTIC_BYPASS_DISTANCE,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
}
|
||||
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
|
||||
const direction = resolveConstructionDimensionDraftDirection(current.points)
|
||||
const originPoint = baselinePoint ?? current.points.at(-1)
|
||||
if (!(direction && originPoint)) return false
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
name:
|
||||
dimensionMode === 'linear' && chainMode === 'continuous'
|
||||
? 'Continuous Dimension'
|
||||
: `${dimensionMode.replaceAll('-', ' ')} Dimension`,
|
||||
anchors: current.anchors,
|
||||
baseline: {
|
||||
origin: [originPoint[0], originPoint[2]],
|
||||
direction,
|
||||
},
|
||||
chainMode,
|
||||
mode: dimensionMode,
|
||||
drawingType,
|
||||
})
|
||||
sceneApi.upsert(node, activeLevelId)
|
||||
selectNode(node.id)
|
||||
triggerSFX('sfx:structure-build')
|
||||
finishTool()
|
||||
updateDraft(emptyDraft())
|
||||
setHover(null)
|
||||
return true
|
||||
}
|
||||
const finishWitnesses = () => {
|
||||
const current = draftRef.current
|
||||
const required = constructionDimensionRequiredAnchorCount(dimensionMode)
|
||||
if (current.stage !== 'witnesses' || current.points.length < required) return false
|
||||
if (!usesBaseline) return commitDraft(current)
|
||||
updateDraft({ ...current, stage: 'baseline' })
|
||||
triggerSFX('sfx:grid-snap')
|
||||
return true
|
||||
}
|
||||
const removeLastWitness = () => {
|
||||
const current = draftRef.current
|
||||
if (current.points.length === 0) return false
|
||||
updateDraft({
|
||||
anchors: current.anchors.slice(0, -1),
|
||||
points: current.points.slice(0, -1),
|
||||
stage: 'witnesses',
|
||||
})
|
||||
return true
|
||||
}
|
||||
const commitAt = (associated: AssociatedPoint) => {
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') commitDraft(current, associated.point)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0) consume(event)
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
consume(event)
|
||||
setHover(resolveEvent(event))
|
||||
}
|
||||
const onPointerLeave = () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
setHover(null)
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
consume(event)
|
||||
if (event.detail > 1) return
|
||||
const associated = resolveEvent(event)
|
||||
if (!associated) return
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') {
|
||||
commitAt(associated)
|
||||
return
|
||||
}
|
||||
const targetNode = associated.targetNodeId
|
||||
? sceneApi.get(associated.targetNodeId as AnyNodeId)
|
||||
: undefined
|
||||
const curvedWallDraft =
|
||||
current.points.length === 0 && targetNode?.type === 'wall'
|
||||
? buildCurvedWallConstructionDimensionDraft(targetNode, dimensionMode)
|
||||
: null
|
||||
if (curvedWallDraft) {
|
||||
const next: Draft = { ...curvedWallDraft, stage: 'witnesses' }
|
||||
updateDraft(next)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
|
||||
else commitDraft(next)
|
||||
return
|
||||
}
|
||||
const previous = current.points.at(-1)
|
||||
if (
|
||||
previous &&
|
||||
Math.hypot(associated.point[0] - previous[0], associated.point[2] - previous[2]) <=
|
||||
MIN_DIMENSION_LENGTH
|
||||
) {
|
||||
return
|
||||
}
|
||||
const next: Draft = {
|
||||
anchors: [...current.anchors, associated.anchor],
|
||||
points: [...current.points, associated.point],
|
||||
stage: 'witnesses',
|
||||
}
|
||||
updateDraft(next)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
if (
|
||||
!collectsMany &&
|
||||
next.points.length === constructionDimensionRequiredAnchorCount(dimensionMode)
|
||||
) {
|
||||
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
|
||||
else commitDraft(next)
|
||||
}
|
||||
}
|
||||
const onDoubleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !collectsMany) return
|
||||
consume(event)
|
||||
finishWitnesses()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && collectsMany) {
|
||||
if (!finishWitnesses()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
if (!removeLastWitness()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
markToolCancelConsumed()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
markToolCancelConsumed()
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') {
|
||||
updateDraft({ ...current, stage: 'witnesses' })
|
||||
return
|
||||
}
|
||||
if (removeLastWitness()) return
|
||||
finishTool()
|
||||
}
|
||||
const onBlur = () => clearSurfacePlanSnapFeedback()
|
||||
|
||||
svg.addEventListener('pointerdown', onPointerDown, true)
|
||||
svg.addEventListener('pointermove', onPointerMove, true)
|
||||
svg.addEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.addEventListener('click', onClick, true)
|
||||
svg.addEventListener('dblclick', onDoubleClick, true)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
svg.removeEventListener('pointerdown', onPointerDown, true)
|
||||
svg.removeEventListener('pointermove', onPointerMove, true)
|
||||
svg.removeEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.removeEventListener('click', onClick, true)
|
||||
svg.removeEventListener('dblclick', onDoubleClick, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [
|
||||
activeLevelId,
|
||||
chainMode,
|
||||
collectsMany,
|
||||
dimensionMode,
|
||||
drawingType,
|
||||
finishTool,
|
||||
gridSnapStep,
|
||||
sceneApi,
|
||||
selectNode,
|
||||
updateDraft,
|
||||
usesBaseline,
|
||||
])
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
draft.stage === 'baseline' && hover
|
||||
? buildConstructionDimensionPreviewGeometries(
|
||||
draft.points,
|
||||
hover.point,
|
||||
unit,
|
||||
dimensionMode,
|
||||
metricNotation,
|
||||
)
|
||||
: [],
|
||||
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
|
||||
)
|
||||
const witnessDraftPoints =
|
||||
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
|
||||
|
||||
if (!activeLevelId) return null
|
||||
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
|
||||
const reticleRadius = 10 * unitsPerPixel
|
||||
const hoverColor = hover?.semantic ? '#22c55e' : '#06b6d4'
|
||||
|
||||
return (
|
||||
<g ref={groupRef}>
|
||||
{witnessDraftPoints.length >= 2 && (draft.stage === 'witnesses' || preview.length === 0) ? (
|
||||
<polyline
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
points={witnessDraftPoints.map((point) => `${point[0]},${point[2]}`).join(' ')}
|
||||
stroke="#06b6d4"
|
||||
strokeDasharray="6 5"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null}
|
||||
{preview.map((geometry, index) => (
|
||||
<FloorplanGeometryRenderer
|
||||
annotationUnitsPerPoint={unitsPerPixel}
|
||||
geometry={geometry}
|
||||
key={`${index}-${geometry.kind}`}
|
||||
sceneRotationDeg={renderContext?.sceneRotationDeg ?? 0}
|
||||
/>
|
||||
))}
|
||||
{draft.points.map((point, index) => (
|
||||
<circle
|
||||
fill="#06b6d4"
|
||||
key={`${index}-${point.join('-')}`}
|
||||
pointerEvents="none"
|
||||
r={4.5 * unitsPerPixel}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
cx={point[0]}
|
||||
cy={point[2]}
|
||||
/>
|
||||
))}
|
||||
{hover ? (
|
||||
<g pointerEvents="none">
|
||||
<circle
|
||||
cx={hover.point[0]}
|
||||
cy={hover.point[2]}
|
||||
fill="none"
|
||||
r={reticleRadius}
|
||||
stroke={hoverColor}
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<line
|
||||
stroke={hoverColor}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover.point[0] - reticleRadius * 1.4}
|
||||
x2={hover.point[0] + reticleRadius * 1.4}
|
||||
y1={hover.point[2]}
|
||||
y2={hover.point[2]}
|
||||
/>
|
||||
<line
|
||||
stroke={hoverColor}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover.point[0]}
|
||||
x2={hover.point[0]}
|
||||
y1={hover.point[2] - reticleRadius * 1.4}
|
||||
y2={hover.point[2] + reticleRadius * 1.4}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorplanConstructionDimensionToolLayer
|
||||
@@ -0,0 +1,518 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
ConstructionDimensionNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions } from '@pascal-app/editor'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
|
||||
const palette = {
|
||||
selectedStroke: '#2563eb',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedHatch: '#93c5fd',
|
||||
wallHoverStroke: '#60a5fa',
|
||||
endpointHandleFill: '#f97316',
|
||||
endpointHandleStroke: '#ffffff',
|
||||
endpointHandleHoverStroke: '#fdba74',
|
||||
endpointHandleActiveFill: '#ea580c',
|
||||
endpointHandleActiveStroke: '#ffffff',
|
||||
curveHandleFill: '#14b8a6',
|
||||
curveHandleStroke: '#ffffff',
|
||||
curveHandleHoverStroke: '#5eead4',
|
||||
measurementStroke: '#334155',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#0f172a',
|
||||
}
|
||||
|
||||
function context(
|
||||
nodes: Record<string, AnyNode> = {},
|
||||
selected = false,
|
||||
purpose: 'edit' | 'document' = 'edit',
|
||||
metricNotation?: 'meters' | 'millimeters',
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: (id) => nodes[id],
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ metricNotation, purpose }),
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
|
||||
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
|
||||
}
|
||||
|
||||
function dimensionSegments(geometry: FloorplanGeometry | null): Array<{
|
||||
start: readonly [number, number]
|
||||
end: readonly [number, number]
|
||||
dimensionStart?: readonly [number, number]
|
||||
dimensionEnd?: readonly [number, number]
|
||||
text: string
|
||||
stroke?: string
|
||||
}> {
|
||||
if (!geometry) return []
|
||||
return flatten(geometry).flatMap((entry) => {
|
||||
if (entry.kind === 'dimension') return [entry]
|
||||
if (entry.kind === 'dimension-string')
|
||||
return entry.segments.map((segment) => ({ ...segment, stroke: entry.stroke }))
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildConstructionDimensionFloorplan', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
})
|
||||
|
||||
afterEach(() => nodeRegistry._reset())
|
||||
|
||||
test('projects witness origins onto the placed baseline', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[1, 0, 1],
|
||||
[4, 0, 2],
|
||||
],
|
||||
baseline: { origin: [0, 5], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimension = dimensionSegments(geometry)[0]
|
||||
|
||||
expect(dimension).toMatchObject({
|
||||
start: [1, 1],
|
||||
end: [4, 2],
|
||||
dimensionStart: [1, 5],
|
||||
dimensionEnd: [4, 5],
|
||||
text: '3m',
|
||||
})
|
||||
})
|
||||
|
||||
test('follows semantic anchors and reports dangling references', () => {
|
||||
const wall = WallNode.parse({ id: 'wall_target', start: [0, 0], end: [4, 0] })
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0],
|
||||
},
|
||||
[4, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const linked = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
|
||||
const movedWall = WallNode.parse({ ...wall, start: [2, 0], end: [6, 0] })
|
||||
const moved = buildConstructionDimensionFloorplan(node, context({ [wall.id]: movedWall }))
|
||||
const dangling = buildConstructionDimensionFloorplan(node, context())
|
||||
const linkedDimension = dimensionSegments(linked)[0]
|
||||
const movedDimension = dimensionSegments(moved)[0]
|
||||
const danglingDimension = dimensionSegments(dangling)[0]
|
||||
|
||||
expect(linkedDimension).toMatchObject({ start: [1, 0], text: '3m' })
|
||||
expect(movedDimension).toMatchObject({ start: [3, 0], text: '1m' })
|
||||
expect(danglingDimension).toMatchObject({
|
||||
start: [1, 0],
|
||||
text: 'UNLINKED · 3m',
|
||||
stroke: '#dc2626',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves wall anchors against the selected assembly datum', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_assembly',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const anchor = {
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0] as [number, number, number],
|
||||
}
|
||||
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
|
||||
buildConstructionDimensionFloorplan(
|
||||
ConstructionDimensionNode.parse({
|
||||
anchors: [anchor, [3, 0, 0]],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy,
|
||||
}),
|
||||
context({ [wall.id]: wall }),
|
||||
)
|
||||
|
||||
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
|
||||
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
|
||||
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
})
|
||||
|
||||
test('uses millimetre notation in document output', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[3, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
expect(
|
||||
dimensionSegments(
|
||||
buildConstructionDimensionFloorplan(node, context({}, false, 'document')),
|
||||
)[0]?.text,
|
||||
).toBe('3000')
|
||||
})
|
||||
|
||||
test('renders a continuous string as adjacent associative segments', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(dimensions).toHaveLength(3)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '3m', '4m'])
|
||||
expect(dimensions[1]).toMatchObject({
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 1],
|
||||
dimensionEnd: [5, 1],
|
||||
})
|
||||
})
|
||||
|
||||
test('renders point-to-point strings as independent witness pairs', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'point-to-point',
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(dimensions).toHaveLength(2)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
|
||||
expect(dimensions[1]).toMatchObject({
|
||||
start: [5, 0],
|
||||
end: [9, 0],
|
||||
dimensionStart: [5, 1],
|
||||
dimensionEnd: [9, 1],
|
||||
})
|
||||
})
|
||||
|
||||
test('suppresses view-specific string segments without mutating physical anchors', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
metadata: { suppressedDimensionSegmentIndexes: [1] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(node.anchors).toHaveLength(4)
|
||||
expect(dimensions).toHaveLength(2)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
|
||||
})
|
||||
|
||||
test('passes persistent dimension standards to linear dimension strings', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy: 'finish-face',
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
metricNotation: 'millimeters',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context({}, false, 'document'))
|
||||
const string = geometry
|
||||
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
|
||||
: null
|
||||
|
||||
expect(string).toMatchObject({
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
|
||||
})
|
||||
|
||||
test('uses the live metric notation for manual dimensions in edit mode', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(
|
||||
node,
|
||||
context({}, false, 'edit', 'millimeters'),
|
||||
)
|
||||
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
|
||||
})
|
||||
|
||||
test('shows witness and baseline handles only while selected', () => {
|
||||
const node = ConstructionDimensionNode.parse({})
|
||||
const idle = buildConstructionDimensionFloorplan(node, context())
|
||||
const selected = buildConstructionDimensionFloorplan(node, context({}, true))
|
||||
|
||||
expect(idle && flatten(idle).filter((entry) => entry.kind === 'endpoint-handle')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
const handles = selected
|
||||
? flatten(selected).filter((entry) => entry.kind === 'endpoint-handle')
|
||||
: []
|
||||
expect(handles).toHaveLength(3)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex: 0 },
|
||||
}),
|
||||
)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex: 1 },
|
||||
}),
|
||||
)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({ affordance: 'move-construction-dimension-baseline' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps linked geometry read-only in a dependent drawing', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
metadata: { drawingCoordinationLocked: true },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context({}, true))
|
||||
|
||||
expect(
|
||||
geometry && flatten(geometry).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('renders radius notation with a leader and center mark', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [3, 1], direction: [1, 0] },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
text: 'R 2m',
|
||||
cx: 3,
|
||||
cy: 1,
|
||||
})
|
||||
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
test('updates an associative curved-wall radius when the host curve changes', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
|
||||
fallback: [2, 0, 1.5],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
|
||||
fallback: [2, 0, -1],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [2, -1.5], direction: [0, -1] },
|
||||
})
|
||||
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
|
||||
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
|
||||
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
|
||||
const originalLabel =
|
||||
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
|
||||
const reshapedLabel =
|
||||
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
|
||||
|
||||
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
|
||||
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
|
||||
})
|
||||
|
||||
test('renders diameter and repeated-feature notation', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'diameter',
|
||||
anchors: [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
featureCount: 6,
|
||||
prefix: 'TYP · ',
|
||||
suffix: ' CLR',
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(dimensionSegments(geometry)[0]).toMatchObject({
|
||||
text: 'TYP · 6 x Ø 2m CLR',
|
||||
start: [-1, 0],
|
||||
end: [1, 0],
|
||||
})
|
||||
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('renders a standalone center mark from a center and radius point', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'center-mark',
|
||||
anchors: [
|
||||
[3, 0, 4],
|
||||
[5, 0, 4],
|
||||
],
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
|
||||
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
|
||||
expect(dimensionSegments(geometry).length).toBe(0)
|
||||
})
|
||||
|
||||
test('renders chord and arc-length dimensions', () => {
|
||||
const chord = ConstructionDimensionNode.parse({
|
||||
mode: 'chord',
|
||||
anchors: [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
const arc = ConstructionDimensionNode.parse({
|
||||
mode: 'arc-length',
|
||||
anchors: [
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
baseline: { origin: [2, 2], direction: [1, 0] },
|
||||
})
|
||||
const chordGeometry = buildConstructionDimensionFloorplan(chord, context())
|
||||
const arcGeometry = buildConstructionDimensionFloorplan(arc, context())
|
||||
const chordEntries = chordGeometry ? flatten(chordGeometry) : []
|
||||
const arcEntries = arcGeometry ? flatten(arcGeometry) : []
|
||||
|
||||
expect(dimensionSegments(chordGeometry)[0]).toMatchObject({
|
||||
text: 'CH 2m',
|
||||
})
|
||||
expect(arcEntries.some((entry) => entry.kind === 'path')).toBe(true)
|
||||
expect(arcEntries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
text: 'ARC 3.14m',
|
||||
})
|
||||
})
|
||||
|
||||
test('renders angular dimensions with an architectural angle label', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'angular',
|
||||
anchors: [
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
baseline: { origin: [1.5, 0.5], direction: [1, 0] },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.some((entry) => entry.kind === 'path')).toBe(true)
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
cx: 1.5,
|
||||
cy: 0.5,
|
||||
text: '∠ 90°',
|
||||
screenUpright: true,
|
||||
})
|
||||
expect(entries).toContainEqual(expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }))
|
||||
})
|
||||
|
||||
test('renders signed coordinate labels for repeated circular features', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'coordinate',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 3],
|
||||
[-1, 0, 4],
|
||||
],
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const labels = geometry
|
||||
? flatten(geometry)
|
||||
.filter((entry) => entry.kind === 'dimension-label')
|
||||
.map((entry) => entry.text)
|
||||
: []
|
||||
|
||||
expect(labels).toEqual(['P1 · X 2m · Y 3m', 'P2 · X -1m · Y 4m'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,660 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
FloorplanStyle,
|
||||
GeometryContext,
|
||||
MeasurementAnchor,
|
||||
MeasurementPoint,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallCurveFrameAt,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
readFloorplanContext,
|
||||
readFloorplanMetricNotationOverride,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from '@pascal-app/editor'
|
||||
import { resolveMeasurementAnchor } from '../measurement/resolve'
|
||||
import {
|
||||
type ConstructionLengthFormatOptions,
|
||||
type ConstructionLengthProfile,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
import { buildDimensionStringGeometry } from '../shared/dimension-string'
|
||||
import {
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
|
||||
const DEFAULT_STROKE = '#334155'
|
||||
const DANGLING_STROKE = '#dc2626'
|
||||
const EPSILON = 1e-6
|
||||
|
||||
export function buildConstructionDimensionFloorplan(
|
||||
node: ConstructionDimensionNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
if (node.visible === false) return null
|
||||
|
||||
const resolved = node.anchors.map((anchor) => resolveDimensionAnchor(node, anchor, ctx))
|
||||
const points = resolved.map((anchor) => anchor.point) as MeasurementPoint[]
|
||||
if (points.length < constructionDimensionRequiredAnchorCount(node.mode)) return null
|
||||
|
||||
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
|
||||
const baseStroke = selected
|
||||
? (ctx.viewState?.palette.selectedStroke ?? '#2563eb')
|
||||
: (ctx.viewState?.palette.measurementStroke ?? DEFAULT_STROKE)
|
||||
const dangling = resolved.some((anchor) => anchor.dangling)
|
||||
const stroke = dangling ? DANGLING_STROKE : baseStroke
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const floorplanContext = readFloorplanContext(ctx)
|
||||
const profile: ConstructionLengthProfile =
|
||||
floorplanContext.purpose === 'document' ? 'document' : 'editor'
|
||||
const metricNotationOverride = readFloorplanMetricNotationOverride(ctx)
|
||||
const displayNode =
|
||||
profile === 'editor' && metricNotationOverride
|
||||
? { ...node, metricNotation: metricNotationOverride }
|
||||
: node
|
||||
const editable =
|
||||
ctx.viewState?.selected === true &&
|
||||
!(
|
||||
typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata) &&
|
||||
node.metadata.drawingCoordinationLocked === true
|
||||
)
|
||||
|
||||
switch (node.mode) {
|
||||
case 'linear':
|
||||
case 'chord':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'radius':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'diameter':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildDiameter(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'center-mark':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildCenterMarkOnly(displayNode, points, stroke, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'arc-length':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildArcLength(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'angular':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildAngular(displayNode, points, stroke, dangling, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'coordinate':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildCoordinate(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDimensionAnchor(
|
||||
node: ConstructionDimensionNode,
|
||||
anchor: MeasurementAnchor,
|
||||
ctx: GeometryContext,
|
||||
): ReturnType<typeof resolveMeasurementAnchor> {
|
||||
const resolved = resolveMeasurementAnchor(anchor, (id) => ctx.resolve(id))
|
||||
if (Array.isArray(anchor) || resolved.dangling) return resolved
|
||||
if (!supportsWallDatum(anchor.reference.featureId)) return resolved
|
||||
|
||||
const referenced = ctx.resolve<WallNode>(anchor.reference.nodeId as AnyNodeId)
|
||||
if (referenced?.type !== 'wall') return resolved
|
||||
|
||||
const t = wallFeatureParameter(anchor.reference.featureId, anchor.reference.parameters?.t)
|
||||
const frame = getWallCurveFrameAt(referenced, t)
|
||||
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
|
||||
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
point: [
|
||||
frame.point.x + frame.normal.x * offset,
|
||||
resolved.point[1],
|
||||
frame.point.y + frame.normal.y * offset,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function supportsWallDatum(featureId: string): boolean {
|
||||
return (
|
||||
featureId === 'wall:start' ||
|
||||
featureId === 'wall:end' ||
|
||||
featureId === 'wall:centerline' ||
|
||||
featureId === 'wall:midpoint' ||
|
||||
featureId === 'wall:face:left' ||
|
||||
featureId === 'wall:face:right' ||
|
||||
featureId === 'wall:top-centerline'
|
||||
)
|
||||
}
|
||||
|
||||
function wallFeatureParameter(featureId: string, parameter: unknown): number {
|
||||
if (featureId === 'wall:start') return 0
|
||||
if (featureId === 'wall:end') return 1
|
||||
return typeof parameter === 'number' ? Math.max(0, Math.min(1, parameter)) : 0.5
|
||||
}
|
||||
|
||||
function wallDatumSide(
|
||||
node: ConstructionDimensionNode,
|
||||
featureId: string,
|
||||
resolved: ReturnType<typeof resolveMeasurementAnchor>,
|
||||
frame: ReturnType<typeof getWallCurveFrameAt>,
|
||||
): 1 | -1 {
|
||||
if (featureId === 'wall:face:left') return 1
|
||||
if (featureId === 'wall:face:right') return -1
|
||||
|
||||
const baselineProjection =
|
||||
(node.baseline.origin[0] - frame.point.x) * frame.normal.x +
|
||||
(node.baseline.origin[1] - frame.point.y) * frame.normal.y
|
||||
if (Math.abs(baselineProjection) > EPSILON) return baselineProjection > 0 ? 1 : -1
|
||||
|
||||
const resolvedNormal = resolved.normal
|
||||
if (resolvedNormal) {
|
||||
const normalProjection = resolvedNormal[0] * frame.normal.x + resolvedNormal[2] * frame.normal.y
|
||||
if (Math.abs(normalProjection) > EPSILON) return normalProjection > 0 ? 1 : -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function wallDatumOffset(
|
||||
wall: WallNode,
|
||||
policy: ConstructionDimensionNode['datumPolicy'],
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
if (policy === 'centerline') return 0
|
||||
if (policy === 'wall-face') {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
return side > 0 ? faces.exterior : faces.interior
|
||||
}
|
||||
|
||||
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
|
||||
const candidates = resolveWallAssemblyDatumReferences(wall)
|
||||
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
|
||||
.map((reference) => reference.offset)
|
||||
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
|
||||
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
|
||||
}
|
||||
|
||||
function buildLinearOrChord(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry {
|
||||
const layout = resolveConstructionDimensionLayout(node, points)
|
||||
const children: FloorplanGeometry[] = []
|
||||
const suppressedSegments = suppressedDimensionSegmentIndexes(node)
|
||||
const visibleSegments = layout.segments.filter((_, index) => !suppressedSegments.has(index))
|
||||
const dimensionSegments = visibleSegments.map((segment) => {
|
||||
const baseText = `${node.mode === 'chord' ? 'CH ' : ''}${formatConstructionLength(segment.value, unit, profile, lengthFormatOptions(node))}`
|
||||
return {
|
||||
witnessStart: segment.witnessStart,
|
||||
witnessEnd: segment.witnessEnd,
|
||||
dimensionStart: segment.dimensionStart,
|
||||
dimensionEnd: segment.dimensionEnd,
|
||||
text: notation(node, baseText, dangling),
|
||||
}
|
||||
})
|
||||
children.push(
|
||||
...(dimensionSegments.length > 0
|
||||
? [
|
||||
buildDimensionStringGeometry({
|
||||
segments: dimensionSegments,
|
||||
offsetNormal: layout.normal,
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: node.extensionStartGap,
|
||||
extensionOvershoot: node.extensionOvershoot,
|
||||
terminator: node.terminator,
|
||||
textPosition: node.textPosition,
|
||||
stroke,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...visibleSegments.map((segment) => hitLine(segment.dimensionStart, segment.dimensionEnd)),
|
||||
)
|
||||
if (editable)
|
||||
children.push(...witnessHandles(layout.witnessPoints), baselineHandle(layout.midpoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildRadius(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('radius', points)
|
||||
if (!layout) return null
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const children: FloorplanGeometry[] = [
|
||||
styledPolyline([layout.center, layout.start, labelPoint], stroke),
|
||||
...openArrow(layout.start, layout.center, stroke),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
notation(
|
||||
node,
|
||||
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
angle(layout.start, labelPoint),
|
||||
),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildDiameter(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('diameter', points)
|
||||
if (!layout?.end) return null
|
||||
const direction = normalized(layout.start, layout.end)
|
||||
if (!direction) return null
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const children: FloorplanGeometry[] = [
|
||||
dimensionGeometry(
|
||||
node,
|
||||
layout.start,
|
||||
layout.end,
|
||||
layout.start,
|
||||
layout.end,
|
||||
normal,
|
||||
notation(
|
||||
node,
|
||||
`Ø ${formatConstructionLength(layout.radius * 2, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
stroke,
|
||||
),
|
||||
hitLine(layout.start, layout.end),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildCenterMarkOnly(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('center-mark', points)
|
||||
if (!layout) return null
|
||||
const children: FloorplanGeometry[] = centerMark(layout.center, layout.radius, stroke, true)
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildArcLength(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('arc-length', points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
|
||||
const projectedEnd = arcPoint(layout.center, layout.radius, layout.endAngle)
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = arcPoint(layout.center, layout.radius, midAngle)
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const children: FloorplanGeometry[] = [
|
||||
arcGeometry(layout.center, layout.radius, layout.startAngle, layout.sweep, stroke),
|
||||
styledLine(layout.center, layout.start, stroke, '0.08 0.08'),
|
||||
styledLine(layout.center, projectedEnd, stroke, '0.08 0.08'),
|
||||
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
|
||||
...openArrow(
|
||||
layout.start,
|
||||
arcPoint(layout.center, layout.radius, layout.startAngle + layout.sweep * 0.08),
|
||||
stroke,
|
||||
),
|
||||
...openArrow(
|
||||
projectedEnd,
|
||||
arcPoint(layout.center, layout.radius, layout.endAngle - layout.sweep * 0.08),
|
||||
stroke,
|
||||
),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
notation(
|
||||
node,
|
||||
`ARC ${formatConstructionLength(layout.arcLength, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
0,
|
||||
true,
|
||||
),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildAngular(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('angular', points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
|
||||
const endRadius = distance(layout.center, layout.end)
|
||||
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
|
||||
const requestedRadius = distance(layout.center, node.baseline.origin)
|
||||
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = arcPoint(layout.center, arcRadius, midAngle)
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const startRayEnd = arcPoint(
|
||||
layout.center,
|
||||
Math.max(layout.radius, arcRadius + 0.12),
|
||||
layout.startAngle,
|
||||
)
|
||||
const endRayEnd = arcPoint(layout.center, Math.max(endRadius, arcRadius + 0.12), layout.endAngle)
|
||||
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
|
||||
const children: FloorplanGeometry[] = [
|
||||
styledLine(layout.center, startRayEnd, stroke),
|
||||
styledLine(layout.center, endRayEnd, stroke),
|
||||
arcGeometry(layout.center, arcRadius, layout.startAngle, layout.sweep, stroke),
|
||||
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
|
||||
labelGeometry(labelPoint, notation(node, `∠ ${formatDegrees(degrees)}`, dangling), 0, true),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, arcRadius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(node.baseline.origin))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildCoordinate(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const datum: FloorplanPoint = [points[0]![0], points[0]![2]]
|
||||
const features = points.slice(1).map((point): FloorplanPoint => [point[0], point[2]])
|
||||
if (features.length === 0) return null
|
||||
const children: FloorplanGeometry[] = [...centerMark(datum, 0.4, stroke, true)]
|
||||
features.forEach((feature, index) => {
|
||||
const dx = feature[0] - datum[0]
|
||||
const dy = feature[1] - datum[1]
|
||||
const label = notation(
|
||||
node,
|
||||
`P${index + 1} · X ${formatConstructionLength(dx, unit, profile, lengthFormatOptions(node))} · Y ${formatConstructionLength(dy, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
false,
|
||||
)
|
||||
children.push(
|
||||
styledLine(datum, feature, stroke, '0.08 0.08'),
|
||||
labelGeometry(feature, label, 0, true, 10),
|
||||
...centerMark(feature, 0.3, stroke, true),
|
||||
)
|
||||
})
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function suppressedDimensionSegmentIndexes(node: ConstructionDimensionNode): ReadonlySet<number> {
|
||||
const metadata = node.metadata
|
||||
if (!(typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata))) {
|
||||
return new Set()
|
||||
}
|
||||
const value = metadata.suppressedDimensionSegmentIndexes
|
||||
if (!Array.isArray(value)) return new Set()
|
||||
return new Set(
|
||||
value.filter(
|
||||
(entry): entry is number =>
|
||||
typeof entry === 'number' && Number.isInteger(entry) && entry >= 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function dimensionGroup(children: FloorplanGeometry[]): FloorplanGeometry {
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
function lengthFormatOptions(node: ConstructionDimensionNode): ConstructionLengthFormatOptions {
|
||||
return {
|
||||
imperialPrecision: node.imperialPrecision,
|
||||
metricNotation: node.metricNotation,
|
||||
}
|
||||
}
|
||||
|
||||
function notation(
|
||||
node: ConstructionDimensionNode,
|
||||
base: string,
|
||||
dangling: boolean,
|
||||
includeFeatureCount = true,
|
||||
): string {
|
||||
const repeated = includeFeatureCount && node.featureCount > 1 ? `${node.featureCount} x ` : ''
|
||||
const content = node.textOverride ?? `${repeated}${base}`
|
||||
const decorated = `${node.prefix}${content}${node.suffix}`
|
||||
return dangling ? `UNLINKED · ${decorated}` : decorated
|
||||
}
|
||||
|
||||
function dimensionGeometry(
|
||||
node: ConstructionDimensionNode,
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
dimensionStart: FloorplanPoint,
|
||||
dimensionEnd: FloorplanPoint,
|
||||
offsetNormal: FloorplanPoint,
|
||||
text: string,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start,
|
||||
end,
|
||||
dimensionStart,
|
||||
dimensionEnd,
|
||||
offsetNormal,
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: node.extensionStartGap,
|
||||
extensionOvershoot: node.extensionOvershoot,
|
||||
terminator: node.terminator,
|
||||
textPosition: node.textPosition,
|
||||
text,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
function arcGeometry(
|
||||
center: FloorplanPoint,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
sweep: number,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
const start = arcPoint(center, radius, startAngle)
|
||||
const end = arcPoint(center, radius, startAngle + sweep)
|
||||
return {
|
||||
kind: 'path',
|
||||
d: `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep >= 0 ? 1 : 0} ${end[0]} ${end[1]}`,
|
||||
...lineStyle(stroke),
|
||||
}
|
||||
}
|
||||
|
||||
function styledLine(
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
stroke: string,
|
||||
strokeDasharray?: string,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: start[0],
|
||||
y1: start[1],
|
||||
x2: end[0],
|
||||
y2: end[1],
|
||||
...lineStyle(stroke, strokeDasharray),
|
||||
}
|
||||
}
|
||||
|
||||
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
|
||||
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
|
||||
}
|
||||
|
||||
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
|
||||
return {
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'butt',
|
||||
strokeLinejoin: 'miter',
|
||||
}
|
||||
}
|
||||
|
||||
function labelGeometry(
|
||||
point: FloorplanPoint,
|
||||
text: string,
|
||||
labelAngle: number,
|
||||
screenUpright = false,
|
||||
offsetPx = 0,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'dimension-label',
|
||||
cx: point[0],
|
||||
cy: point[1],
|
||||
text,
|
||||
angle: labelAngle,
|
||||
screenUpright,
|
||||
offsetPx,
|
||||
appearance: 'outlined',
|
||||
}
|
||||
}
|
||||
|
||||
function centerMark(
|
||||
center: FloorplanPoint,
|
||||
radius: number,
|
||||
stroke: string,
|
||||
force = false,
|
||||
): FloorplanGeometry[] {
|
||||
if (!force && radius <= EPSILON) return []
|
||||
const half = Math.min(0.22, Math.max(0.1, radius * 0.18))
|
||||
const gap = Math.min(0.045, half * 0.3)
|
||||
return [
|
||||
styledLine([center[0] - half, center[1]], [center[0] - gap, center[1]], stroke),
|
||||
styledLine([center[0] + gap, center[1]], [center[0] + half, center[1]], stroke),
|
||||
styledLine([center[0], center[1] - half], [center[0], center[1] - gap], stroke),
|
||||
styledLine([center[0], center[1] + gap], [center[0], center[1] + half], stroke),
|
||||
]
|
||||
}
|
||||
|
||||
function openArrow(
|
||||
tip: FloorplanPoint,
|
||||
toward: FloorplanPoint,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const direction = normalized(tip, toward)
|
||||
if (!direction) return []
|
||||
const length = 0.15
|
||||
const halfWidth = 0.055
|
||||
const base: FloorplanPoint = [tip[0] + direction[0] * length, tip[1] + direction[1] * length]
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
return [
|
||||
styledLine(tip, [base[0] + normal[0] * halfWidth, base[1] + normal[1] * halfWidth], stroke),
|
||||
styledLine(tip, [base[0] - normal[0] * halfWidth, base[1] - normal[1] * halfWidth], stroke),
|
||||
]
|
||||
}
|
||||
|
||||
function baselineHandle(point: FloorplanPoint): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'endpoint-handle',
|
||||
point,
|
||||
state: 'idle',
|
||||
variant: 'curve',
|
||||
affordance: 'move-construction-dimension-baseline',
|
||||
payload: null,
|
||||
}
|
||||
}
|
||||
|
||||
function anchorHandles(points: readonly MeasurementPoint[]): FloorplanGeometry[] {
|
||||
return witnessHandles(points.map((point): FloorplanPoint => [point[0], point[2]]))
|
||||
}
|
||||
|
||||
function witnessHandles(points: readonly FloorplanPoint[]): FloorplanGeometry[] {
|
||||
return points.map((point, witnessIndex) => ({
|
||||
kind: 'endpoint-handle',
|
||||
point,
|
||||
state: 'idle',
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex },
|
||||
}))
|
||||
}
|
||||
|
||||
function hitLine(start: FloorplanPoint, end: FloorplanPoint): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'hit-line',
|
||||
x1: start[0],
|
||||
y1: start[1],
|
||||
x2: end[0],
|
||||
y2: end[1],
|
||||
strokeWidthPx: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function arcPoint(center: FloorplanPoint, radius: number, pointAngle: number): FloorplanPoint {
|
||||
return [center[0] + Math.cos(pointAngle) * radius, center[1] + Math.sin(pointAngle) * radius]
|
||||
}
|
||||
|
||||
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const magnitude = Math.hypot(dx, dy)
|
||||
return magnitude <= EPSILON ? null : [dx / magnitude, dy / magnitude]
|
||||
}
|
||||
|
||||
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.atan2(second[1] - first[1], second[0] - first[0])
|
||||
}
|
||||
|
||||
function formatDegrees(value: number): string {
|
||||
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanPoint,
|
||||
MeasurementPoint,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ConstructionDimensionSegmentLayout = {
|
||||
dimensionStart: FloorplanPoint
|
||||
dimensionEnd: FloorplanPoint
|
||||
value: number
|
||||
witnessStart: FloorplanPoint
|
||||
witnessEnd: FloorplanPoint
|
||||
}
|
||||
|
||||
export type ConstructionDimensionLayout = {
|
||||
dimensionPoints: FloorplanPoint[]
|
||||
direction: FloorplanPoint
|
||||
midpoint: FloorplanPoint
|
||||
normal: FloorplanPoint
|
||||
segments: ConstructionDimensionSegmentLayout[]
|
||||
witnessPoints: FloorplanPoint[]
|
||||
}
|
||||
|
||||
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
export type CircularConstructionDimensionLayout = {
|
||||
center: FloorplanPoint
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint | null
|
||||
radius: number
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
sweep: number
|
||||
chordLength: number
|
||||
arcLength: number
|
||||
}
|
||||
|
||||
export function resolveCircularConstructionDimensionLayout(
|
||||
mode: ConstructionDimensionMode,
|
||||
anchors: readonly MeasurementPoint[],
|
||||
): CircularConstructionDimensionLayout | null {
|
||||
if (anchors.length < 2) return null
|
||||
const first = project(anchors[0]!)
|
||||
const second = project(anchors[1]!)
|
||||
|
||||
if (mode === 'diameter') {
|
||||
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
|
||||
const radius = distance(first, second) / 2
|
||||
if (radius <= 1e-9) return null
|
||||
return {
|
||||
center,
|
||||
start: first,
|
||||
end: second,
|
||||
radius,
|
||||
startAngle: Math.atan2(first[1] - center[1], first[0] - center[0]),
|
||||
endAngle: Math.atan2(second[1] - center[1], second[0] - center[0]),
|
||||
sweep: Math.PI,
|
||||
chordLength: radius * 2,
|
||||
arcLength: Math.PI * radius,
|
||||
}
|
||||
}
|
||||
|
||||
const usesMiddleCenter = mode === 'arc-length' || mode === 'angular'
|
||||
const center = usesMiddleCenter ? second : first
|
||||
const start = usesMiddleCenter ? first : second
|
||||
const radius = distance(center, start)
|
||||
if (radius <= 1e-9) return null
|
||||
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0])
|
||||
const endAnchor = anchors[2]
|
||||
const end = endAnchor ? project(endAnchor) : null
|
||||
const endAngle = end ? Math.atan2(end[1] - center[1], end[0] - center[0]) : startAngle
|
||||
const sweep = end ? normalizedSignedSweep(startAngle, endAngle) : 0
|
||||
return {
|
||||
center,
|
||||
start,
|
||||
end,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
sweep,
|
||||
chordLength: end ? distance(start, end) : radius,
|
||||
arcLength: Math.abs(sweep) * radius,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedSignedSweep(startAngle: number, endAngle: number): number {
|
||||
let sweep = endAngle - startAngle
|
||||
while (sweep > Math.PI) sweep -= Math.PI * 2
|
||||
while (sweep <= -Math.PI) sweep += Math.PI * 2
|
||||
return sweep
|
||||
}
|
||||
|
||||
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
export function resolveConstructionDimensionLayout(
|
||||
node: Pick<ConstructionDimensionNode, 'baseline' | 'chainMode'>,
|
||||
anchors: readonly MeasurementPoint[],
|
||||
): ConstructionDimensionLayout {
|
||||
if (anchors.length < 2) {
|
||||
throw new Error('Construction dimension layout requires at least two anchors')
|
||||
}
|
||||
const magnitude = Math.hypot(node.baseline.direction[0], node.baseline.direction[1])
|
||||
const direction: FloorplanPoint = [
|
||||
node.baseline.direction[0] / magnitude,
|
||||
node.baseline.direction[1] / magnitude,
|
||||
]
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const witnessPoints = anchors.map(project)
|
||||
const dimensionPoints = witnessPoints.map((point): FloorplanPoint => {
|
||||
const deltaX = point[0] - node.baseline.origin[0]
|
||||
const deltaY = point[1] - node.baseline.origin[1]
|
||||
const distance = deltaX * direction[0] + deltaY * direction[1]
|
||||
return [
|
||||
node.baseline.origin[0] + distance * direction[0],
|
||||
node.baseline.origin[1] + distance * direction[1],
|
||||
]
|
||||
})
|
||||
const segmentIndexes =
|
||||
node.chainMode === 'continuous'
|
||||
? witnessPoints.slice(0, -1).map((_, index) => [index, index + 1] as const)
|
||||
: Array.from(
|
||||
{ length: Math.floor(witnessPoints.length / 2) },
|
||||
(_, index) => [index * 2, index * 2 + 1] as const,
|
||||
)
|
||||
const segments = segmentIndexes.map(([startIndex, endIndex]) => {
|
||||
const witnessStart = witnessPoints[startIndex]!
|
||||
const witnessEnd = witnessPoints[endIndex]!
|
||||
const dimensionStart = dimensionPoints[startIndex]!
|
||||
const dimensionEnd = dimensionPoints[endIndex]!
|
||||
return {
|
||||
dimensionStart,
|
||||
dimensionEnd,
|
||||
value: Math.abs(
|
||||
(witnessEnd[0] - witnessStart[0]) * direction[0] +
|
||||
(witnessEnd[1] - witnessStart[1]) * direction[1],
|
||||
),
|
||||
witnessStart,
|
||||
witnessEnd,
|
||||
}
|
||||
})
|
||||
const first = dimensionPoints[0]!
|
||||
const last = dimensionPoints.at(-1)!
|
||||
return {
|
||||
dimensionPoints,
|
||||
direction,
|
||||
midpoint: [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2],
|
||||
normal,
|
||||
segments,
|
||||
witnessPoints,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { constructionDimensionDefinition } from './definition'
|
||||
export { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
export {
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionDatumPolicy,
|
||||
type ConstructionDimensionDrawingPresentation,
|
||||
type ConstructionDimensionImperialPrecision,
|
||||
type ConstructionDimensionMetricNotation,
|
||||
type ConstructionDimensionNode,
|
||||
type ConstructionDimensionTerminator,
|
||||
type ConstructionDimensionTextPosition,
|
||||
type ConstructionDrawingType,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingSuppressedSegments,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useDrawingView,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
const MODE_LABELS: Record<ConstructionDimensionNode['mode'], string> = {
|
||||
linear: 'Linear',
|
||||
radius: 'Radius',
|
||||
diameter: 'Diameter',
|
||||
'center-mark': 'Center mark',
|
||||
chord: 'Chord',
|
||||
'arc-length': 'Arc length',
|
||||
angular: 'Angular',
|
||||
coordinate: 'Coordinate',
|
||||
}
|
||||
|
||||
const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [
|
||||
{ label: 'Centerline', value: 'centerline' },
|
||||
{ label: 'Wall face', value: 'wall-face' },
|
||||
{ label: 'Structural face', value: 'structural-face' },
|
||||
{ label: 'Finish face', value: 'finish-face' },
|
||||
]
|
||||
|
||||
const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [
|
||||
{ label: 'Architectural tick', value: 'architectural-tick' },
|
||||
{ label: 'Filled arrow', value: 'filled-arrow' },
|
||||
{ label: 'Open arrow', value: 'open-arrow' },
|
||||
{ label: 'Dot', value: 'dot' },
|
||||
]
|
||||
|
||||
const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [
|
||||
{ label: 'Above line', value: 'above' },
|
||||
{ label: 'Centered on line', value: 'centered' },
|
||||
]
|
||||
|
||||
const IMPERIAL_PRECISION_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConstructionDimensionImperialPrecision
|
||||
}> = [
|
||||
{ label: 'Nearest inch', value: '1' },
|
||||
{ label: 'Nearest 1/2 inch', value: '1/2' },
|
||||
{ label: 'Nearest 1/4 inch', value: '1/4' },
|
||||
{ label: 'Nearest 1/8 inch', value: '1/8' },
|
||||
{ label: 'Nearest 1/16 inch', value: '1/16' },
|
||||
]
|
||||
|
||||
const METRIC_NOTATION_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConstructionDimensionMetricNotation
|
||||
}> = [
|
||||
{ label: 'Meters', value: 'meters' },
|
||||
{ label: 'Millimeters', value: 'millimeters' },
|
||||
]
|
||||
|
||||
export default function ConstructionDimensionPanel() {
|
||||
const selectedId = useViewer((state) => state.selection.selectedIds[0])
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const dimension = useScene((state) => {
|
||||
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined
|
||||
return node?.type === 'construction-dimension' ? node : null
|
||||
})
|
||||
const foundationControllers = useScene(
|
||||
useShallow((state) =>
|
||||
Object.values(state.nodes).filter(
|
||||
(candidate): candidate is ConstructionDimensionNode =>
|
||||
candidate.type === 'construction-dimension' &&
|
||||
candidate.id !== dimension?.id &&
|
||||
candidate.drawingType === 'foundation-plan',
|
||||
),
|
||||
),
|
||||
)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const deleteNode = useScene((state) => state.deleteNode)
|
||||
const activeDrawingType = useDrawingView((state) => state.drawingType)
|
||||
|
||||
if (!(dimension && selectedId)) return null
|
||||
const update = (patch: Partial<ConstructionDimensionNode>) => updateNode(dimension.id, patch)
|
||||
const supportsCenterMark = ['radius', 'diameter', 'arc-length', 'angular'].includes(
|
||||
dimension.mode,
|
||||
)
|
||||
const activeDrawingLabel =
|
||||
DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan'
|
||||
const activePresentation = resolveConstructionDimensionDrawingPresentation(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
)
|
||||
const activeDrawingOverride = resolveConstructionDimensionDrawingOverride(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
)
|
||||
const suppressedSegmentsText = formatSuppressedSegments(
|
||||
activeDrawingOverride?.suppressedSegmentIndexes ?? [],
|
||||
)
|
||||
const updateDrawingPresentation = (
|
||||
drawingType: ConstructionDrawingType,
|
||||
presentation: ConstructionDimensionDrawingPresentation,
|
||||
) => {
|
||||
const drawingOverrides = setConstructionDimensionDrawingPresentation(
|
||||
dimension,
|
||||
drawingType,
|
||||
presentation,
|
||||
)
|
||||
update({
|
||||
drawingOverrides,
|
||||
...(presentation === 'controlled' && !dimension.controllingDimensionId
|
||||
? { controllingDimensionId: foundationControllers[0]?.id ?? null }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
const updateSuppressedSegments = (value: string) => {
|
||||
update({
|
||||
drawingOverrides: setConstructionDimensionDrawingSuppressedSegments(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
parseSuppressedSegments(value),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/blueprint.webp"
|
||||
onClose={() => setSelection({ selectedIds: [] })}
|
||||
title="Construction Dimension"
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Dimension">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Mode</span>
|
||||
<span className="font-medium text-foreground">{MODE_LABELS[dimension.mode]}</span>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Feature count"
|
||||
max={999}
|
||||
min={1}
|
||||
onChange={(featureCount) => update({ featureCount })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={dimension.featureCount}
|
||||
/>
|
||||
{supportsCenterMark ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Center mark</span>
|
||||
<input
|
||||
checked={dimension.showCenterMark}
|
||||
onChange={(event) => update({ showCenterMark: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Drawing coordination">
|
||||
<SelectField
|
||||
label="Primary drawing"
|
||||
onChange={(drawingType) =>
|
||||
update({ drawingType: drawingType as ConstructionDrawingType })
|
||||
}
|
||||
options={DRAWING_TYPE_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={dimension.drawingType}
|
||||
/>
|
||||
<SelectField
|
||||
label={`${activeDrawingLabel} presentation`}
|
||||
onChange={(presentation) =>
|
||||
updateDrawingPresentation(
|
||||
activeDrawingType,
|
||||
presentation as ConstructionDimensionDrawingPresentation,
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ label: 'Shown', value: 'shown' },
|
||||
{ label: 'Omitted', value: 'omit' },
|
||||
...(activeDrawingType === 'floor-plan'
|
||||
? [{ label: 'Controlled by foundation', value: 'controlled' }]
|
||||
: []),
|
||||
]}
|
||||
value={activePresentation}
|
||||
/>
|
||||
{activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? (
|
||||
<SelectField
|
||||
disabled={foundationControllers.length === 0}
|
||||
label="Foundation controller"
|
||||
onChange={(controllingDimensionId) =>
|
||||
update({
|
||||
controllingDimensionId: controllingDimensionId as NonNullable<
|
||||
ConstructionDimensionNode['controllingDimensionId']
|
||||
>,
|
||||
})
|
||||
}
|
||||
options={foundationControllers.map((controller) => ({
|
||||
label: controller.name || 'Foundation dimension',
|
||||
value: controller.id,
|
||||
}))}
|
||||
placeholder="No foundation dimensions"
|
||||
value={dimension.controllingDimensionId ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Linked dimensions reuse the controller's associative anchors and update with it.
|
||||
</p>
|
||||
<TextField
|
||||
label={`${activeDrawingLabel} suppressed segments`}
|
||||
onCommit={updateSuppressedSegments}
|
||||
placeholder="e.g. 2, 4"
|
||||
value={suppressedSegmentsText}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Segment numbers are one-based and apply only in this drawing view.
|
||||
</p>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Notation">
|
||||
<TextField
|
||||
label="Prefix"
|
||||
onCommit={(prefix) => update({ prefix })}
|
||||
value={dimension.prefix}
|
||||
/>
|
||||
<TextField
|
||||
label="Suffix"
|
||||
onCommit={(suffix) => update({ suffix })}
|
||||
value={dimension.suffix}
|
||||
/>
|
||||
<TextField
|
||||
label="Text override"
|
||||
onCommit={(textOverride) => update({ textOverride: textOverride || null })}
|
||||
placeholder="Use measured value"
|
||||
value={dimension.textOverride ?? ''}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Standards">
|
||||
<SelectField
|
||||
label="Datum policy"
|
||||
onChange={(datumPolicy) =>
|
||||
update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy })
|
||||
}
|
||||
options={DATUM_POLICY_OPTIONS}
|
||||
value={dimension.datumPolicy}
|
||||
/>
|
||||
<SelectField
|
||||
label="Terminator"
|
||||
onChange={(terminator) =>
|
||||
update({ terminator: terminator as ConstructionDimensionTerminator })
|
||||
}
|
||||
options={TERMINATOR_OPTIONS}
|
||||
value={dimension.terminator}
|
||||
/>
|
||||
<SelectField
|
||||
label="Text position"
|
||||
onChange={(textPosition) =>
|
||||
update({ textPosition: textPosition as ConstructionDimensionTextPosition })
|
||||
}
|
||||
options={TEXT_POSITION_OPTIONS}
|
||||
value={dimension.textPosition}
|
||||
/>
|
||||
<SelectField
|
||||
label="Imperial precision"
|
||||
onChange={(imperialPrecision) =>
|
||||
update({
|
||||
imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision,
|
||||
})
|
||||
}
|
||||
options={IMPERIAL_PRECISION_OPTIONS}
|
||||
value={dimension.imperialPrecision}
|
||||
/>
|
||||
<SelectField
|
||||
label="Metric notation"
|
||||
onChange={(metricNotation) =>
|
||||
update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation })
|
||||
}
|
||||
options={METRIC_NOTATION_OPTIONS}
|
||||
value={dimension.metricNotation}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Extension gap"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(extensionStartGap) => update({ extensionStartGap })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
value={dimension.extensionStartGap}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Extension overshoot"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(extensionOvershoot) => update({ extensionOvershoot })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
value={dimension.extensionOvershoot}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:structure-delete')
|
||||
deleteNode(dimension.id)
|
||||
setSelection({ selectedIds: [] })
|
||||
}}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function parseSuppressedSegments(value: string): number[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[,\s]+/)
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((index) => Number.isInteger(index) && index > 0)
|
||||
.map((index) => index - 1),
|
||||
),
|
||||
].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
function formatSuppressedSegments(indexes: readonly number[]): string {
|
||||
return indexes.map((index) => index + 1).join(', ')
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{placeholder && options.length === 0 ? <option value="">{placeholder}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onCommit: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground"
|
||||
defaultValue={value}
|
||||
key={value}
|
||||
onBlur={(event) => onCommit(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConstructionDimensionNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const constructionDimensionParametrics: ParametricDescriptor<ConstructionDimensionNode> = {
|
||||
groups: [],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
ConstructionDimensionBaseline,
|
||||
ConstructionDimensionChainMode,
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
RoofSegmentNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import {
|
||||
buildDoorFloorplanSchedule,
|
||||
computeDoorFloorplanLevelData,
|
||||
} from '../shared/opening-documentation'
|
||||
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
@@ -164,9 +169,14 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
kind: 'door',
|
||||
snapProfile: 'item',
|
||||
facingIndicator: true,
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: DoorNode,
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
schedule: buildDoorFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<DoorNodeType>,
|
||||
},
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
// Leverage the schema's zod `.default()` annotations to compute the
|
||||
@@ -222,6 +232,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
||||
// direction + perpendicular for the cutout footprint.
|
||||
floorplan: buildDoorFloorplan,
|
||||
computeFloorplanLevelData: computeDoorFloorplanLevelData,
|
||||
floorplanDependsOnSiblings: true,
|
||||
// Opening symbols position from `ctx.parent` (the host wall); merge the
|
||||
// walls' live drag overrides so the symbol tracks a wall / group drag in
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
GeometryContext,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
buildOpeningMarkAnnotation,
|
||||
type OpeningFloorplanLevelData,
|
||||
} from '../shared/opening-documentation'
|
||||
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
|
||||
|
||||
/**
|
||||
@@ -722,7 +727,38 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
const markAnnotation = buildOpeningMarkAnnotation(
|
||||
node,
|
||||
wall,
|
||||
ctx.levelData as OpeningFloorplanLevelData | undefined,
|
||||
{
|
||||
preferredSide: swingSign === 1 ? -1 : 1,
|
||||
stroke: showSelectedChrome ? '#f97316' : '#334155',
|
||||
},
|
||||
)
|
||||
if (markAnnotation) children.push(markAnnotation)
|
||||
|
||||
return { kind: 'group', children: children.map(markDoorPlanObstacle) }
|
||||
}
|
||||
|
||||
function markDoorPlanObstacle(geometry: FloorplanGeometry): FloorplanGeometry {
|
||||
if (geometry.kind === 'group') {
|
||||
const isOpeningMark = readFloorplanGeometryMetadata(geometry).annotationRole === 'opening-mark'
|
||||
return isOpeningMark
|
||||
? geometry
|
||||
: { ...geometry, children: geometry.children.map(markDoorPlanObstacle) }
|
||||
}
|
||||
if (
|
||||
geometry.kind === 'path' ||
|
||||
geometry.kind === 'polygon' ||
|
||||
geometry.kind === 'polyline' ||
|
||||
geometry.kind === 'rect' ||
|
||||
geometry.kind === 'circle' ||
|
||||
geometry.kind === 'line'
|
||||
) {
|
||||
return withFloorplanGeometryMetadata(geometry, { annotationObstacle: 'bounds' })
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { OpeningDocumentationFields } from '../shared/opening-documentation-fields'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
|
||||
const doorTypeOptions = [
|
||||
@@ -254,6 +255,7 @@ export default function DoorPanel() {
|
||||
useScene.temporal.getState().pause()
|
||||
const cloned = structuredClone(node) as any
|
||||
delete cloned.id
|
||||
delete cloned.mark
|
||||
cloned.metadata = { ...cloned.metadata, isNew: true }
|
||||
const duplicate = DoorNode.parse(cloned)
|
||||
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||
@@ -583,6 +585,21 @@ export default function DoorPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Documentation">
|
||||
<OpeningDocumentationFields
|
||||
constructionType={node.constructionType}
|
||||
dimensionReference={node.dimensionReference}
|
||||
finishOpeningHeight={node.finishOpeningHeight}
|
||||
finishOpeningWidth={node.finishOpeningWidth}
|
||||
mark={node.mark}
|
||||
masonryOpeningHeight={node.masonryOpeningHeight}
|
||||
masonryOpeningWidth={node.masonryOpeningWidth}
|
||||
onChange={handleUpdate}
|
||||
roughOpeningHeight={node.roughOpeningHeight}
|
||||
roughOpeningWidth={node.roughOpeningWidth}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { DoorNode } from '@pascal-app/core'
|
||||
export {
|
||||
DoorNode,
|
||||
OpeningConstructionType,
|
||||
OpeningDimensionReference,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { drawingSheetDefinition } from './definition'
|
||||
|
||||
describe('drawingSheetDefinition', () => {
|
||||
test('registers persistent drawing sheets as non-geometric document nodes', () => {
|
||||
expect(drawingSheetDefinition.kind).toBe('drawing-sheet')
|
||||
expect(drawingSheetDefinition.bake).toBe('strip')
|
||||
expect(drawingSheetDefinition.schemaVersion).toBe(4)
|
||||
expect(drawingSheetDefinition.dirtyTracking).toBe(false)
|
||||
expect(drawingSheetDefinition.capabilities).toMatchObject({
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
drawingSheetDefinition.schema.safeParse({
|
||||
id: 'drawing-sheet_default',
|
||||
type: 'drawing-sheet',
|
||||
...drawingSheetDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('contributes drawing-sheet matching through the editor extension', () => {
|
||||
const sheet = drawingSheetDefinition.schema.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_floor',
|
||||
levelId: 'level_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '1',
|
||||
title: 'Main floor',
|
||||
scale: '1:50',
|
||||
},
|
||||
],
|
||||
})
|
||||
const resolveDrawingSheet =
|
||||
getFloorplanNodeExtension(drawingSheetDefinition)?.resolveDrawingSheet
|
||||
|
||||
expect(
|
||||
resolveDrawingSheet?.({ node: sheet, levelId: 'level_main', drawingType: 'floor-plan' }),
|
||||
).toBe(sheet)
|
||||
expect(
|
||||
resolveDrawingSheet?.({ node: sheet, levelId: 'level_upper', drawingType: 'floor-plan' }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { DrawingSheetNode as DrawingSheetNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { DrawingSheetNode } from './schema'
|
||||
|
||||
export const drawingSheetDefinition: NodeDefinition<typeof DrawingSheetNode> = {
|
||||
kind: 'drawing-sheet',
|
||||
bake: 'strip',
|
||||
schemaVersion: 4,
|
||||
schema: DrawingSheetNode,
|
||||
category: 'analysis',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
|
||||
node.placedViews.some(
|
||||
(view) =>
|
||||
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
|
||||
)
|
||||
? node
|
||||
: null,
|
||||
} satisfies FloorplanNodeExtension<DrawingSheetNodeSchema>,
|
||||
},
|
||||
|
||||
defaults: () => {
|
||||
const stub = DrawingSheetNodeSchema.parse({
|
||||
id: 'drawing-sheet_default' as never,
|
||||
type: 'drawing-sheet',
|
||||
})
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
|
||||
presentation: {
|
||||
label: 'Drawing Sheet',
|
||||
description: 'A persistent construction-document sheet with placed views and title-block data.',
|
||||
icon: { kind: 'iconify', name: 'lucide:file-text' },
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A persistent construction-document sheet containing paper setup, placed drawing views, notes, schedules, and title-block metadata.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { drawingSheetDefinition } from './definition'
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
} from '@pascal-app/core'
|
||||
@@ -162,6 +162,7 @@ function toMiterWall(segment: SegmentLike): WallNode {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
thickness: segment.thickness,
|
||||
|
||||
@@ -5,10 +5,12 @@ import { cabinetDefinition, cabinetModuleDefinition } from './cabinet'
|
||||
import { ceilingDefinition } from './ceiling'
|
||||
import { chimneyDefinition } from './chimney'
|
||||
import { columnDefinition } from './column'
|
||||
import { constructionDimensionDefinition } from './construction-dimension'
|
||||
import { cupolaDefinition } from './cupola'
|
||||
import { doorDefinition } from './door'
|
||||
import { dormerDefinition } from './dormer'
|
||||
import { downspoutDefinition } from './downspout'
|
||||
import { drawingSheetDefinition } from './drawing-sheet'
|
||||
import { ductFittingDefinition } from './duct-fitting'
|
||||
import { ductSegmentDefinition } from './duct-segment'
|
||||
import { ductTerminalDefinition } from './duct-terminal'
|
||||
@@ -38,6 +40,7 @@ import { solarPanelDefinition } from './solar-panel'
|
||||
import { spawnDefinition } from './spawn'
|
||||
import { stairDefinition } from './stair'
|
||||
import { stairSegmentDefinition } from './stair-segment'
|
||||
import { structuralGridDefinition } from './structural-grid'
|
||||
import { turbineVentDefinition } from './turbine-vent'
|
||||
import { wallDefinition } from './wall'
|
||||
import { windowDefinition } from './window'
|
||||
@@ -90,6 +93,9 @@ export const builtinPlugin: Plugin = {
|
||||
guideDefinition as unknown as AnyNodeDefinition,
|
||||
scanDefinition as unknown as AnyNodeDefinition,
|
||||
measurementDefinition as unknown as AnyNodeDefinition,
|
||||
constructionDimensionDefinition as unknown as AnyNodeDefinition,
|
||||
drawingSheetDefinition as unknown as AnyNodeDefinition,
|
||||
structuralGridDefinition as unknown as AnyNodeDefinition,
|
||||
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
|
||||
boxVentDefinition as unknown as AnyNodeDefinition,
|
||||
ridgeVentDefinition as unknown as AnyNodeDefinition,
|
||||
@@ -130,10 +136,12 @@ export {
|
||||
export { ceilingDefinition } from './ceiling'
|
||||
export { chimneyDefinition } from './chimney'
|
||||
export { columnDefinition } from './column'
|
||||
export { constructionDimensionDefinition } from './construction-dimension'
|
||||
export { cupolaDefinition } from './cupola'
|
||||
export { doorDefinition } from './door'
|
||||
export { dormerDefinition } from './dormer'
|
||||
export { downspoutDefinition } from './downspout'
|
||||
export { drawingSheetDefinition } from './drawing-sheet'
|
||||
export { ductFittingDefinition } from './duct-fitting'
|
||||
export { ductSegmentDefinition } from './duct-segment'
|
||||
export { ductTerminalDefinition } from './duct-terminal'
|
||||
@@ -155,6 +163,35 @@ export { ridgeVentDefinition } from './ridge-vent'
|
||||
export { roofDefinition } from './roof'
|
||||
export { roofSegmentDefinition } from './roof-segment'
|
||||
export { scanDefinition } from './scan'
|
||||
export {
|
||||
type BuildClearanceAdvisoriesOptions,
|
||||
buildClearanceAdvisories,
|
||||
type ClearanceAdvisory,
|
||||
type ClearanceAdvisoryCategory,
|
||||
type ClearanceAdvisorySeverity,
|
||||
type ClearanceEvidence,
|
||||
type ClearanceProfile,
|
||||
type ClearanceRule,
|
||||
type ClearanceRuleSource,
|
||||
DEFAULT_CLEARANCE_PROFILES,
|
||||
} from './shared/clearance-advisories'
|
||||
export {
|
||||
type BuildConstructionModuleAdvisoriesOptions,
|
||||
buildConstructionModuleAdvisories,
|
||||
type ConstructionModuleAdvisory,
|
||||
type ConstructionModuleAdvisorySeverity,
|
||||
type ConstructionModuleMeasurementKind,
|
||||
type ConstructionModuleProfile,
|
||||
type ConstructionModuleSystem,
|
||||
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
|
||||
} from './shared/construction-module-advisories'
|
||||
export {
|
||||
type BuildDimensionCompletenessAuditOptions,
|
||||
buildDimensionCompletenessAudit,
|
||||
type DimensionCompletenessIssue,
|
||||
type DimensionCompletenessIssueKind,
|
||||
type DimensionCompletenessIssueSeverity,
|
||||
} from './shared/dimension-completeness-audit'
|
||||
export { shelfDefinition } from './shelf'
|
||||
export { siteDefinition } from './site'
|
||||
export { skylightDefinition } from './skylight'
|
||||
@@ -163,6 +200,7 @@ export { solarPanelDefinition } from './solar-panel'
|
||||
export { spawnDefinition } from './spawn'
|
||||
export { stairDefinition } from './stair'
|
||||
export { stairSegmentDefinition } from './stair-segment'
|
||||
export { structuralGridDefinition } from './structural-grid'
|
||||
export { turbineVentDefinition } from './turbine-vent'
|
||||
export { wallDefinition } from './wall'
|
||||
export { windowDefinition } from './window'
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core'
|
||||
import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor'
|
||||
import {
|
||||
createFloorplanContextExtensions,
|
||||
MEASUREMENT_ACTIVE_COLOR,
|
||||
MEASUREMENT_FLOORPLAN_COLOR,
|
||||
} from '@pascal-app/editor'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
|
||||
const palette = {
|
||||
@@ -21,7 +25,11 @@ const palette = {
|
||||
measurementLabelText: '#0f172a',
|
||||
}
|
||||
|
||||
const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({
|
||||
const context = (
|
||||
unit: 'metric' | 'imperial',
|
||||
selected = false,
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
): GeometryContext => ({
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
@@ -34,6 +42,7 @@ const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ metricNotation }),
|
||||
})
|
||||
|
||||
const labels = (geometry: FloorplanGeometry): string[] => {
|
||||
@@ -69,6 +78,23 @@ describe('buildMeasurementFloorplan', () => {
|
||||
).toMatchObject({ appearance: 'outlined' })
|
||||
})
|
||||
|
||||
test('formats metric distance labels in millimeters', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_distance_mm',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[3.048, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const metric = buildMeasurementFloorplan(node, context('metric', false, 'millimeters'))
|
||||
expect(metric && labels(metric)).toEqual(['3048mm'])
|
||||
})
|
||||
|
||||
test('uses indigo analysis colors in plan view', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_appearance',
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
formatVolumeLabel,
|
||||
measurementFloorplanPresentationColor,
|
||||
measurementPolygonLabelAnchor,
|
||||
readFloorplanContext,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from '@pascal-app/editor'
|
||||
import { measurementResolvedEditPoints } from './edit'
|
||||
import { resolveMeasurementNode } from './resolve'
|
||||
@@ -46,6 +48,7 @@ export function buildMeasurementFloorplan(
|
||||
if (node.visible === false) return null
|
||||
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const metricNotation = readFloorplanContext(ctx).metricNotation
|
||||
const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id))
|
||||
const measurement = resolved.payload
|
||||
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
|
||||
@@ -85,77 +88,83 @@ export function buildMeasurementFloorplan(
|
||||
]
|
||||
: []
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1, y1, x2, y2, ...style },
|
||||
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
|
||||
...collapsedHitTarget,
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x2,
|
||||
cy: y2,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: (x1 + x2) / 2,
|
||||
cy: (y1 + y2) / 2,
|
||||
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`,
|
||||
angle: Math.atan2(y2 - y1, x2 - x1),
|
||||
offsetPx: 14,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
return withFloorplanGeometryMetadata(
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1, y1, x2, y2, ...style },
|
||||
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
|
||||
...collapsedHitTarget,
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x2,
|
||||
cy: y2,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: (x1 + x2) / 2,
|
||||
cy: (y1 + y2) / 2,
|
||||
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation)}`,
|
||||
angle: Math.atan2(y2 - y1, x2 - x1),
|
||||
offsetPx: 14,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
},
|
||||
{ annotationRole: 'measurement' },
|
||||
)
|
||||
}
|
||||
|
||||
if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
|
||||
const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
|
||||
...style,
|
||||
},
|
||||
...(angleArc.length >= 2
|
||||
? [
|
||||
{
|
||||
kind: 'polyline' as const,
|
||||
points: angleArc.map(projectPoint),
|
||||
...style,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
|
||||
angle: 0,
|
||||
offsetPx: 10,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
return withFloorplanGeometryMetadata(
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
|
||||
...style,
|
||||
},
|
||||
...(angleArc.length >= 2
|
||||
? [
|
||||
{
|
||||
kind: 'polyline' as const,
|
||||
points: angleArc.map(projectPoint),
|
||||
...style,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
|
||||
angle: 0,
|
||||
offsetPx: 10,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
},
|
||||
{ annotationRole: 'measurement' },
|
||||
)
|
||||
}
|
||||
|
||||
if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
|
||||
@@ -163,31 +172,34 @@ export function buildMeasurementFloorplan(
|
||||
const label =
|
||||
measurement.kind === 'area'
|
||||
? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit, metricNotation)}`
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: measurement.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: centroid[0],
|
||||
cy: centroid[2],
|
||||
text: `${statusPrefix}${label}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
return withFloorplanGeometryMetadata(
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: measurement.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: centroid[0],
|
||||
cy: centroid[2],
|
||||
text: `${statusPrefix}${label}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
},
|
||||
{ annotationRole: 'measurement' },
|
||||
)
|
||||
}
|
||||
|
||||
const volume = measurement
|
||||
@@ -232,5 +244,8 @@ export function buildMeasurementFloorplan(
|
||||
})
|
||||
children.push(...editHandles)
|
||||
|
||||
return { kind: 'group', children }
|
||||
return withFloorplanGeometryMetadata(
|
||||
{ kind: 'group', children },
|
||||
{ annotationRole: 'measurement' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ export {
|
||||
type ResolvedMeasurement,
|
||||
type ResolvedMeasurementPayload,
|
||||
remapMeasurementReferences,
|
||||
resolveMeasurementAnchor,
|
||||
resolveMeasurementNode,
|
||||
} from './resolve'
|
||||
|
||||
@@ -170,7 +170,7 @@ export function measurementFeaturePoint(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnchor(
|
||||
export function resolveMeasurementAnchor(
|
||||
anchor: MeasurementAnchor,
|
||||
resolve: NodeResolver,
|
||||
): {
|
||||
@@ -227,7 +227,7 @@ export function resolveMeasurementNode(
|
||||
const dangling: MeasurementFeatureReference[] = []
|
||||
const anchorNormals: Array<MeasurementPoint | null> = []
|
||||
const point = (anchor: MeasurementAnchor) => {
|
||||
const result = resolveAnchor(anchor, resolve)
|
||||
const result = resolveMeasurementAnchor(anchor, resolve)
|
||||
if (result.dangling) dangling.push(result.dangling)
|
||||
anchorNormals.push(result.normal)
|
||||
return result.point
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
CabinetNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildClearanceAdvisories,
|
||||
type ClearanceProfile,
|
||||
DEFAULT_CLEARANCE_PROFILES,
|
||||
} from './clearance-advisories'
|
||||
|
||||
const adaProfile: ClearanceProfile = {
|
||||
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'us-ada-2010-advisory')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const officeProfile: ClearanceProfile = {
|
||||
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'office-residential-advisory')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
describe('clearance advisories', () => {
|
||||
test('keeps default clearance profiles optional and quiet', () => {
|
||||
const narrowHall = ZoneNode.parse({
|
||||
id: 'zone_hall',
|
||||
name: 'Hallway',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.8, 0],
|
||||
[0.8, 4],
|
||||
[0, 4],
|
||||
],
|
||||
})
|
||||
|
||||
expect(buildClearanceAdvisories(nodes(narrowHall))).toEqual([])
|
||||
})
|
||||
|
||||
test('checks circulation, entry, and door clear widths with ADA provenance', () => {
|
||||
const hall = ZoneNode.parse({
|
||||
id: 'zone_hall',
|
||||
name: 'North Corridor',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.8, 0],
|
||||
[0.8, 5],
|
||||
[0, 5],
|
||||
],
|
||||
})
|
||||
const entry = ZoneNode.parse({
|
||||
id: 'zone_entry',
|
||||
name: 'Entry vestibule',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.86, 0],
|
||||
[0.86, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_narrow',
|
||||
width: 0.78,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(hall, entry, door), {
|
||||
profiles: [adaProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
|
||||
'ada-door-clear-opening',
|
||||
'ada-entry-clear-width',
|
||||
'ada-accessible-route-clear-width',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.source.edition === '2010')).toBe(true)
|
||||
expect(advisories.every((advisory) => advisory.severity === 'warning')).toBe(true)
|
||||
})
|
||||
|
||||
test('reports missing fixture, cabinet, and appliance clearance evidence', () => {
|
||||
const toilet = ItemNode.parse({
|
||||
id: 'item_toilet',
|
||||
asset: {
|
||||
id: 'asset_toilet',
|
||||
category: 'plumbing',
|
||||
name: 'Accessible Toilet',
|
||||
thumbnail: '',
|
||||
src: 'asset://toilet.glb',
|
||||
tags: ['fixture'],
|
||||
},
|
||||
})
|
||||
const sinkCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_sink',
|
||||
stack: [{ id: 'sink', type: 'sink' }],
|
||||
})
|
||||
const applianceCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_dishwasher',
|
||||
stack: [{ id: 'dishwasher', type: 'dishwasher' }],
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(toilet, sinkCabinet, applianceCabinet), {
|
||||
profiles: [adaProfile, officeProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.id)).toEqual([
|
||||
'clearance:office-residential-advisory:cabinet_dishwasher:office-appliance-front-clearance',
|
||||
'clearance:office-residential-advisory:cabinet_dishwasher:office-cabinet-front-clearance',
|
||||
'clearance:office-residential-advisory:cabinet_sink:office-cabinet-front-clearance',
|
||||
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-depth',
|
||||
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-width',
|
||||
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-depth',
|
||||
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-width',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.measured === null)).toBe(true)
|
||||
expect(advisories.every((advisory) => advisory.severity === 'info')).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts explicit clearance evidence for surrounding cabinet and fixture checks', () => {
|
||||
const toilet = ItemNode.parse({
|
||||
id: 'item_toilet',
|
||||
asset: {
|
||||
id: 'asset_toilet',
|
||||
category: 'plumbing',
|
||||
name: 'Accessible Toilet',
|
||||
thumbnail: '',
|
||||
src: 'asset://toilet.glb',
|
||||
tags: ['fixture'],
|
||||
},
|
||||
})
|
||||
const cabinet = CabinetNode.parse({
|
||||
id: 'cabinet_base',
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(toilet, cabinet), {
|
||||
profiles: [adaProfile, officeProfile],
|
||||
evidence: {
|
||||
item_toilet: {
|
||||
'ada-fixture-clear-floor-width': 0.9,
|
||||
'ada-fixture-clear-floor-depth': 1.0,
|
||||
},
|
||||
cabinet_base: {
|
||||
'office-cabinet-front-clearance': 1.0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual(['ada-fixture-clear-floor-depth'])
|
||||
expect(advisories[0]?.measured).toBe(1)
|
||||
expect(advisories[0]?.severity).toBe('warning')
|
||||
})
|
||||
|
||||
test('checks closet depth and stair geometry from modeled dimensions', () => {
|
||||
const closet = ZoneNode.parse({
|
||||
id: 'zone_closet',
|
||||
name: 'Bedroom Closet',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.55, 0],
|
||||
[0.55, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_tall_riser',
|
||||
width: 0.82,
|
||||
totalRise: 2.8,
|
||||
stepCount: 12,
|
||||
})
|
||||
const segment = StairSegmentNode.parse({
|
||||
id: 'sseg_shallow_treads',
|
||||
width: 1,
|
||||
length: 2.2,
|
||||
height: 2,
|
||||
stepCount: 10,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(closet, stair, segment), {
|
||||
profiles: [officeProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
|
||||
'office-stair-tread-depth',
|
||||
'office-stair-riser-height',
|
||||
'office-stair-tread-depth',
|
||||
'office-stair-width',
|
||||
'office-closet-depth',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.source.title.includes('Pascal'))).toBe(true)
|
||||
})
|
||||
|
||||
test('can include disabled profiles for profile preview UIs', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_preview',
|
||||
width: 0.78,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(door), {
|
||||
includeDisabled: true,
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.profileId)).toEqual(['us-ada-2010-advisory'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type CabinetModuleNode,
|
||||
type CabinetNode,
|
||||
type DoorNode,
|
||||
type ItemNode,
|
||||
resolveStairTotalRise,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
|
||||
export type ClearanceAdvisoryCategory =
|
||||
| 'circulation'
|
||||
| 'entry'
|
||||
| 'door-approach'
|
||||
| 'fixture'
|
||||
| 'cabinet'
|
||||
| 'appliance'
|
||||
| 'closet'
|
||||
| 'stair'
|
||||
|
||||
export type ClearanceAdvisorySeverity = 'info' | 'warning'
|
||||
|
||||
export type ClearanceRuleSource = {
|
||||
title: string
|
||||
edition: string
|
||||
section: string
|
||||
url?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type ClearanceRule = {
|
||||
id: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
label: string
|
||||
measurement:
|
||||
| 'clear-width'
|
||||
| 'clear-depth'
|
||||
| 'clear-floor-width'
|
||||
| 'clear-floor-depth'
|
||||
| 'front-clearance'
|
||||
| 'stair-width'
|
||||
| 'tread-depth'
|
||||
| 'riser-height'
|
||||
minValue: number
|
||||
source: ClearanceRuleSource
|
||||
}
|
||||
|
||||
export type ClearanceProfile = {
|
||||
id: string
|
||||
label: string
|
||||
jurisdiction?: string
|
||||
enabled: boolean
|
||||
rules: readonly ClearanceRule[]
|
||||
}
|
||||
|
||||
export type ClearanceEvidence = Readonly<
|
||||
Record<string, Partial<Record<ClearanceRule['id'], number>>>
|
||||
>
|
||||
|
||||
export type BuildClearanceAdvisoriesOptions = {
|
||||
profiles?: readonly ClearanceProfile[]
|
||||
includeDisabled?: boolean
|
||||
evidence?: ClearanceEvidence
|
||||
}
|
||||
|
||||
export type ClearanceAdvisory = {
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
profileId: string
|
||||
profileLabel: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
ruleId: string
|
||||
label: string
|
||||
measured: number | null
|
||||
required: number
|
||||
severity: ClearanceAdvisorySeverity
|
||||
source: ClearanceRuleSource
|
||||
message: string
|
||||
}
|
||||
|
||||
type ClearanceTarget = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
measurements: Partial<Record<ClearanceRule['measurement'], number>>
|
||||
}
|
||||
|
||||
const ADA_2010: Pick<ClearanceRuleSource, 'title' | 'edition' | 'url'> = {
|
||||
title: '2010 ADA Standards for Accessible Design',
|
||||
edition: '2010',
|
||||
url: 'https://www.access-board.gov/ada/',
|
||||
}
|
||||
|
||||
const OFFICE_STANDARD: Pick<ClearanceRuleSource, 'title' | 'edition'> = {
|
||||
title: 'Pascal construction-document advisory profile',
|
||||
edition: '2026-07-21',
|
||||
}
|
||||
|
||||
export const DEFAULT_CLEARANCE_PROFILES: readonly ClearanceProfile[] = [
|
||||
{
|
||||
id: 'us-ada-2010-advisory',
|
||||
label: 'U.S. ADA 2010 advisory checks',
|
||||
jurisdiction: 'US',
|
||||
enabled: false,
|
||||
rules: [
|
||||
{
|
||||
id: 'ada-accessible-route-clear-width',
|
||||
category: 'circulation',
|
||||
label: 'accessible route clear width',
|
||||
measurement: 'clear-width',
|
||||
minValue: 36 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '403.5.1',
|
||||
note: 'Accessible routes generally require 36 inches minimum clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-entry-clear-width',
|
||||
category: 'entry',
|
||||
label: 'entry clear width',
|
||||
measurement: 'clear-width',
|
||||
minValue: 36 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '403.5.1',
|
||||
note: 'Entries serving an accessible route are checked against the route clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-door-clear-opening',
|
||||
category: 'door-approach',
|
||||
label: 'door clear opening',
|
||||
measurement: 'clear-width',
|
||||
minValue: 32 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '404.2.3',
|
||||
note: 'Door openings on accessible routes require 32 inches minimum clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-fixture-clear-floor-width',
|
||||
category: 'fixture',
|
||||
label: 'fixture clear floor space width',
|
||||
measurement: 'clear-floor-width',
|
||||
minValue: 30 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '305.3',
|
||||
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-fixture-clear-floor-depth',
|
||||
category: 'fixture',
|
||||
label: 'fixture clear floor space depth',
|
||||
measurement: 'clear-floor-depth',
|
||||
minValue: 48 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '305.3',
|
||||
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'office-residential-advisory',
|
||||
label: 'Office residential advisory checks',
|
||||
enabled: false,
|
||||
rules: [
|
||||
{
|
||||
id: 'office-cabinet-front-clearance',
|
||||
category: 'cabinet',
|
||||
label: 'cabinet front working clearance',
|
||||
measurement: 'front-clearance',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Kitchen working clearances',
|
||||
note: 'Office drafting convention for cabinet and drawer operation clearance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-appliance-front-clearance',
|
||||
category: 'appliance',
|
||||
label: 'appliance front working clearance',
|
||||
measurement: 'front-clearance',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Kitchen appliance clearances',
|
||||
note: 'Office drafting convention for appliance door and working clearance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-closet-depth',
|
||||
category: 'closet',
|
||||
label: 'closet clear depth',
|
||||
measurement: 'clear-depth',
|
||||
minValue: 0.6,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Storage clearances',
|
||||
note: 'Office drafting convention for reach-in closet depth.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-width',
|
||||
category: 'stair',
|
||||
label: 'stair clear width',
|
||||
measurement: 'stair-width',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Office drafting convention; verify against local stair code before permit use.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-tread-depth',
|
||||
category: 'stair',
|
||||
label: 'stair tread depth',
|
||||
measurement: 'tread-depth',
|
||||
minValue: 0.25,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Office drafting convention; verify against local stair code before permit use.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-riser-height',
|
||||
category: 'stair',
|
||||
label: 'stair riser height',
|
||||
measurement: 'riser-height',
|
||||
minValue: -0.2,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Negative minValue means measured riser height must be less than or equal to the absolute value.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export function buildClearanceAdvisories(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildClearanceAdvisoriesOptions = {},
|
||||
): ClearanceAdvisory[] {
|
||||
const profiles = (options.profiles ?? DEFAULT_CLEARANCE_PROFILES).filter(
|
||||
(profile) => options.includeDisabled === true || profile.enabled,
|
||||
)
|
||||
if (profiles.length === 0) return []
|
||||
|
||||
const targets = Object.values(nodes).flatMap((node) => clearanceTargets(node, nodes))
|
||||
const advisories: ClearanceAdvisory[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
for (const profile of profiles) {
|
||||
for (const rule of profile.rules) {
|
||||
if (rule.category !== target.category) continue
|
||||
const measured =
|
||||
target.measurements[rule.measurement] ?? options.evidence?.[target.nodeId]?.[rule.id]
|
||||
if (measured === undefined) {
|
||||
advisories.push(clearanceAdvisory({ target, profile, rule, measured: null }))
|
||||
continue
|
||||
}
|
||||
if (violatesClearanceRule(measured, rule)) {
|
||||
advisories.push(clearanceAdvisory({ target, profile, rule, measured }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return advisories.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function clearanceTargets(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ClearanceTarget[] {
|
||||
if (node.type === 'zone') return zoneTargets(node)
|
||||
if (node.type === 'door') return doorTargets(node)
|
||||
if (node.type === 'item') return itemTargets(node)
|
||||
if (node.type === 'cabinet' || node.type === 'cabinet-module') return cabinetTargets(node)
|
||||
if (node.type === 'stair') return stairTargets(node, nodes)
|
||||
if (node.type === 'stair-segment') return stairSegmentTargets(node)
|
||||
return []
|
||||
}
|
||||
|
||||
function zoneTargets(zone: ZoneNode): ClearanceTarget[] {
|
||||
const role = normalizedText([zone.name, zone.occupancy, String(zone.metadata ?? '')])
|
||||
const dimensions = zoneClearDimensions(zone)
|
||||
const targets: ClearanceTarget[] = []
|
||||
|
||||
if (containsAny(role, ['hall', 'hallway', 'corridor', 'passage', 'circulation'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'circulation',
|
||||
measurements: { 'clear-width': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(role, ['entry', 'entrance', 'vestibule', 'foyer'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'entry',
|
||||
measurements: { 'clear-width': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(role, ['closet', 'wardrobe'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'closet',
|
||||
measurements: { 'clear-depth': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function doorTargets(door: DoorNode): ClearanceTarget[] {
|
||||
return [
|
||||
{
|
||||
nodeId: door.id,
|
||||
nodeType: door.type,
|
||||
category: 'door-approach',
|
||||
measurements: { 'clear-width': door.width },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function itemTargets(item: ItemNode): ClearanceTarget[] {
|
||||
const text = normalizedText([
|
||||
item.asset.name,
|
||||
item.asset.category,
|
||||
...(item.asset.tags ?? []),
|
||||
...(item.asset.functionTags ?? []),
|
||||
])
|
||||
const targets: ClearanceTarget[] = []
|
||||
|
||||
if (containsAny(text, ['toilet', 'lavatory', 'sink', 'fixture', 'tub', 'shower', 'wc'])) {
|
||||
targets.push({
|
||||
nodeId: item.id,
|
||||
nodeType: item.type,
|
||||
category: 'fixture',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(text, ['appliance', 'fridge', 'refrigerator', 'oven', 'range', 'dishwasher'])) {
|
||||
targets.push({
|
||||
nodeId: item.id,
|
||||
nodeType: item.type,
|
||||
category: 'appliance',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function cabinetTargets(cabinet: CabinetNode | CabinetModuleNode): ClearanceTarget[] {
|
||||
const targets: ClearanceTarget[] = [
|
||||
{
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'cabinet',
|
||||
measurements: {},
|
||||
},
|
||||
]
|
||||
|
||||
if ((cabinet.stack ?? []).some((compartment) => isApplianceCompartment(compartment.type))) {
|
||||
targets.push({
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'appliance',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
if ((cabinet.stack ?? []).some((compartment) => compartment.type === 'sink')) {
|
||||
targets.push({
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'fixture',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function stairTargets(
|
||||
stair: StairNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ClearanceTarget[] {
|
||||
const measurements: ClearanceTarget['measurements'] = { 'stair-width': stair.width }
|
||||
const totalRise = resolveStairTotalRise(stair, nodes as Record<string, AnyNode>)
|
||||
if (stair.stepCount > 0 && totalRise > 0) {
|
||||
measurements['riser-height'] = totalRise / stair.stepCount
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: stair.id,
|
||||
nodeType: stair.type,
|
||||
category: 'stair',
|
||||
measurements,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function stairSegmentTargets(segment: StairSegmentNode): ClearanceTarget[] {
|
||||
const measurements: ClearanceTarget['measurements'] = { 'stair-width': segment.width }
|
||||
if (segment.segmentType === 'stair' && segment.stepCount > 0) {
|
||||
measurements['tread-depth'] = segment.length / segment.stepCount
|
||||
if (segment.height > 0) measurements['riser-height'] = segment.height / segment.stepCount
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: segment.id,
|
||||
nodeType: segment.type,
|
||||
category: 'stair',
|
||||
measurements,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function clearanceAdvisory(args: {
|
||||
target: ClearanceTarget
|
||||
profile: ClearanceProfile
|
||||
rule: ClearanceRule
|
||||
measured: number | null
|
||||
}): ClearanceAdvisory {
|
||||
const { target, profile, rule, measured } = args
|
||||
const measuredLabel =
|
||||
measured === null ? 'not verified' : formatConstructionLength(measured, 'metric')
|
||||
const requiredLabel = formatConstructionLength(Math.abs(rule.minValue), 'metric')
|
||||
const comparator = rule.minValue < 0 ? 'at most' : 'at least'
|
||||
|
||||
return {
|
||||
id: ['clearance', profile.id, target.nodeId, rule.id].join(':'),
|
||||
nodeId: target.nodeId,
|
||||
nodeType: target.nodeType,
|
||||
profileId: profile.id,
|
||||
profileLabel: profile.label,
|
||||
category: rule.category,
|
||||
ruleId: rule.id,
|
||||
label: rule.label,
|
||||
measured,
|
||||
required: Math.abs(rule.minValue),
|
||||
severity: measured === null ? 'info' : 'warning',
|
||||
source: rule.source,
|
||||
message:
|
||||
measured === null
|
||||
? `${titleCase(target.nodeType)} ${target.nodeId} requires ${rule.label} verification (${comparator} ${requiredLabel}) per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`
|
||||
: `${titleCase(target.nodeType)} ${target.nodeId} ${rule.label} ${measuredLabel} is below ${requiredLabel} per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function violatesClearanceRule(measured: number, rule: ClearanceRule): boolean {
|
||||
if (!Number.isFinite(measured)) return true
|
||||
if (rule.minValue < 0) return measured > Math.abs(rule.minValue)
|
||||
return measured < rule.minValue
|
||||
}
|
||||
|
||||
function zoneClearDimensions(zone: ZoneNode): { minSpan: number } {
|
||||
const xs = zone.polygon.map((point) => point[0])
|
||||
const zs = zone.polygon.map((point) => point[1])
|
||||
if (xs.length === 0 || zs.length === 0) return { minSpan: 0 }
|
||||
return {
|
||||
minSpan: Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedText(parts: readonly string[]): string {
|
||||
return parts.join(' ').toLowerCase()
|
||||
}
|
||||
|
||||
function containsAny(text: string, needles: readonly string[]): boolean {
|
||||
return needles.some((needle) => text.includes(needle))
|
||||
}
|
||||
|
||||
function isApplianceCompartment(type: string): boolean {
|
||||
return [
|
||||
'oven',
|
||||
'microwave',
|
||||
'dishwasher',
|
||||
'cooktop-gas',
|
||||
'cooktop-induction',
|
||||
'fridge-single',
|
||||
'fridge-double',
|
||||
'fridge-top-freezer',
|
||||
'fridge-bottom-freezer',
|
||||
].includes(type)
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DimensionTerminator, DimensionTextPosition } from '@pascal-app/core'
|
||||
import type {
|
||||
ConstructionImperialPrecision,
|
||||
ConstructionMetricNotation,
|
||||
} from './construction-length'
|
||||
|
||||
export type ConstructionDimensionDrawingStandard = {
|
||||
datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face'
|
||||
intersectionReferencePolicy: 'single' | 'both-faces'
|
||||
terminator: DimensionTerminator
|
||||
textPosition: DimensionTextPosition
|
||||
imperialPrecision: ConstructionImperialPrecision
|
||||
metricNotation: ConstructionMetricNotation
|
||||
openingChainOffset: number
|
||||
wallSpanOffset: number
|
||||
firstOpeningWidthOffset: number
|
||||
firstGeneralTierOffset: number
|
||||
tierSpacing: number
|
||||
extensionStartGap: number
|
||||
extensionOvershoot: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONSTRUCTION_DIMENSION_STANDARD = {
|
||||
datumPolicy: 'wall-face',
|
||||
intersectionReferencePolicy: 'single',
|
||||
terminator: 'architectural-tick',
|
||||
textPosition: 'above',
|
||||
imperialPrecision: '1/16',
|
||||
metricNotation: 'meters',
|
||||
openingChainOffset: 0.55,
|
||||
wallSpanOffset: 1.05,
|
||||
firstOpeningWidthOffset: 0.62,
|
||||
firstGeneralTierOffset: 0.55,
|
||||
tierSpacing: 0.62,
|
||||
extensionStartGap: 0.075,
|
||||
extensionOvershoot: 0.12,
|
||||
} satisfies ConstructionDimensionDrawingStandard
|
||||
|
||||
export function constructionDimensionStandard(
|
||||
overrides: Partial<ConstructionDimensionDrawingStandard> = {},
|
||||
): ConstructionDimensionDrawingStandard {
|
||||
return { ...DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, ...overrides }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
|
||||
describe('formatConstructionLength profiles', () => {
|
||||
test('keeps metre notation for interactive metric dimensions', () => {
|
||||
expect(formatConstructionLength(3.456, 'metric')).toBe('3.46m')
|
||||
expect(formatConstructionLength(-0.004, 'metric')).toBe('0m')
|
||||
})
|
||||
|
||||
test('uses whole millimetres without a suffix for metric documents', () => {
|
||||
expect(formatConstructionLength(3.4564, 'metric', 'document')).toBe('3456')
|
||||
expect(formatConstructionLength(-0.004, 'metric', 'document')).toBe('-4')
|
||||
})
|
||||
|
||||
test('keeps architectural imperial notation in document output', () => {
|
||||
expect(formatConstructionLength(1.524, 'imperial', 'document')).toBe(`5'-0"`)
|
||||
})
|
||||
|
||||
test('honors drafting standard precision and notation overrides', () => {
|
||||
expect(
|
||||
formatConstructionLength(1.524, 'metric', 'editor', { metricNotation: 'millimeters' }),
|
||||
).toBe('1524')
|
||||
expect(
|
||||
formatConstructionLength((7 * 12 + 5.25) * 0.0254, 'imperial', 'editor', {
|
||||
imperialPrecision: '1/2',
|
||||
}),
|
||||
).toBe(`7'-5 1/2"`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
const INCHES_PER_METER = 1 / 0.0254
|
||||
const IMPERIAL_FRACTION_DENOMINATOR = 16
|
||||
|
||||
export type ConstructionLinearUnit = 'metric' | 'imperial'
|
||||
export type ConstructionLengthProfile = 'editor' | 'document'
|
||||
export type ConstructionMetricNotation = 'meters' | 'millimeters'
|
||||
export type ConstructionImperialPrecision = '1' | '1/2' | '1/4' | '1/8' | '1/16'
|
||||
|
||||
export type ConstructionLengthFormatOptions = {
|
||||
metricNotation?: ConstructionMetricNotation
|
||||
imperialPrecision?: ConstructionImperialPrecision
|
||||
}
|
||||
|
||||
export function formatConstructionLength(
|
||||
meters: number,
|
||||
unit: ConstructionLinearUnit,
|
||||
profile: ConstructionLengthProfile = 'editor',
|
||||
options: ConstructionLengthFormatOptions = {},
|
||||
): string {
|
||||
if (!Number.isFinite(meters)) return '--'
|
||||
|
||||
if (unit === 'metric') {
|
||||
if (profile === 'document' || options.metricNotation === 'millimeters') {
|
||||
return `${Math.round(meters * 1000)}`
|
||||
}
|
||||
|
||||
const rounded = Number.parseFloat(Math.abs(meters).toFixed(2))
|
||||
const sign = meters < 0 && rounded !== 0 ? '-' : ''
|
||||
return `${sign}${rounded}m`
|
||||
}
|
||||
|
||||
const sign = meters < 0 ? '-' : ''
|
||||
const denominator = imperialPrecisionDenominator(options.imperialPrecision)
|
||||
const totalFractionUnits = Math.round(Math.abs(meters) * INCHES_PER_METER * denominator)
|
||||
const unitsPerFoot = 12 * denominator
|
||||
const feet = Math.floor(totalFractionUnits / unitsPerFoot)
|
||||
const remainder = totalFractionUnits - feet * unitsPerFoot
|
||||
const inches = Math.floor(remainder / denominator)
|
||||
const numerator = remainder - inches * denominator
|
||||
const fraction = formatFraction(numerator, denominator)
|
||||
const inchText = fraction ? `${inches} ${fraction}` : `${inches}`
|
||||
|
||||
if (feet === 0) return `${sign}${inchText}"`
|
||||
return `${sign}${feet}'-${inchText}"`
|
||||
}
|
||||
|
||||
function imperialPrecisionDenominator(precision?: ConstructionImperialPrecision): number {
|
||||
switch (precision) {
|
||||
case '1':
|
||||
return 1
|
||||
case '1/2':
|
||||
return 2
|
||||
case '1/4':
|
||||
return 4
|
||||
case '1/8':
|
||||
return 8
|
||||
default:
|
||||
return IMPERIAL_FRACTION_DENOMINATOR
|
||||
}
|
||||
}
|
||||
|
||||
function formatFraction(numerator: number, denominator: number): string {
|
||||
if (numerator === 0) return ''
|
||||
const divisor = greatestCommonDivisor(numerator, denominator)
|
||||
return `${numerator / divisor}/${denominator / divisor}`
|
||||
}
|
||||
|
||||
function greatestCommonDivisor(a: number, b: number): number {
|
||||
let left = Math.abs(a)
|
||||
let right = Math.abs(b)
|
||||
while (right !== 0) {
|
||||
const next = left % right
|
||||
left = right
|
||||
right = next
|
||||
}
|
||||
return left || 1
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, DoorNode, WallNode, WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
buildConstructionModuleAdvisories,
|
||||
type ConstructionModuleProfile,
|
||||
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
|
||||
} from './construction-module-advisories'
|
||||
|
||||
const FOOT = 0.3048
|
||||
|
||||
const metricProfile: ConstructionModuleProfile = {
|
||||
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'metric-common')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const imperialProfile: ConstructionModuleProfile = {
|
||||
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'imperial-common')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
describe('construction module advisories', () => {
|
||||
test('keeps default construction module profiles optional and quiet', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_off_module',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
expect(buildConstructionModuleAdvisories(nodes(wall))).toEqual([])
|
||||
})
|
||||
|
||||
test('reports metric wall lengths that miss the configured construction module', () => {
|
||||
const compliantWall = WallNode.parse({
|
||||
id: 'wall_metric_ok',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
})
|
||||
const offModuleWall = WallNode.parse({
|
||||
id: 'wall_metric_off',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(compliantWall, offModuleWall), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toHaveLength(1)
|
||||
expect(advisories[0]).toMatchObject({
|
||||
id: 'construction-module:metric-common:wall_metric_off:wall-length',
|
||||
nodeId: 'wall_metric_off',
|
||||
profileId: 'metric-common',
|
||||
kind: 'wall-length',
|
||||
module: 0.1,
|
||||
measured: 3.97,
|
||||
nearestMultiple: 4,
|
||||
severity: 'info',
|
||||
})
|
||||
expect(advisories[0]?.deviation).toBeCloseTo(0.03)
|
||||
expect(advisories[0]?.message).toContain('100 mm construction module')
|
||||
})
|
||||
|
||||
test('checks overall level extents at exterior finish faces', () => {
|
||||
const walls = [
|
||||
WallNode.parse({
|
||||
id: 'wall_bottom',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [4.03, 0],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_right',
|
||||
parentId: 'level_main',
|
||||
start: [4.03, 0],
|
||||
end: [4.03, 3],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_top',
|
||||
parentId: 'level_main',
|
||||
start: [4.03, 3],
|
||||
end: [0, 3],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_left',
|
||||
parentId: 'level_main',
|
||||
start: [0, 3],
|
||||
end: [0, 0],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
]
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(...walls), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'construction-module:metric-common:level_main:level-overall-width',
|
||||
nodeId: 'level_main',
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-width',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
advisories.find((advisory) => advisory.kind === 'level-overall-width')?.measured,
|
||||
).toBeCloseTo(4.23)
|
||||
expect(advisories).not.toContainEqual(expect.objectContaining({ kind: 'level-overall-depth' }))
|
||||
})
|
||||
|
||||
test('reports imperial opening widths that miss common inch modules', () => {
|
||||
const compliantDoor = DoorNode.parse({
|
||||
id: 'door_imperial_ok',
|
||||
width: 3 * FOOT,
|
||||
})
|
||||
const offModuleDoor = DoorNode.parse({
|
||||
id: 'door_imperial_off',
|
||||
width: 0.95,
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(compliantDoor, offModuleDoor), {
|
||||
profiles: [imperialProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toHaveLength(1)
|
||||
expect(advisories[0]).toMatchObject({
|
||||
id: 'construction-module:imperial-common:door_imperial_off:opening-width',
|
||||
nodeId: 'door_imperial_off',
|
||||
profileId: 'imperial-common',
|
||||
kind: 'opening-width',
|
||||
})
|
||||
expect(advisories[0]?.module).toBeCloseTo(12 * 0.0254)
|
||||
expect(advisories[0]?.message).toContain('1\'-0" construction module')
|
||||
})
|
||||
|
||||
test('checks verified rough, masonry, and finish opening widths without inventing them', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_verified_widths',
|
||||
width: 1.2,
|
||||
roughOpeningWidth: 1.23,
|
||||
masonryOpeningWidth: 1.4,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_verified_widths',
|
||||
width: 1.2,
|
||||
finishOpeningWidth: 1.27,
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(door, window), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.id)).toEqual([
|
||||
'construction-module:metric-common:door_verified_widths:rough-opening-width',
|
||||
'construction-module:metric-common:window_verified_widths:finish-opening-width',
|
||||
])
|
||||
})
|
||||
|
||||
test('can explicitly include disabled profiles for preflight previews', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_preview',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(wall), {
|
||||
includeDisabled: true,
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.profileId).sort()).toEqual([
|
||||
'imperial-common',
|
||||
'metric-common',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type DoorNode,
|
||||
getWallAssemblyFaceOffsets,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
|
||||
const INCH = 0.0254
|
||||
|
||||
export type ConstructionModuleSystem = 'imperial' | 'metric'
|
||||
export type ConstructionModuleAdvisorySeverity = 'info' | 'warning'
|
||||
|
||||
export type ConstructionModuleProfile = {
|
||||
id: string
|
||||
label: string
|
||||
system: ConstructionModuleSystem
|
||||
modules: readonly number[]
|
||||
tolerance: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ConstructionModuleMeasurementKind =
|
||||
| 'wall-length'
|
||||
| 'level-overall-width'
|
||||
| 'level-overall-depth'
|
||||
| 'opening-width'
|
||||
| 'rough-opening-width'
|
||||
| 'masonry-opening-width'
|
||||
| 'finish-opening-width'
|
||||
|
||||
export type ConstructionModuleAdvisory = {
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
profileId: string
|
||||
profileLabel: string
|
||||
system: ConstructionModuleSystem
|
||||
kind: ConstructionModuleMeasurementKind
|
||||
label: string
|
||||
module: number
|
||||
measured: number
|
||||
deviation: number
|
||||
nearestMultiple: number
|
||||
severity: ConstructionModuleAdvisorySeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BuildConstructionModuleAdvisoriesOptions = {
|
||||
profiles?: readonly ConstructionModuleProfile[]
|
||||
includeDisabled?: boolean
|
||||
}
|
||||
|
||||
type ConstructionModuleMeasurement = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
kind: ConstructionModuleMeasurementKind
|
||||
label: string
|
||||
measured: number
|
||||
}
|
||||
|
||||
type ModuleFit = {
|
||||
module: number
|
||||
nearestMultiple: number
|
||||
deviation: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONSTRUCTION_MODULE_PROFILES: readonly ConstructionModuleProfile[] = [
|
||||
{
|
||||
id: 'imperial-common',
|
||||
label: 'Imperial common modules',
|
||||
system: 'imperial',
|
||||
modules: [12 * INCH, 16 * INCH, 24 * INCH],
|
||||
tolerance: 0.25 * INCH,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'metric-common',
|
||||
label: 'Metric common modules',
|
||||
system: 'metric',
|
||||
modules: [0.1, 0.2, 0.4, 0.6],
|
||||
tolerance: 0.005,
|
||||
enabled: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function buildConstructionModuleAdvisories(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildConstructionModuleAdvisoriesOptions = {},
|
||||
): ConstructionModuleAdvisory[] {
|
||||
const profiles = (options.profiles ?? DEFAULT_CONSTRUCTION_MODULE_PROFILES).filter(
|
||||
(profile) => options.includeDisabled === true || profile.enabled,
|
||||
)
|
||||
if (profiles.length === 0) return []
|
||||
|
||||
const measurements = [
|
||||
...Object.values(nodes).flatMap((node) => constructionModuleMeasurements(node)),
|
||||
...levelOverallMeasurements(nodes),
|
||||
]
|
||||
const advisories: ConstructionModuleAdvisory[] = []
|
||||
|
||||
for (const measurement of measurements) {
|
||||
for (const profile of profiles) {
|
||||
const fit = bestModuleFit(measurement.measured, profile.modules)
|
||||
if (!fit || fit.deviation <= profile.tolerance) continue
|
||||
|
||||
advisories.push({
|
||||
id: ['construction-module', profile.id, measurement.nodeId, measurement.kind].join(':'),
|
||||
nodeId: measurement.nodeId,
|
||||
nodeType: measurement.nodeType,
|
||||
profileId: profile.id,
|
||||
profileLabel: profile.label,
|
||||
system: profile.system,
|
||||
kind: measurement.kind,
|
||||
label: measurement.label,
|
||||
module: fit.module,
|
||||
measured: measurement.measured,
|
||||
deviation: fit.deviation,
|
||||
nearestMultiple: fit.nearestMultiple,
|
||||
severity: 'info',
|
||||
message: moduleAdvisoryMessage(measurement, profile, fit),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return advisories.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function levelOverallMeasurements(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ConstructionModuleMeasurement[] {
|
||||
const wallsByLevel = new Map<string, WallNode[]>()
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'wall' || !node.parentId) continue
|
||||
if (node.curveOffset !== undefined && Math.abs(node.curveOffset) > 1e-6) continue
|
||||
const levelWalls = wallsByLevel.get(node.parentId) ?? []
|
||||
levelWalls.push(node)
|
||||
wallsByLevel.set(node.parentId, levelWalls)
|
||||
}
|
||||
|
||||
const measurements: ConstructionModuleMeasurement[] = []
|
||||
for (const [levelId, walls] of wallsByLevel) {
|
||||
const primaryWall = walls.reduce((longest, wall) =>
|
||||
wallLength(wall) > wallLength(longest) ? wall : longest,
|
||||
)
|
||||
const primaryLength = wallLength(primaryWall)
|
||||
if (
|
||||
walls.length < 2 ||
|
||||
!isUsefulLength(primaryLength) ||
|
||||
!walls.some((wall) => !wallsAreParallel(primaryWall, wall))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const footprintPoints = walls.flatMap(wallFootprintPoints)
|
||||
const direction: [number, number] = [
|
||||
(primaryWall.end[0] - primaryWall.start[0]) / primaryLength,
|
||||
(primaryWall.end[1] - primaryWall.start[1]) / primaryLength,
|
||||
]
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const along = footprintPoints.map(([x, y]) => x * direction[0] + y * direction[1])
|
||||
const across = footprintPoints.map(([x, y]) => x * normal[0] + y * normal[1])
|
||||
const width = Math.max(...along) - Math.min(...along)
|
||||
const depth = Math.max(...across) - Math.min(...across)
|
||||
|
||||
if (isUsefulLength(width)) {
|
||||
measurements.push({
|
||||
nodeId: levelId,
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-width',
|
||||
label: 'overall plan width',
|
||||
measured: width,
|
||||
})
|
||||
}
|
||||
if (isUsefulLength(depth)) {
|
||||
measurements.push({
|
||||
nodeId: levelId,
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-depth',
|
||||
label: 'overall plan depth',
|
||||
measured: depth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return measurements
|
||||
}
|
||||
|
||||
function wallLength(wall: WallNode): number {
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function wallsAreParallel(left: WallNode, right: WallNode): boolean {
|
||||
const leftLength = wallLength(left)
|
||||
const rightLength = wallLength(right)
|
||||
if (!(isUsefulLength(leftLength) && isUsefulLength(rightLength))) return true
|
||||
const leftDirection = [
|
||||
(left.end[0] - left.start[0]) / leftLength,
|
||||
(left.end[1] - left.start[1]) / leftLength,
|
||||
]
|
||||
const rightDirection = [
|
||||
(right.end[0] - right.start[0]) / rightLength,
|
||||
(right.end[1] - right.start[1]) / rightLength,
|
||||
]
|
||||
return (
|
||||
Math.abs(leftDirection[0]! * rightDirection[1]! - leftDirection[1]! * rightDirection[0]!) < 1e-4
|
||||
)
|
||||
}
|
||||
|
||||
function wallFootprintPoints(wall: WallNode): [number, number][] {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const length = wallLength(wall)
|
||||
if (!isUsefulLength(length)) return []
|
||||
|
||||
const normal: [number, number] = [-dy / length, dx / length]
|
||||
const offsets = getWallAssemblyFaceOffsets(wall)
|
||||
return [offsets.interior, offsets.exterior].flatMap((offset) => [
|
||||
[wall.start[0] + normal[0] * offset, wall.start[1] + normal[1] * offset],
|
||||
[wall.end[0] + normal[0] * offset, wall.end[1] + normal[1] * offset],
|
||||
])
|
||||
}
|
||||
|
||||
function constructionModuleMeasurements(node: AnyNode): ConstructionModuleMeasurement[] {
|
||||
if (node.type === 'wall') return wallMeasurements(node)
|
||||
if (node.type === 'door' || node.type === 'window') return openingMeasurements(node)
|
||||
return []
|
||||
}
|
||||
|
||||
function wallMeasurements(wall: WallNode): ConstructionModuleMeasurement[] {
|
||||
if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return []
|
||||
|
||||
const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
if (!isUsefulLength(length)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: wall.id,
|
||||
nodeType: wall.type,
|
||||
kind: 'wall-length',
|
||||
label: 'wall length',
|
||||
measured: length,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function openingMeasurements(opening: DoorNode | WindowNode): ConstructionModuleMeasurement[] {
|
||||
return [
|
||||
widthMeasurement(opening, 'opening-width', 'nominal width', opening.width),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'rough-opening-width',
|
||||
'rough opening width',
|
||||
opening.roughOpeningWidth,
|
||||
),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'masonry-opening-width',
|
||||
'masonry opening width',
|
||||
opening.masonryOpeningWidth,
|
||||
),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'finish-opening-width',
|
||||
'finish opening width',
|
||||
opening.finishOpeningWidth,
|
||||
),
|
||||
].filter((measurement): measurement is ConstructionModuleMeasurement => measurement !== null)
|
||||
}
|
||||
|
||||
function widthMeasurement(
|
||||
opening: DoorNode | WindowNode,
|
||||
kind: ConstructionModuleMeasurementKind,
|
||||
label: string,
|
||||
measured: number | undefined,
|
||||
): ConstructionModuleMeasurement | null {
|
||||
if (!isUsefulLength(measured)) return null
|
||||
return {
|
||||
nodeId: opening.id,
|
||||
nodeType: opening.type,
|
||||
kind,
|
||||
label,
|
||||
measured,
|
||||
}
|
||||
}
|
||||
|
||||
function bestModuleFit(measured: number, modules: readonly number[]): ModuleFit | null {
|
||||
let best: ModuleFit | null = null
|
||||
for (const module of modules) {
|
||||
if (!isUsefulLength(module)) continue
|
||||
const multiple = Math.max(1, Math.round(measured / module))
|
||||
const nearestMultiple = multiple * module
|
||||
const deviation = Math.abs(measured - nearestMultiple)
|
||||
if (!best || deviation < best.deviation) {
|
||||
best = { module, nearestMultiple, deviation }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function moduleAdvisoryMessage(
|
||||
measurement: ConstructionModuleMeasurement,
|
||||
profile: ConstructionModuleProfile,
|
||||
fit: ModuleFit,
|
||||
): string {
|
||||
const unit = profile.system === 'imperial' ? 'imperial' : 'metric'
|
||||
const measured = formatConstructionLength(measurement.measured, unit)
|
||||
const module = formatModuleLength(fit.module, profile.system)
|
||||
const deviation = formatConstructionLength(fit.deviation, unit)
|
||||
|
||||
return `${titleCase(measurement.nodeType)} ${measurement.nodeId} ${measurement.label} ${measured} is ${deviation} off the ${module} construction module.`
|
||||
}
|
||||
|
||||
function formatModuleLength(module: number, system: ConstructionModuleSystem): string {
|
||||
if (system === 'metric') return `${Math.round(module * 1000)} mm`
|
||||
return formatConstructionLength(module, 'imperial')
|
||||
}
|
||||
|
||||
function isUsefulLength(value: number | undefined): value is number {
|
||||
return value !== undefined && Number.isFinite(value) && value > 1e-6
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
CabinetNode,
|
||||
ConstructionDimensionNode,
|
||||
DoorNode,
|
||||
StairNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { buildDimensionCompletenessAudit } from './dimension-completeness-audit'
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
function featureAnchor(nodeId: string, fallback: [number, number, number] = [0, 0, 0]) {
|
||||
return {
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId, featureId: 'center' },
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dimension completeness audit', () => {
|
||||
test('reports missing overall exterior wall dimensions and partition references', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const partitionWall = WallNode.parse({
|
||||
id: 'wall_partition',
|
||||
start: [1, 0],
|
||||
end: [1, 3],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'missing-partition-reference',
|
||||
'undocumented-critical-node',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'missing-overall-dimension',
|
||||
nodeId: 'wall_exterior',
|
||||
severity: 'warning',
|
||||
}),
|
||||
)
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'missing-partition-reference',
|
||||
nodeId: 'wall_partition',
|
||||
severity: 'info',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('uses associative construction-dimension anchors as dimension coverage', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const partitionWall = WallNode.parse({
|
||||
id: 'wall_partition',
|
||||
start: [1, 0],
|
||||
end: [1, 3],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_wall_refs',
|
||||
anchors: [
|
||||
featureAnchor(exteriorWall.id, [0, 0, 0]),
|
||||
featureAnchor(partitionWall.id, [1, 0, 0]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall, dimension))).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('can count the automatic wall and opening dimension plan as coverage', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildDimensionCompletenessAudit(nodes(exteriorWall, door), {
|
||||
includeAutomaticDimensions: true,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reports undimensioned exterior openings and missing verified rough openings', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry', 'window_front'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
width: 0.9,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_front',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
roughOpeningWidth: 1.22,
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, window))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'missing-verified-rough-opening',
|
||||
'undimensioned-exterior-opening',
|
||||
'undimensioned-exterior-opening',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
expect(issues.filter((auditIssue) => auditIssue.nodeId === 'window_front')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('suppresses exterior opening and rough-opening issues when evidence exists', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
width: 0.9,
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
const openingDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_door',
|
||||
anchors: [featureAnchor(door.id, [2, 0, 0]), featureAnchor(door.id, [3, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, openingDimension))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
})
|
||||
|
||||
test('can require rough-opening height verification as a stricter profile', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildDimensionCompletenessAudit(nodes(door), { requireRoughOpeningHeights: true }),
|
||||
).toMatchObject([
|
||||
{
|
||||
kind: 'missing-verified-rough-opening',
|
||||
nodeId: 'door_entry',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('does not require rough openings for masonry openings or frameless openings', () => {
|
||||
const masonryWindow = WindowNode.parse({
|
||||
id: 'window_masonry',
|
||||
constructionType: 'masonry',
|
||||
})
|
||||
const framelessOpening = DoorNode.parse({
|
||||
id: 'door_opening',
|
||||
openingKind: 'opening',
|
||||
})
|
||||
|
||||
expect(buildDimensionCompletenessAudit(nodes(masonryWindow, framelessOpening))).toEqual([])
|
||||
})
|
||||
|
||||
test('detects duplicate and contradictory dimension string overrides', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const firstDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_first',
|
||||
textOverride: '5.00m',
|
||||
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [5, 0, 0])],
|
||||
})
|
||||
const duplicateDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_duplicate',
|
||||
textOverride: '5.00 m',
|
||||
anchors: [featureAnchor('wall_other', [0, 0, 0]), featureAnchor('wall_other', [5, 0, 0])],
|
||||
})
|
||||
const conflictingDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_conflict',
|
||||
textOverride: '4.80m',
|
||||
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [4.8, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(
|
||||
nodes(wall, firstDimension, duplicateDimension, conflictingDimension),
|
||||
)
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'duplicate-dimension-string',
|
||||
nodeId: 'construction-dimension_first',
|
||||
}),
|
||||
)
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'contradictory-dimension-string',
|
||||
nodeId: wall.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('detects continuous dimension segment totals that disagree with the overall string', () => {
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_chain',
|
||||
chainMode: 'continuous',
|
||||
textOverride: '3.00m',
|
||||
anchors: [
|
||||
featureAnchor('wall_a', [0, 0, 0]),
|
||||
featureAnchor('wall_b', [1, 0, 0]),
|
||||
featureAnchor('wall_c', [2, 0, 0]),
|
||||
],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(dimension))
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'dimension-segment-total-mismatch',
|
||||
nodeId: 'construction-dimension_chain',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('reports construction-critical nodes without dimensions or schedules', () => {
|
||||
const undocumentedCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_undocumented',
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_documented',
|
||||
})
|
||||
const stairDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_stair',
|
||||
anchors: [featureAnchor(stair.id, [0, 0, 0]), featureAnchor(stair.id, [1, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(
|
||||
nodes(undocumentedCabinet, stair, stairDimension),
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'undocumented-critical-node',
|
||||
nodeId: undocumentedCabinet.id,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('includes unresolved annotation collisions from preflight evidence', () => {
|
||||
const issues = buildDimensionCompletenessAudit(nodes(), {
|
||||
preflightIssues: [
|
||||
{
|
||||
id: 'dimension-label_wall_a',
|
||||
kind: 'unresolved-collision',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Wall A dimension label still overlaps another annotation after automatic layout.',
|
||||
},
|
||||
{
|
||||
id: 'dimension-label_wall_b',
|
||||
kind: 'short-unreadable-segment',
|
||||
severity: 'warning',
|
||||
message: 'Wall B uses an outside label.',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'dimension-completeness:unresolved-annotation-collision:dimension-label_wall_a',
|
||||
kind: 'unresolved-annotation-collision',
|
||||
nodeId: 'dimension-label_wall_a',
|
||||
nodeType: 'annotation',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('includes clipped sheet content from sheet preflight evidence', () => {
|
||||
const issues = buildDimensionCompletenessAudit(nodes(), {
|
||||
preflightIssues: [
|
||||
{
|
||||
message:
|
||||
'Scaled plan exceeds the sheet viewport. Review clipped view or annotation content.',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'dimension-completeness:clipped-sheet-content:sheet',
|
||||
kind: 'clipped-sheet-content',
|
||||
nodeId: 'sheet',
|
||||
nodeType: 'sheet',
|
||||
severity: 'warning',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type ConstructionDimensionNode,
|
||||
type DoorNode,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type DimensionCompletenessIssueKind =
|
||||
| 'missing-overall-dimension'
|
||||
| 'undimensioned-exterior-opening'
|
||||
| 'missing-partition-reference'
|
||||
| 'missing-verified-rough-opening'
|
||||
| 'duplicate-dimension-string'
|
||||
| 'contradictory-dimension-string'
|
||||
| 'dimension-segment-total-mismatch'
|
||||
| 'undocumented-critical-node'
|
||||
| 'unresolved-annotation-collision'
|
||||
| 'clipped-sheet-content'
|
||||
|
||||
export type DimensionCompletenessIssueSeverity = 'info' | 'warning'
|
||||
|
||||
export type DimensionCompletenessIssue = {
|
||||
id: string
|
||||
kind: DimensionCompletenessIssueKind
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
severity: DimensionCompletenessIssueSeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BuildDimensionCompletenessAuditOptions = {
|
||||
includeAutomaticDimensions?: boolean
|
||||
requireRoughOpeningHeights?: boolean
|
||||
dimensionValueTolerance?: number
|
||||
preflightIssues?: readonly DimensionCompletenessPreflightIssue[]
|
||||
}
|
||||
|
||||
export type DimensionCompletenessPreflightIssue = {
|
||||
id?: string
|
||||
kind?: string
|
||||
severity?: DimensionCompletenessIssueSeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
type DimensionCoverage = ReadonlySet<string>
|
||||
type DocumentationCoverage = {
|
||||
dimensioned: ReadonlySet<string>
|
||||
scheduled: ReadonlySet<string>
|
||||
}
|
||||
type OpeningNode = DoorNode | WindowNode
|
||||
type DimensionRecord = {
|
||||
dimension: ConstructionDimensionNode
|
||||
referencedNodeIds: readonly string[]
|
||||
normalizedText: string | null
|
||||
parsedTextValue: number | null
|
||||
segmentTotal: number | null
|
||||
}
|
||||
|
||||
export function buildDimensionCompletenessAudit(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions = {},
|
||||
): DimensionCompletenessIssue[] {
|
||||
const coverage = dimensionCoverage(nodes, options)
|
||||
const documentation = documentationCoverage(nodes, coverage)
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
|
||||
issues.push(...dimensionStringIssues(nodes, options))
|
||||
issues.push(...preflightCompletenessIssues(options.preflightIssues ?? []))
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type === 'wall') {
|
||||
issues.push(...wallDimensionIssues(node, coverage))
|
||||
} else if (node.type === 'door' || node.type === 'window') {
|
||||
issues.push(...openingDimensionIssues(node, nodes, coverage, options))
|
||||
}
|
||||
|
||||
if (isConstructionCriticalNode(node, nodes) && !hasDocumentationCoverage(node, documentation)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'undocumented-critical-node',
|
||||
node,
|
||||
'warning',
|
||||
`${titleCase(node.type)} ${node.id} has no construction dimension or schedule entry.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function dimensionCoverage(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCoverage {
|
||||
const covered = new Set<string>()
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'construction-dimension') continue
|
||||
|
||||
for (const nodeId of measurementAnchorReferenceNodeIds(
|
||||
(node as ConstructionDimensionNode).anchors,
|
||||
)) {
|
||||
covered.add(nodeId)
|
||||
}
|
||||
}
|
||||
if (options.includeAutomaticDimensions === true) {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (
|
||||
node.type === 'wall' &&
|
||||
node.visible !== false &&
|
||||
Math.abs(node.curveOffset ?? 0) <= 1e-6 &&
|
||||
(isExteriorWall(node) || isPartitionWall(node))
|
||||
) {
|
||||
covered.add(node.id)
|
||||
}
|
||||
}
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'door' && node.type !== 'window') continue
|
||||
const host = openingHostWall(node, nodes)
|
||||
if (host && covered.has(host.id)) covered.add(node.id)
|
||||
}
|
||||
}
|
||||
return covered
|
||||
}
|
||||
|
||||
function documentationCoverage(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
dimensioned: DimensionCoverage,
|
||||
): DocumentationCoverage {
|
||||
const scheduled = new Set<string>()
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (hasGeneratedScheduleEntry(node)) scheduled.add(node.id)
|
||||
}
|
||||
|
||||
return { dimensioned, scheduled }
|
||||
}
|
||||
|
||||
function dimensionStringIssues(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCompletenessIssue[] {
|
||||
const records = Object.values(nodes)
|
||||
.filter((node): node is ConstructionDimensionNode => node.type === 'construction-dimension')
|
||||
.map((dimension) => dimensionRecord(dimension))
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
|
||||
issues.push(...duplicateDimensionStringIssues(records))
|
||||
issues.push(...contradictoryDimensionStringIssues(records))
|
||||
issues.push(...segmentTotalMismatchIssues(records, options.dimensionValueTolerance ?? 0.005))
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function dimensionRecord(dimension: ConstructionDimensionNode): DimensionRecord {
|
||||
const normalizedText = normalizedDimensionText(dimension.textOverride)
|
||||
return {
|
||||
dimension,
|
||||
referencedNodeIds: measurementAnchorReferenceNodeIds(dimension.anchors),
|
||||
normalizedText,
|
||||
parsedTextValue: normalizedText ? parseDimensionTextValue(normalizedText) : null,
|
||||
segmentTotal: continuousSegmentTotal(dimension),
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateDimensionStringIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const byText = new Map<string, DimensionRecord[]>()
|
||||
for (const record of records) {
|
||||
if (!record.normalizedText) continue
|
||||
const existing = byText.get(record.normalizedText)
|
||||
if (existing) existing.push(record)
|
||||
else byText.set(record.normalizedText, [record])
|
||||
}
|
||||
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const [text, duplicates] of byText) {
|
||||
if (duplicates.length < 2) continue
|
||||
const dimension = duplicates[0]?.dimension
|
||||
if (!dimension) continue
|
||||
issues.push(
|
||||
issue(
|
||||
'duplicate-dimension-string',
|
||||
dimension,
|
||||
'info',
|
||||
`Dimension string "${text}" is used by ${duplicates.length} construction dimensions.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function contradictoryDimensionStringIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const byNode = new Map<string, Map<string, DimensionRecord[]>>()
|
||||
for (const record of records) {
|
||||
if (!record.normalizedText) continue
|
||||
for (const nodeId of record.referencedNodeIds) {
|
||||
const byText = byNode.get(nodeId) ?? new Map<string, DimensionRecord[]>()
|
||||
const matchingText = byText.get(record.normalizedText)
|
||||
if (matchingText) matchingText.push(record)
|
||||
else byText.set(record.normalizedText, [record])
|
||||
byNode.set(nodeId, byText)
|
||||
}
|
||||
}
|
||||
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const [nodeId, byText] of byNode) {
|
||||
if (byText.size < 2) continue
|
||||
const firstRecord = [...byText.values()][0]?.[0]
|
||||
if (!firstRecord) continue
|
||||
issues.push({
|
||||
id: ['dimension-completeness', 'contradictory-dimension-string', nodeId].join(':'),
|
||||
kind: 'contradictory-dimension-string',
|
||||
nodeId,
|
||||
nodeType: 'unknown',
|
||||
severity: 'warning',
|
||||
message: `Referenced node ${nodeId} has contradictory construction dimension strings: ${[
|
||||
...byText.keys(),
|
||||
].join(', ')}.`,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function segmentTotalMismatchIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
tolerance: number,
|
||||
): DimensionCompletenessIssue[] {
|
||||
return records.flatMap((record) => {
|
||||
if (record.dimension.chainMode !== 'continuous') return []
|
||||
if (record.parsedTextValue === null || record.segmentTotal === null) return []
|
||||
if (Math.abs(record.parsedTextValue - record.segmentTotal) <= tolerance) return []
|
||||
|
||||
return [
|
||||
issue(
|
||||
'dimension-segment-total-mismatch',
|
||||
record.dimension,
|
||||
'warning',
|
||||
`Continuous dimension ${record.dimension.id} text ${record.normalizedText} does not match its segment total ${record.segmentTotal.toFixed(3)}m.`,
|
||||
),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function preflightCompletenessIssues(
|
||||
preflightIssues: readonly DimensionCompletenessPreflightIssue[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const preflightIssue of preflightIssues) {
|
||||
const normalizedKind = preflightIssue.kind?.trim().toLowerCase()
|
||||
const normalizedMessage = preflightIssue.message.trim().toLowerCase()
|
||||
|
||||
if (normalizedKind === 'unresolved-collision') {
|
||||
issues.push(
|
||||
preflightIssueCompletenessIssue(
|
||||
'unresolved-annotation-collision',
|
||||
preflightIssue,
|
||||
'annotation',
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedKind === 'clipped-content' ||
|
||||
normalizedKind === 'clipped-sheet-content' ||
|
||||
normalizedMessage.includes('clipped') ||
|
||||
normalizedMessage.includes('exceeds the sheet viewport')
|
||||
) {
|
||||
issues.push(preflightIssueCompletenessIssue('clipped-sheet-content', preflightIssue, 'sheet'))
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function preflightIssueCompletenessIssue(
|
||||
kind: Extract<
|
||||
DimensionCompletenessIssueKind,
|
||||
'unresolved-annotation-collision' | 'clipped-sheet-content'
|
||||
>,
|
||||
preflightIssue: DimensionCompletenessPreflightIssue,
|
||||
fallbackNodeId: string,
|
||||
): DimensionCompletenessIssue {
|
||||
const nodeId = preflightIssue.id?.trim() || fallbackNodeId
|
||||
return {
|
||||
id: ['dimension-completeness', kind, nodeId].join(':'),
|
||||
kind,
|
||||
nodeId,
|
||||
nodeType: fallbackNodeId,
|
||||
severity: preflightIssue.severity ?? 'warning',
|
||||
message: preflightIssue.message,
|
||||
}
|
||||
}
|
||||
|
||||
function wallDimensionIssues(
|
||||
wall: WallNode,
|
||||
coverage: DimensionCoverage,
|
||||
): DimensionCompletenessIssue[] {
|
||||
if (coverage.has(wall.id)) return []
|
||||
|
||||
if (isExteriorWall(wall)) {
|
||||
return [
|
||||
issue(
|
||||
'missing-overall-dimension',
|
||||
wall,
|
||||
'warning',
|
||||
`Exterior wall ${wall.id} has no associative overall construction dimension.`,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
if (isPartitionWall(wall)) {
|
||||
return [
|
||||
issue(
|
||||
'missing-partition-reference',
|
||||
wall,
|
||||
'info',
|
||||
`Partition wall ${wall.id} has no associative partition reference dimension.`,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function openingDimensionIssues(
|
||||
opening: OpeningNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
coverage: DimensionCoverage,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCompletenessIssue[] {
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
const hostWall = openingHostWall(opening, nodes)
|
||||
|
||||
if (hostWall && isExteriorWall(hostWall) && !coverage.has(opening.id)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'undimensioned-exterior-opening',
|
||||
opening,
|
||||
'warning',
|
||||
`${titleCase(opening.type)} ${opening.id} is on exterior wall ${hostWall.id} but has no associative opening dimension.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (missingVerifiedRoughOpening(opening, options)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'missing-verified-rough-opening',
|
||||
opening,
|
||||
'info',
|
||||
`${titleCase(opening.type)} ${opening.id} has no verified rough-opening ${options.requireRoughOpeningHeights === true ? 'width and height' : 'width'} recorded.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function openingHostWall(
|
||||
opening: OpeningNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): WallNode | null {
|
||||
const hostId = opening.wallId ?? opening.parentId ?? null
|
||||
if (!hostId) return null
|
||||
const host = nodes[hostId]
|
||||
return host?.type === 'wall' ? host : null
|
||||
}
|
||||
|
||||
function missingVerifiedRoughOpening(
|
||||
opening: OpeningNode,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): boolean {
|
||||
if (opening.openingKind === 'opening') return false
|
||||
if (opening.constructionType === 'masonry') return false
|
||||
if (opening.roughOpeningWidth === undefined) return true
|
||||
return options.requireRoughOpeningHeights === true && opening.roughOpeningHeight === undefined
|
||||
}
|
||||
|
||||
function isExteriorWall(wall: WallNode): boolean {
|
||||
return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
|
||||
}
|
||||
|
||||
function isPartitionWall(wall: WallNode): boolean {
|
||||
return wall.frontSide === 'interior' || wall.backSide === 'interior'
|
||||
}
|
||||
|
||||
function hasDocumentationCoverage(node: AnyNode, coverage: DocumentationCoverage): boolean {
|
||||
return coverage.dimensioned.has(node.id) || coverage.scheduled.has(node.id)
|
||||
}
|
||||
|
||||
function hasGeneratedScheduleEntry(node: AnyNode): boolean {
|
||||
if (node.type === 'door' || node.type === 'window') return node.openingKind !== 'opening'
|
||||
return node.type === 'zone' && node.spaceRole === 'room'
|
||||
}
|
||||
|
||||
function isConstructionCriticalNode(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): boolean {
|
||||
if (node.type === 'wall') return isExteriorWall(node) || isPartitionWall(node)
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
const hostWall = openingHostWall(node, nodes)
|
||||
return hostWall ? isExteriorWall(hostWall) : false
|
||||
}
|
||||
if (node.type === 'zone') return node.spaceRole === 'room'
|
||||
return (
|
||||
node.type === 'cabinet' ||
|
||||
node.type === 'cabinet-module' ||
|
||||
node.type === 'stair' ||
|
||||
node.type === 'stair-segment'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedDimensionText(text: string | null): string | null {
|
||||
const normalized = text
|
||||
?.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/(\d)\s+(MM|M|")/gi, '$1$2')
|
||||
.toUpperCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function parseDimensionTextValue(text: string): number | null {
|
||||
const metricMatch = text.match(/^([0-9]+(?:\.[0-9]+)?)\s*(MM|M)?$/)
|
||||
if (metricMatch) {
|
||||
const value = Number.parseFloat(metricMatch[1] ?? '')
|
||||
if (!Number.isFinite(value)) return null
|
||||
return metricMatch[2] === 'MM' ? value / 1000 : value
|
||||
}
|
||||
|
||||
const imperialMatch = text.match(/^(?:(\d+(?:\.\d+)?)')?(?:-)?(?:(\d+(?:\.\d+)?)")?$/)
|
||||
if (imperialMatch) {
|
||||
const feet = Number.parseFloat(imperialMatch[1] ?? '0')
|
||||
const inches = Number.parseFloat(imperialMatch[2] ?? '0')
|
||||
const totalInches = feet * 12 + inches
|
||||
return totalInches > 0 ? totalInches * 0.0254 : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function continuousSegmentTotal(dimension: ConstructionDimensionNode): number | null {
|
||||
if (dimension.chainMode !== 'continuous' || dimension.anchors.length < 3) return null
|
||||
|
||||
const directionLength = Math.hypot(
|
||||
dimension.baseline.direction[0],
|
||||
dimension.baseline.direction[1],
|
||||
)
|
||||
if (directionLength <= 1e-9) return null
|
||||
const dirX = dimension.baseline.direction[0] / directionLength
|
||||
const dirZ = dimension.baseline.direction[1] / directionLength
|
||||
|
||||
let total = 0
|
||||
for (let index = 1; index < dimension.anchors.length; index += 1) {
|
||||
const previousAnchor = dimension.anchors[index - 1]
|
||||
const currentAnchor = dimension.anchors[index]
|
||||
if (!previousAnchor || !currentAnchor) return null
|
||||
const previous = anchorFallbackPoint(previousAnchor)
|
||||
const current = anchorFallbackPoint(currentAnchor)
|
||||
total += Math.abs((current[0] - previous[0]) * dirX + (current[2] - previous[2]) * dirZ)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function anchorFallbackPoint(
|
||||
anchor: ConstructionDimensionNode['anchors'][number],
|
||||
): [number, number, number] {
|
||||
return Array.isArray(anchor) ? anchor : anchor.fallback
|
||||
}
|
||||
|
||||
function issue(
|
||||
kind: DimensionCompletenessIssueKind,
|
||||
node: Pick<AnyNode, 'id' | 'type'>,
|
||||
severity: DimensionCompletenessIssueSeverity,
|
||||
message: string,
|
||||
): DimensionCompletenessIssue {
|
||||
return {
|
||||
id: ['dimension-completeness', kind, node.id].join(':'),
|
||||
kind,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
severity,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildDimensionStringGeometry } from './dimension-string'
|
||||
|
||||
describe('buildDimensionStringGeometry', () => {
|
||||
test('expands a logical dimension string into renderable dimension segments', () => {
|
||||
const geometry = buildDimensionStringGeometry({
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: 0.04,
|
||||
extensionOvershoot: 0.12,
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
stroke: '#334155',
|
||||
segments: [
|
||||
{
|
||||
witnessStart: [0, 0],
|
||||
witnessEnd: [2, 0],
|
||||
dimensionStart: [0, 1],
|
||||
dimensionEnd: [2, 1],
|
||||
text: '2m',
|
||||
},
|
||||
{
|
||||
witnessStart: [2, 0],
|
||||
witnessEnd: [5, 0],
|
||||
dimensionStart: [2, 1],
|
||||
dimensionEnd: [5, 1],
|
||||
text: '3m',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(geometry).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'dimension-string',
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: 0.04,
|
||||
extensionOvershoot: 0.12,
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
stroke: '#334155',
|
||||
segments: [
|
||||
{
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
dimensionStart: [0, 1],
|
||||
dimensionEnd: [2, 1],
|
||||
text: '2m',
|
||||
},
|
||||
{
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 1],
|
||||
dimensionEnd: [5, 1],
|
||||
text: '3m',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
DimensionTerminator,
|
||||
DimensionTextPosition,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type DimensionStringSegment = {
|
||||
witnessStart: FloorplanPoint
|
||||
witnessEnd: FloorplanPoint
|
||||
dimensionStart?: FloorplanPoint
|
||||
dimensionEnd?: FloorplanPoint
|
||||
text: string
|
||||
}
|
||||
|
||||
export type DimensionStringGeometryInput = {
|
||||
segments: readonly DimensionStringSegment[]
|
||||
offsetNormal: FloorplanPoint
|
||||
offsetDistance?: number
|
||||
extensionStartGap?: number
|
||||
extensionOvershoot?: number
|
||||
terminator?: DimensionTerminator
|
||||
textPosition?: DimensionTextPosition
|
||||
stroke?: string
|
||||
}
|
||||
|
||||
export function buildDimensionStringGeometry(
|
||||
input: DimensionStringGeometryInput,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'dimension-string',
|
||||
segments: input.segments.map((segment) => ({
|
||||
start: segment.witnessStart,
|
||||
end: segment.witnessEnd,
|
||||
dimensionStart: segment.dimensionStart,
|
||||
dimensionEnd: segment.dimensionEnd,
|
||||
text: segment.text,
|
||||
})),
|
||||
offsetNormal: input.offsetNormal,
|
||||
offsetDistance: input.offsetDistance ?? 0,
|
||||
extensionStartGap: input.extensionStartGap,
|
||||
extensionOvershoot: input.extensionOvershoot ?? 0,
|
||||
terminator: input.terminator,
|
||||
textPosition: input.textPosition,
|
||||
stroke: input.stroke,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
type OpeningDocumentationPatch = {
|
||||
mark?: string
|
||||
constructionType?: 'framed' | 'masonry'
|
||||
dimensionReference?: 'nominal' | 'rough-opening' | 'masonry-opening' | 'finish-opening'
|
||||
roughOpeningWidth?: number
|
||||
roughOpeningHeight?: number
|
||||
masonryOpeningWidth?: number
|
||||
masonryOpeningHeight?: number
|
||||
finishOpeningWidth?: number
|
||||
finishOpeningHeight?: number
|
||||
}
|
||||
|
||||
export function OpeningDocumentationFields({
|
||||
mark,
|
||||
constructionType = 'framed',
|
||||
dimensionReference = 'nominal',
|
||||
roughOpeningWidth,
|
||||
roughOpeningHeight,
|
||||
masonryOpeningWidth,
|
||||
masonryOpeningHeight,
|
||||
finishOpeningWidth,
|
||||
finishOpeningHeight,
|
||||
onChange,
|
||||
}: OpeningDocumentationPatch & {
|
||||
onChange: (patch: OpeningDocumentationPatch) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Mark
|
||||
</span>
|
||||
<input
|
||||
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2.5 font-mono text-foreground text-xs outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-orange-400/60"
|
||||
defaultValue={mark ?? ''}
|
||||
key={`mark:${mark ?? ''}`}
|
||||
maxLength={16}
|
||||
onBlur={(event) => {
|
||||
const next = event.currentTarget.value.trim().toLocaleUpperCase()
|
||||
if (next !== (mark ?? '')) onChange({ mark: next || undefined })
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
if (event.key === 'Escape') {
|
||||
event.currentTarget.value = mark ?? ''
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Auto-assigned"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Construction
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-foreground text-xs outline-none focus:border-orange-400/60"
|
||||
onChange={(event) => {
|
||||
const next = event.currentTarget.value as 'framed' | 'masonry'
|
||||
onChange({
|
||||
constructionType: next,
|
||||
dimensionReference:
|
||||
next === 'masonry' && dimensionReference === 'nominal'
|
||||
? 'masonry-opening'
|
||||
: dimensionReference,
|
||||
})
|
||||
}}
|
||||
value={constructionType}
|
||||
>
|
||||
<option value="framed">Framed</option>
|
||||
<option value="masonry">Masonry</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Dimension to
|
||||
</span>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-foreground text-xs outline-none focus:border-orange-400/60"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
dimensionReference: event.currentTarget
|
||||
.value as OpeningDocumentationPatch['dimensionReference'],
|
||||
})
|
||||
}
|
||||
value={dimensionReference}
|
||||
>
|
||||
<option value="nominal">Nominal</option>
|
||||
<option value="rough-opening">Rough opening</option>
|
||||
<option value="masonry-opening">Masonry opening</option>
|
||||
<option value="finish-opening">Finish opening</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<OptionalMeterInput
|
||||
label="RO Width"
|
||||
onChange={(value) => onChange({ roughOpeningWidth: value })}
|
||||
value={roughOpeningWidth}
|
||||
/>
|
||||
<OptionalMeterInput
|
||||
label="RO Height"
|
||||
onChange={(value) => onChange({ roughOpeningHeight: value })}
|
||||
value={roughOpeningHeight}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<OptionalMeterInput
|
||||
label="MO Width"
|
||||
onChange={(value) => onChange({ masonryOpeningWidth: value })}
|
||||
value={masonryOpeningWidth}
|
||||
/>
|
||||
<OptionalMeterInput
|
||||
label="MO Height"
|
||||
onChange={(value) => onChange({ masonryOpeningHeight: value })}
|
||||
value={masonryOpeningHeight}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<OptionalMeterInput
|
||||
label="FO Width"
|
||||
onChange={(value) => onChange({ finishOpeningWidth: value })}
|
||||
value={finishOpeningWidth}
|
||||
/>
|
||||
<OptionalMeterInput
|
||||
label="FO Height"
|
||||
onChange={(value) => onChange({ finishOpeningHeight: value })}
|
||||
value={finishOpeningHeight}
|
||||
/>
|
||||
</div>
|
||||
<p className="px-0.5 text-[10px] text-muted-foreground/65 leading-4">
|
||||
Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionalMeterInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value?: number
|
||||
onChange: (value: number | undefined) => void
|
||||
}) {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit))
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex h-8 items-center rounded-lg border border-border/50 bg-[#2C2C2E] focus-within:border-orange-400/60">
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent px-2 font-mono text-foreground text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
defaultValue={displayValue}
|
||||
key={`${label}:${displayValue}`}
|
||||
min={0.01}
|
||||
onBlur={(event) => {
|
||||
const raw = event.currentTarget.value
|
||||
if (raw === String(displayValue)) return
|
||||
const parsed = Number.parseFloat(raw)
|
||||
const next =
|
||||
raw === '' || !Number.isFinite(parsed) || parsed <= 0
|
||||
? undefined
|
||||
: linearUnitToMeters(parsed, unit)
|
||||
if (next !== value) onChange(next)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
if (event.key === 'Escape') {
|
||||
event.currentTarget.value = String(displayValue)
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
onWheel={(event) => event.currentTarget.blur()}
|
||||
placeholder="Verify"
|
||||
step={unit === 'imperial' ? 0.01 : 0.001}
|
||||
type="number"
|
||||
/>
|
||||
<span className="pr-2 font-mono text-[10px] text-muted-foreground">
|
||||
{getLinearUnitLabel(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function roundForInput(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, DoorNode, LevelNode, WallNode, WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
buildDoorFloorplanSchedule,
|
||||
buildOpeningMarkAnnotation,
|
||||
buildWindowFloorplanSchedule,
|
||||
computeDoorFloorplanLevelData,
|
||||
computeWindowFloorplanLevelData,
|
||||
resolveOpeningDimensionDocumentation,
|
||||
} from './opening-documentation'
|
||||
|
||||
const FOOT = 0.3048
|
||||
|
||||
function fixture(levelNumber = 0) {
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_main',
|
||||
level: levelNumber,
|
||||
children: ['wall_main'],
|
||||
})
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_main',
|
||||
parentId: level.id,
|
||||
children: ['door_a', 'door_b', 'window_a', 'window_b'],
|
||||
start: [0, 0],
|
||||
end: [10, 0],
|
||||
thickness: 0.2,
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const doorA = DoorNode.parse({
|
||||
id: 'door_a',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [3, 3.5 * FOOT, 0],
|
||||
width: 3 * FOOT,
|
||||
height: 7 * FOOT,
|
||||
})
|
||||
const doorB = DoorNode.parse({
|
||||
id: 'door_b',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [6, 3.5 * FOOT, 0],
|
||||
width: 3 * FOOT,
|
||||
height: 7 * FOOT,
|
||||
})
|
||||
const windowA = WindowNode.parse({
|
||||
id: 'window_a',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 5 * FOOT, 0],
|
||||
width: 4 * FOOT,
|
||||
height: 4 * FOOT,
|
||||
})
|
||||
const windowB = WindowNode.parse({
|
||||
id: 'window_b',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [8, 5 * FOOT, 0],
|
||||
width: 4 * FOOT,
|
||||
height: 4 * FOOT,
|
||||
})
|
||||
const nodes = Object.fromEntries(
|
||||
[level, wall, doorA, doorB, windowA, windowB].map((node) => [node.id, node]),
|
||||
) as Record<string, AnyNode>
|
||||
|
||||
return { doorA, doorB, level, nodes, wall, windowA, windowB }
|
||||
}
|
||||
|
||||
describe('opening construction documentation', () => {
|
||||
test('assigns deterministic level-based door marks and skips explicit marks', () => {
|
||||
const { doorA, doorB, nodes } = fixture()
|
||||
const explicit = DoorNode.parse({ ...doorA, mark: '101' })
|
||||
const marks = computeDoorFloorplanLevelData({ siblings: [explicit, doorB], nodes })
|
||||
|
||||
expect(marks.markById.get(explicit.id)).toBe('101')
|
||||
expect(marks.markById.get(doorB.id)).toBe('102')
|
||||
|
||||
const upperFixture = fixture(1)
|
||||
const upperMarks = computeDoorFloorplanLevelData({
|
||||
siblings: [upperFixture.doorA],
|
||||
nodes: upperFixture.nodes,
|
||||
})
|
||||
expect(upperMarks.markById.get(upperFixture.doorA.id)).toBe('201')
|
||||
})
|
||||
|
||||
test('assigns stable window marks in level order', () => {
|
||||
const { nodes, windowA, windowB } = fixture()
|
||||
const marks = computeWindowFloorplanLevelData({
|
||||
siblings: [windowA, windowB],
|
||||
nodes,
|
||||
})
|
||||
|
||||
expect(marks.markById.get(windowA.id)).toBe('W01')
|
||||
expect(marks.markById.get(windowB.id)).toBe('W02')
|
||||
})
|
||||
|
||||
test('builds U.S. door schedule dimensions without inventing a rough opening', () => {
|
||||
const { doorA, level, nodes } = fixture()
|
||||
const schedule = buildDoorFloorplanSchedule({
|
||||
siblings: [doorA],
|
||||
nodes,
|
||||
levelId: level.id,
|
||||
unit: 'imperial',
|
||||
})
|
||||
|
||||
expect(schedule?.rows[0]?.cells).toMatchObject({
|
||||
mark: '101',
|
||||
size: `3'-0" x 7'-0"`,
|
||||
roughOpening: 'VERIFY',
|
||||
})
|
||||
})
|
||||
|
||||
test('includes verified window rough opening, sill, and head heights', () => {
|
||||
const { level, nodes, windowA } = fixture()
|
||||
const documented = WindowNode.parse({
|
||||
...windowA,
|
||||
roughOpeningWidth: 4.1 * FOOT,
|
||||
roughOpeningHeight: 4.2 * FOOT,
|
||||
})
|
||||
const schedule = buildWindowFloorplanSchedule({
|
||||
siblings: [documented],
|
||||
nodes,
|
||||
levelId: level.id,
|
||||
unit: 'imperial',
|
||||
})
|
||||
|
||||
expect(schedule?.rows[0]?.cells).toMatchObject({
|
||||
mark: 'W01',
|
||||
roughOpening: `4'-1 3/16" x 4'-2 3/8"`,
|
||||
sill: `3'-0"`,
|
||||
head: `7'-0"`,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves explicit opening dimension documentation without inventing missing values', () => {
|
||||
const { doorA, windowA } = fixture()
|
||||
const roughDoor = DoorNode.parse({
|
||||
...doorA,
|
||||
dimensionReference: 'rough-opening',
|
||||
roughOpeningWidth: 3.1 * FOOT,
|
||||
roughOpeningHeight: 7.1 * FOOT,
|
||||
})
|
||||
const missingRoughDoor = DoorNode.parse({
|
||||
...doorA,
|
||||
id: 'door_missing_ro',
|
||||
dimensionReference: 'rough-opening',
|
||||
})
|
||||
const masonryWindow = WindowNode.parse({
|
||||
...windowA,
|
||||
constructionType: 'masonry',
|
||||
masonryOpeningWidth: 4.25 * FOOT,
|
||||
masonryOpeningHeight: 4.25 * FOOT,
|
||||
})
|
||||
|
||||
expect(resolveOpeningDimensionDocumentation(roughDoor)).toMatchObject({
|
||||
constructionType: 'framed',
|
||||
reference: 'rough-opening',
|
||||
locationPolicy: 'centerline',
|
||||
prefix: 'RO',
|
||||
verified: true,
|
||||
width: 3.1 * FOOT,
|
||||
})
|
||||
expect(resolveOpeningDimensionDocumentation(missingRoughDoor)).toMatchObject({
|
||||
reference: 'rough-opening',
|
||||
prefix: 'RO',
|
||||
verified: false,
|
||||
width: null,
|
||||
})
|
||||
expect(resolveOpeningDimensionDocumentation(masonryWindow)).toMatchObject({
|
||||
constructionType: 'masonry',
|
||||
reference: 'masonry-opening',
|
||||
locationPolicy: 'edge-to-edge',
|
||||
prefix: 'MO',
|
||||
verified: true,
|
||||
width: 4.25 * FOOT,
|
||||
})
|
||||
})
|
||||
|
||||
test('warns about duplicate manually assigned marks', () => {
|
||||
const { doorA, doorB, level, nodes } = fixture()
|
||||
const schedule = buildDoorFloorplanSchedule({
|
||||
siblings: [
|
||||
DoorNode.parse({ ...doorA, mark: 'A1' }),
|
||||
DoorNode.parse({ ...doorB, mark: 'a1' }),
|
||||
],
|
||||
nodes,
|
||||
levelId: level.id,
|
||||
unit: 'imperial',
|
||||
})
|
||||
|
||||
expect(schedule?.issues).toEqual(['Duplicate door mark A1 (2 instances)'])
|
||||
})
|
||||
|
||||
test('places the opening mark tag on the interior face of an exterior wall', () => {
|
||||
const { doorA, nodes, wall } = fixture()
|
||||
const levelData = computeDoorFloorplanLevelData({ siblings: [doorA], nodes })
|
||||
const annotation = buildOpeningMarkAnnotation(doorA, wall, levelData)
|
||||
|
||||
expect(annotation?.kind).toBe('group')
|
||||
if (annotation?.kind !== 'group') return
|
||||
const tag = annotation.children.find((child) => child.kind === 'text')
|
||||
expect(tag).toMatchObject({ kind: 'text', text: '101', x: 3 })
|
||||
expect(tag?.kind === 'text' ? tag.y : null).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,413 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
DoorNode,
|
||||
FloorplanGeometry,
|
||||
LevelNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FloorplanSchedule, withFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
type ConstructionLengthProfile,
|
||||
type ConstructionLinearUnit,
|
||||
formatConstructionLength,
|
||||
} from './construction-length'
|
||||
|
||||
type OpeningNode = DoorNode | WindowNode
|
||||
type OpeningKind = OpeningNode['type']
|
||||
|
||||
export type OpeningConstructionType = 'framed' | 'masonry'
|
||||
export type OpeningDimensionReference =
|
||||
| 'nominal'
|
||||
| 'rough-opening'
|
||||
| 'masonry-opening'
|
||||
| 'finish-opening'
|
||||
|
||||
export type OpeningDimensionDocumentation = {
|
||||
constructionType: OpeningConstructionType
|
||||
reference: OpeningDimensionReference
|
||||
locationPolicy: 'centerline' | 'edge-to-edge'
|
||||
width: number | null
|
||||
height: number | null
|
||||
prefix: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export type OpeningFloorplanLevelData = {
|
||||
markById: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
type MarkResolution = OpeningFloorplanLevelData & {
|
||||
issues: readonly string[]
|
||||
}
|
||||
|
||||
export function computeDoorFloorplanLevelData(args: {
|
||||
siblings: ReadonlyArray<DoorNode>
|
||||
nodes: Record<string, AnyNode>
|
||||
}): OpeningFloorplanLevelData {
|
||||
return resolveOpeningMarks(args.siblings, args.nodes, 'door')
|
||||
}
|
||||
|
||||
export function computeWindowFloorplanLevelData(args: {
|
||||
siblings: ReadonlyArray<WindowNode>
|
||||
nodes: Record<string, AnyNode>
|
||||
}): OpeningFloorplanLevelData {
|
||||
return resolveOpeningMarks(args.siblings, args.nodes, 'window')
|
||||
}
|
||||
|
||||
export function buildDoorFloorplanSchedule(args: {
|
||||
siblings: ReadonlyArray<DoorNode>
|
||||
nodes: Readonly<Record<string, AnyNode>>
|
||||
levelId: string
|
||||
unit: ConstructionLinearUnit
|
||||
profile?: ConstructionLengthProfile
|
||||
}): FloorplanSchedule | null {
|
||||
if (args.siblings.length === 0) return null
|
||||
const marks = resolveOpeningMarks(args.siblings, args.nodes, 'door', args.levelId)
|
||||
return {
|
||||
id: 'doors',
|
||||
title: 'DOOR SCHEDULE',
|
||||
columns: [
|
||||
{ key: 'mark', label: 'MARK', weight: 0.65 },
|
||||
{ key: 'type', label: 'TYPE', weight: 1.25 },
|
||||
{ key: 'size', label: 'NOMINAL SIZE', weight: 1.35 },
|
||||
{ key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 },
|
||||
{ key: 'operation', label: 'OPERATION', weight: 1.35 },
|
||||
{ key: 'frame', label: 'FRAME T / D', weight: 1.25 },
|
||||
{ key: 'hardware', label: 'HARDWARE', weight: 1.35 },
|
||||
],
|
||||
rows: args.siblings.map((door) => ({
|
||||
id: door.id,
|
||||
cells: {
|
||||
mark: marks.markById.get(door.id) ?? '—',
|
||||
type: door.openingKind === 'opening' ? 'Opening' : titleCase(door.doorType),
|
||||
size: formatSize(door.width, door.height, args.unit, args.profile ?? 'document'),
|
||||
roughOpening: formatRoughOpening(door, args.unit, args.profile ?? 'document'),
|
||||
operation: doorOperation(door),
|
||||
frame: `${formatConstructionLength(door.frameThickness, args.unit, args.profile ?? 'document')} / ${formatConstructionLength(door.frameDepth, args.unit, args.profile ?? 'document')}`,
|
||||
hardware: doorHardware(door),
|
||||
},
|
||||
})),
|
||||
issues: marks.issues,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWindowFloorplanSchedule(args: {
|
||||
siblings: ReadonlyArray<WindowNode>
|
||||
nodes: Readonly<Record<string, AnyNode>>
|
||||
levelId: string
|
||||
unit: ConstructionLinearUnit
|
||||
profile?: ConstructionLengthProfile
|
||||
}): FloorplanSchedule | null {
|
||||
if (args.siblings.length === 0) return null
|
||||
const marks = resolveOpeningMarks(args.siblings, args.nodes, 'window', args.levelId)
|
||||
return {
|
||||
id: 'windows',
|
||||
title: 'WINDOW SCHEDULE',
|
||||
columns: [
|
||||
{ key: 'mark', label: 'MARK', weight: 0.65 },
|
||||
{ key: 'type', label: 'TYPE', weight: 1.2 },
|
||||
{ key: 'size', label: 'NOMINAL SIZE', weight: 1.35 },
|
||||
{ key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 },
|
||||
{ key: 'sill', label: 'SILL', weight: 0.9 },
|
||||
{ key: 'head', label: 'HEAD', weight: 0.9 },
|
||||
{ key: 'operation', label: 'OPERATION', weight: 1.35 },
|
||||
],
|
||||
rows: args.siblings.map((window) => ({
|
||||
id: window.id,
|
||||
cells: {
|
||||
mark: marks.markById.get(window.id) ?? '—',
|
||||
type: window.openingKind === 'opening' ? 'Opening' : titleCase(window.windowType),
|
||||
size: formatSize(window.width, window.height, args.unit, args.profile ?? 'document'),
|
||||
roughOpening: formatRoughOpening(window, args.unit, args.profile ?? 'document'),
|
||||
sill: formatConstructionLength(
|
||||
Math.max(0, window.position[1] - window.height / 2),
|
||||
args.unit,
|
||||
args.profile ?? 'document',
|
||||
),
|
||||
head: formatConstructionLength(
|
||||
window.position[1] + window.height / 2,
|
||||
args.unit,
|
||||
args.profile ?? 'document',
|
||||
),
|
||||
operation: windowOperation(window),
|
||||
},
|
||||
})),
|
||||
issues: marks.issues,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOpeningMarkAnnotation(
|
||||
opening: OpeningNode,
|
||||
wall: WallNode,
|
||||
levelData: OpeningFloorplanLevelData | undefined,
|
||||
{
|
||||
preferredSide = -1,
|
||||
stroke = '#334155',
|
||||
}: {
|
||||
preferredSide?: -1 | 1
|
||||
stroke?: string
|
||||
} = {},
|
||||
): FloorplanGeometry | null {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dz = wall.end[1] - wall.start[1]
|
||||
const wallLength = Math.hypot(dx, dz)
|
||||
if (wallLength < 1e-6) return null
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirZ = dz / wallLength
|
||||
const normalX = -dirZ
|
||||
const normalZ = dirX
|
||||
const side = interiorSide(wall, preferredSide)
|
||||
const openingCenterX = wall.start[0] + dirX * opening.position[0]
|
||||
const openingCenterZ = wall.start[1] + dirZ * opening.position[0]
|
||||
const halfDepth = (wall.thickness ?? 0.1) / 2
|
||||
const bubbleOffset = halfDepth + 0.5
|
||||
const bubbleX = openingCenterX + normalX * bubbleOffset * side
|
||||
const bubbleZ = openingCenterZ + normalZ * bubbleOffset * side
|
||||
const explicitMark = opening.mark?.trim()
|
||||
const mark = levelData?.markById.get(opening.id) ?? (explicitMark || fallbackMark(opening))
|
||||
const bubbleWidth = Math.max(0.38, mark.length * 0.105 + 0.18)
|
||||
const bubbleHeight = 0.32
|
||||
const leaderEndOffset = bubbleOffset - bubbleHeight / 2
|
||||
|
||||
return withFloorplanGeometryMetadata(
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'line',
|
||||
x1: openingCenterX + normalX * halfDepth * side,
|
||||
y1: openingCenterZ + normalZ * halfDepth * side,
|
||||
x2: openingCenterX + normalX * leaderEndOffset * side,
|
||||
y2: openingCenterZ + normalZ * leaderEndOffset * side,
|
||||
stroke,
|
||||
strokeWidth: 0.018,
|
||||
},
|
||||
{
|
||||
kind: 'rect',
|
||||
x: bubbleX - bubbleWidth / 2,
|
||||
y: bubbleZ - bubbleHeight / 2,
|
||||
width: bubbleWidth,
|
||||
height: bubbleHeight,
|
||||
rx: bubbleHeight / 2,
|
||||
ry: bubbleHeight / 2,
|
||||
fill: '#ffffff',
|
||||
stroke,
|
||||
strokeWidth: 0.02,
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
x: bubbleX,
|
||||
y: bubbleZ,
|
||||
text: mark,
|
||||
fontSize: 0.15,
|
||||
fill: stroke,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'middle',
|
||||
upright: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ annotationRole: 'opening-mark' },
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveOpeningDimensionDocumentation(
|
||||
opening: OpeningNode,
|
||||
): OpeningDimensionDocumentation {
|
||||
const constructionType = opening.constructionType ?? 'framed'
|
||||
const requestedReference =
|
||||
constructionType === 'masonry' &&
|
||||
opening.dimensionReference === 'nominal' &&
|
||||
opening.masonryOpeningWidth !== undefined
|
||||
? 'masonry-opening'
|
||||
: (opening.dimensionReference ?? 'nominal')
|
||||
|
||||
const dimensions = openingDocumentationDimensions(opening, requestedReference)
|
||||
|
||||
return {
|
||||
constructionType,
|
||||
reference: requestedReference,
|
||||
locationPolicy: constructionType === 'masonry' ? 'edge-to-edge' : 'centerline',
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
prefix: openingDimensionPrefix(requestedReference),
|
||||
verified: requestedReference === 'nominal' || dimensions.width !== null,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOpeningMarks<T extends OpeningNode>(
|
||||
openings: ReadonlyArray<T>,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
kind: OpeningKind,
|
||||
explicitLevelId?: string,
|
||||
): MarkResolution {
|
||||
const markById = new Map<string, string>()
|
||||
const explicitMarks = new Map<string, string[]>()
|
||||
const used = new Set<string>()
|
||||
|
||||
for (const opening of openings) {
|
||||
const mark = opening.mark?.trim()
|
||||
if (!mark) continue
|
||||
markById.set(opening.id, mark)
|
||||
used.add(mark.toLocaleUpperCase())
|
||||
const normalized = mark.toLocaleUpperCase()
|
||||
const ids = explicitMarks.get(normalized)
|
||||
if (ids) ids.push(opening.id)
|
||||
else explicitMarks.set(normalized, [opening.id])
|
||||
}
|
||||
|
||||
const level = resolveLevel(openings[0], nodes, explicitLevelId)
|
||||
let sequence = 1
|
||||
for (const opening of openings) {
|
||||
if (markById.has(opening.id)) continue
|
||||
let candidate = automaticMark(kind, level?.level ?? 0, sequence)
|
||||
while (used.has(candidate.toLocaleUpperCase())) {
|
||||
sequence++
|
||||
candidate = automaticMark(kind, level?.level ?? 0, sequence)
|
||||
}
|
||||
markById.set(opening.id, candidate)
|
||||
used.add(candidate.toLocaleUpperCase())
|
||||
sequence++
|
||||
}
|
||||
|
||||
const issues = [...explicitMarks.entries()]
|
||||
.filter(([, ids]) => ids.length > 1)
|
||||
.map(([mark, ids]) => `Duplicate ${kind} mark ${mark} (${ids.length} instances)`)
|
||||
|
||||
return { markById, issues }
|
||||
}
|
||||
|
||||
function resolveLevel(
|
||||
opening: OpeningNode | undefined,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
explicitLevelId?: string,
|
||||
): LevelNode | undefined {
|
||||
const explicit = explicitLevelId ? nodes[explicitLevelId] : undefined
|
||||
if (explicit?.type === 'level') return explicit
|
||||
|
||||
let current: AnyNode | undefined = opening
|
||||
const visited = new Set<string>()
|
||||
while (current?.parentId && !visited.has(current.parentId)) {
|
||||
visited.add(current.parentId)
|
||||
current = nodes[current.parentId]
|
||||
if (current?.type === 'level') return current
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function automaticMark(kind: OpeningKind, level: number, sequence: number): string {
|
||||
if (kind === 'door') return String((Math.max(0, level) + 1) * 100 + sequence)
|
||||
return `W${String(sequence).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fallbackMark(opening: OpeningNode): string {
|
||||
return opening.type === 'door' ? 'D?' : 'W?'
|
||||
}
|
||||
|
||||
function interiorSide(wall: WallNode, fallback: -1 | 1): -1 | 1 {
|
||||
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return -1
|
||||
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return 1
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatSize(
|
||||
width: number,
|
||||
height: number,
|
||||
unit: ConstructionLinearUnit,
|
||||
profile: ConstructionLengthProfile,
|
||||
): string {
|
||||
return `${formatConstructionLength(width, unit, profile)} x ${formatConstructionLength(height, unit, profile)}`
|
||||
}
|
||||
|
||||
function formatRoughOpening(
|
||||
opening: OpeningNode,
|
||||
unit: ConstructionLinearUnit,
|
||||
profile: ConstructionLengthProfile,
|
||||
): string {
|
||||
if (opening.roughOpeningWidth === undefined || opening.roughOpeningHeight === undefined) {
|
||||
return 'VERIFY'
|
||||
}
|
||||
return formatSize(opening.roughOpeningWidth, opening.roughOpeningHeight, unit, profile)
|
||||
}
|
||||
|
||||
function openingDocumentationDimensions(
|
||||
opening: OpeningNode,
|
||||
reference: OpeningDimensionReference,
|
||||
): { width: number | null; height: number | null } {
|
||||
switch (reference) {
|
||||
case 'nominal':
|
||||
return { width: opening.width, height: opening.height }
|
||||
case 'rough-opening':
|
||||
return {
|
||||
width: opening.roughOpeningWidth ?? null,
|
||||
height: opening.roughOpeningHeight ?? null,
|
||||
}
|
||||
case 'masonry-opening':
|
||||
return {
|
||||
width: opening.masonryOpeningWidth ?? null,
|
||||
height: opening.masonryOpeningHeight ?? null,
|
||||
}
|
||||
case 'finish-opening':
|
||||
return {
|
||||
width: opening.finishOpeningWidth ?? null,
|
||||
height: opening.finishOpeningHeight ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openingDimensionPrefix(reference: OpeningDimensionReference): string {
|
||||
switch (reference) {
|
||||
case 'nominal':
|
||||
return ''
|
||||
case 'rough-opening':
|
||||
return 'RO'
|
||||
case 'masonry-opening':
|
||||
return 'MO'
|
||||
case 'finish-opening':
|
||||
return 'FO'
|
||||
}
|
||||
}
|
||||
|
||||
function doorOperation(door: DoorNode): string {
|
||||
if (door.openingKind === 'opening') return 'None'
|
||||
if (door.doorType === 'hinged')
|
||||
return `${titleCase(door.hingesSide)} / ${titleCase(door.swingDirection)}`
|
||||
if (door.doorType === 'sliding' || door.doorType === 'pocket' || door.doorType === 'barn') {
|
||||
return `Slide ${titleCase(door.slideDirection)}`
|
||||
}
|
||||
return titleCase(door.doorType)
|
||||
}
|
||||
|
||||
function doorHardware(door: DoorNode): string {
|
||||
if (door.openingKind === 'opening') return 'None'
|
||||
const hardware = []
|
||||
if (door.doorCloser) hardware.push('Closer')
|
||||
if (door.panicBar) hardware.push('Panic bar')
|
||||
if (door.threshold) hardware.push('Threshold')
|
||||
return hardware.length > 0 ? hardware.join(', ') : 'Standard'
|
||||
}
|
||||
|
||||
function windowOperation(window: WindowNode): string {
|
||||
if (window.openingKind === 'opening') return 'None'
|
||||
if (window.windowType === 'fixed') return 'Fixed'
|
||||
if (window.windowType === 'casement') {
|
||||
return window.casementStyle === 'french'
|
||||
? 'French casement'
|
||||
: `${titleCase(window.hingesSide)} hinge`
|
||||
}
|
||||
if (window.windowType === 'awning' || window.windowType === 'hopper') {
|
||||
return titleCase(window.awningDirection)
|
||||
}
|
||||
return titleCase(window.windowType)
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toLocaleUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core'
|
||||
import { buildOpeningPlacementDimensions } from './opening-placement-dimensions'
|
||||
|
||||
function dimensionTexts(geometry: FloorplanGeometry[]): string[] {
|
||||
return geometry.flatMap((entry) => (entry.kind === 'dimension' ? [entry.text] : []))
|
||||
}
|
||||
|
||||
function context(unit: 'metric' | 'imperial'): {
|
||||
door: DoorNode
|
||||
ctx: GeometryContext
|
||||
} {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: 'wall_main',
|
||||
position: [1.8288, 1.05, 0],
|
||||
width: 0.6096,
|
||||
})
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_main',
|
||||
parentId: 'level_main',
|
||||
children: [door.id],
|
||||
start: [0, 0],
|
||||
end: [3.6576, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
return {
|
||||
door,
|
||||
ctx: {
|
||||
resolve: (id) => (id === door.id ? door : undefined),
|
||||
children: [door],
|
||||
siblings: [],
|
||||
parent: wall,
|
||||
viewState: {
|
||||
selected: true,
|
||||
unit,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: true,
|
||||
palette: {
|
||||
selectedStroke: '#f97316',
|
||||
hoveredStroke: '#fb923c',
|
||||
wallHoverStroke: '#fb923c',
|
||||
handleFill: '#ffffff',
|
||||
handleStroke: '#f97316',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildOpeningPlacementDimensions', () => {
|
||||
test('formats temporary placement clearances using the live metric preference', () => {
|
||||
const { door, ctx } = context('metric')
|
||||
|
||||
expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual(['1.52m', '1.52m'])
|
||||
})
|
||||
|
||||
test('formats temporary placement clearances using the live imperial preference', () => {
|
||||
const { door, ctx } = context('imperial')
|
||||
|
||||
expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual([`5'-0"`, `5'-0"`])
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { readFloorplanContext } from '@pascal-app/editor'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
import { resolveWallOpeningCeiling } from './wall-opening-ceiling'
|
||||
|
||||
/**
|
||||
@@ -66,7 +68,8 @@ export function buildOpeningPlacementDimensions(
|
||||
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
||||
]
|
||||
const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along]
|
||||
const round = (value: number) => Number.parseFloat(value.toFixed(2))
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const metricNotation = readFloorplanContext(ctx).metricNotation
|
||||
|
||||
// This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes
|
||||
// same-kind nodes; doors and windows need each other, so resolve the wall's
|
||||
@@ -118,7 +121,7 @@ export function buildOpeningPlacementDimensions(
|
||||
offsetNormal: outwardNormal,
|
||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `${round(gap.distance)}m`,
|
||||
text: formatConstructionLength(gap.distance, unit, 'editor', { metricNotation }),
|
||||
stroke: '#f97316',
|
||||
})
|
||||
}
|
||||
@@ -126,7 +129,9 @@ export function buildOpeningPlacementDimensions(
|
||||
// Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
|
||||
if (guides.equalSpacing) {
|
||||
const wallAngle = Math.atan2(dz, dx)
|
||||
const text = `${round(guides.equalSpacing.gap)}m`
|
||||
const text = formatConstructionLength(guides.equalSpacing.gap, unit, 'editor', {
|
||||
metricNotation,
|
||||
})
|
||||
for (const seg of guides.equalSpacing.segments) {
|
||||
out.push({
|
||||
kind: 'equal-spacing-badge',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership'
|
||||
|
||||
function slabCreatorCount(viewMode: '2d' | '3d' | 'split', trigger: SlabCompletionTrigger): number {
|
||||
const floorplanCommits = viewMode === '2d' && trigger === 'grid'
|
||||
const registryCommits = shouldRegistryCommitSlab(viewMode, trigger)
|
||||
return Number(floorplanCommits) + Number(registryCommits)
|
||||
}
|
||||
|
||||
describe('slab placement ownership', () => {
|
||||
test.each([
|
||||
['2d', 'grid'],
|
||||
['2d', 'keyboard'],
|
||||
['3d', 'grid'],
|
||||
['split', 'grid'],
|
||||
] as const)('commits one slab in %s from %s completion', (viewMode, trigger) => {
|
||||
expect(slabCreatorCount(viewMode, trigger)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export type SlabCompletionTrigger = 'grid' | 'keyboard'
|
||||
|
||||
export function shouldRegistryCommitSlab(
|
||||
viewMode: '2d' | '3d' | 'split',
|
||||
trigger: SlabCompletionTrigger,
|
||||
): boolean {
|
||||
return trigger === 'keyboard' || viewMode !== '2d'
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership'
|
||||
import { SlabNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -140,8 +141,10 @@ export const SlabTool: React.FC = () => {
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
if (shouldRegistryCommitSlab(useEditor.getState().viewMode, 'grid')) {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
}
|
||||
setPoints([])
|
||||
clearSlabSnapFeedback()
|
||||
} else {
|
||||
@@ -154,16 +157,18 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Finish the polygon (Enter or double-click): commit once there are enough
|
||||
// vertices. Closing near the first vertex (in onGridClick) is the third way.
|
||||
const finishDrawing = () => {
|
||||
const finishDrawing = (trigger: SlabCompletionTrigger) => {
|
||||
if (points.length < 3) return
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
if (shouldRegistryCommitSlab(useEditor.getState().viewMode, trigger)) {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
}
|
||||
setPoints([])
|
||||
clearSlabSnapFeedback()
|
||||
}
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
finishDrawing()
|
||||
finishDrawing('grid')
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -175,7 +180,7 @@ export const SlabTool: React.FC = () => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
finishDrawing()
|
||||
finishDrawing('keyboard')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
resolveStairTotalRise,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
stairFootprintAABB,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
|
||||
const MIN_CURVED_RISE = 0.3
|
||||
const MIN_CURVED_WIDTH = 0.4
|
||||
@@ -427,6 +429,12 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: StairNode,
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
linkedLevelIds: (node) =>
|
||||
node.toLevelId && node.toLevelId !== node.parentId ? [node.toLevelId as AnyNodeId] : [],
|
||||
} satisfies FloorplanNodeExtension<StairNodeType>,
|
||||
},
|
||||
snapProfile: 'structural',
|
||||
// A footprint with a clear front: you approach a stair from the low end,
|
||||
// which sits on the -Z side of the run (the run ascends along +Z). Show the
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
LevelNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildFloorplanStairEntry,
|
||||
createFloorplanContextExtensions,
|
||||
readFloorplanGeometryMetadata,
|
||||
} from '@pascal-app/editor'
|
||||
import {
|
||||
buildStairDocumentation,
|
||||
resolveStairPlanDirection,
|
||||
resolveStraightStairDirectionArrow,
|
||||
stairPlanBreakStep,
|
||||
} from './documentation'
|
||||
|
||||
function context(levelId = 'level_ground', unit: 'metric' | 'imperial' = 'metric') {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: LevelNode.parse({ id: levelId }),
|
||||
viewState: {
|
||||
selected: false,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
unit,
|
||||
palette: { measurementStroke: '#123456' } as NonNullable<
|
||||
GeometryContext['viewState']
|
||||
>['palette'],
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
|
||||
} satisfies GeometryContext
|
||||
}
|
||||
|
||||
function straightFixture() {
|
||||
const segment = StairSegmentNode.parse({
|
||||
id: 'sseg_flight',
|
||||
segmentType: 'stair',
|
||||
width: 1.2,
|
||||
length: 3,
|
||||
height: 2.5,
|
||||
stepCount: 10,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_main',
|
||||
parentId: 'level_ground',
|
||||
fromLevelId: 'level_ground',
|
||||
toLevelId: 'level_upper',
|
||||
stairType: 'straight',
|
||||
railingMode: 'both',
|
||||
railingHeight: 0.92,
|
||||
children: [segment.id],
|
||||
})
|
||||
const entry = buildFloorplanStairEntry(stair, [segment])!
|
||||
return { entry, segment, stair }
|
||||
}
|
||||
|
||||
function annotationTexts(geometry: FloorplanGeometry[]) {
|
||||
return geometry.flatMap((entry) =>
|
||||
entry.kind === 'text' &&
|
||||
readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation'
|
||||
? [entry.text]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
describe('stair construction documentation', () => {
|
||||
test('derives straight-flight direction, riser, tread, width, rail, and break annotations', () => {
|
||||
const { entry, stair } = straightFixture()
|
||||
const geometry = buildStairDocumentation(stair, entry, context())
|
||||
|
||||
expect(annotationTexts(geometry)).toEqual([
|
||||
'UP',
|
||||
'10 R @ 0.25m · T 0.3m · CLR W 1.2m',
|
||||
'RAIL BOTH @ 0.92m',
|
||||
])
|
||||
expect(
|
||||
geometry.some(
|
||||
(entry) =>
|
||||
entry.kind === 'polyline' &&
|
||||
readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('uses DN and reverses the direction arrow on the destination level', () => {
|
||||
const { entry, stair } = straightFixture()
|
||||
const downArrow = resolveStraightStairDirectionArrow(entry, 'down')
|
||||
|
||||
expect(resolveStairPlanDirection(stair, 'level_ground')).toBe('up')
|
||||
expect(resolveStairPlanDirection(stair, 'level_upper')).toBe('down')
|
||||
expect(annotationTexts(buildStairDocumentation(stair, entry, context('level_upper')))[0]).toBe(
|
||||
'DN',
|
||||
)
|
||||
expect(downArrow?.polyline.at(-1)).toEqual(entry.arrow?.polyline[0])
|
||||
expect(downArrow?.head[0]).toEqual(entry.arrow?.polyline[0])
|
||||
})
|
||||
|
||||
test('derives curved-stair tread depth at the walking line', () => {
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_curved',
|
||||
parentId: 'level_ground',
|
||||
stairType: 'curved',
|
||||
width: 1.2,
|
||||
innerRadius: 0.9,
|
||||
sweepAngle: Math.PI / 2,
|
||||
totalRise: 3,
|
||||
stepCount: 12,
|
||||
railingMode: 'left',
|
||||
railingHeight: 1,
|
||||
})
|
||||
const entry = buildFloorplanStairEntry(stair, [])!
|
||||
|
||||
expect(annotationTexts(buildStairDocumentation(stair, entry, context()))).toEqual([
|
||||
'12 R @ 0.25m · T(CL) 0.2m · CLR W 1.2m',
|
||||
'UP',
|
||||
'RAIL LEFT @ 1m',
|
||||
])
|
||||
})
|
||||
|
||||
test('uses the same construction notation in imperial plans', () => {
|
||||
const { entry, stair } = straightFixture()
|
||||
const texts = annotationTexts(
|
||||
buildStairDocumentation(stair, entry, context('level_ground', 'imperial')),
|
||||
)
|
||||
|
||||
expect(texts[1]).toContain(`10 R @ 9 13/16"`)
|
||||
expect(texts[1]).toContain(`CLR W 3'-11 1/4"`)
|
||||
})
|
||||
|
||||
test('aligns tread visibility with the documented break position', () => {
|
||||
expect(stairPlanBreakStep(10)).toBe(7)
|
||||
expect(stairPlanBreakStep(15)).toBe(11)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
type Point2D,
|
||||
resolveStairTotalRise,
|
||||
type StairNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import type {
|
||||
FloorplanStairArrowEntry,
|
||||
FloorplanStairEntry,
|
||||
FloorplanStairSegmentEntry,
|
||||
} from '@pascal-app/editor'
|
||||
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
|
||||
import {
|
||||
type ConstructionLengthProfile,
|
||||
type ConstructionMetricNotation,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
|
||||
const ANNOTATION_OFFSET = 0.28
|
||||
const ANNOTATION_FONT_SIZE = 0.125
|
||||
const DIRECTION_FONT_SIZE = 0.16
|
||||
const BREAK_POSITION = 0.68
|
||||
const BREAK_ZIGZAG = 0.07
|
||||
const MIN_ARROW_HEAD = 0.14
|
||||
const MAX_ARROW_HEAD = 0.24
|
||||
|
||||
export type StairPlanDirection = 'up' | 'down'
|
||||
|
||||
export function resolveStairPlanDirection(
|
||||
stair: StairNode,
|
||||
activeLevelId: string | null | undefined,
|
||||
): StairPlanDirection {
|
||||
if (
|
||||
activeLevelId &&
|
||||
stair.toLevelId &&
|
||||
stair.toLevelId !== stair.fromLevelId &&
|
||||
activeLevelId === stair.toLevelId
|
||||
) {
|
||||
return 'down'
|
||||
}
|
||||
return 'up'
|
||||
}
|
||||
|
||||
export function resolveStraightStairDirectionArrow(
|
||||
entry: FloorplanStairEntry,
|
||||
direction: StairPlanDirection,
|
||||
): FloorplanStairArrowEntry | null {
|
||||
const arrow = entry.arrow
|
||||
if (!arrow || direction === 'up') return arrow
|
||||
const polyline = [...arrow.polyline].reverse()
|
||||
const tip = polyline[polyline.length - 1]
|
||||
const tail = polyline[polyline.length - 2]
|
||||
if (!(tip && tail)) return null
|
||||
|
||||
const bodyLength = distance(tail, tip)
|
||||
if (bodyLength <= Number.EPSILON) return null
|
||||
const headLength = clamp(bodyLength * 0.72, MIN_ARROW_HEAD, MAX_ARROW_HEAD)
|
||||
const directionX = (tip.x - tail.x) / bodyLength
|
||||
const directionY = (tip.y - tail.y) / bodyLength
|
||||
const base = {
|
||||
x: tip.x - directionX * headLength,
|
||||
y: tip.y - directionY * headLength,
|
||||
}
|
||||
const halfWidth = headLength * 0.34
|
||||
return {
|
||||
polyline,
|
||||
head: [
|
||||
tip,
|
||||
{ x: base.x - directionY * halfWidth, y: base.y + directionX * halfWidth },
|
||||
{ x: base.x + directionY * halfWidth, y: base.y - directionX * halfWidth },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function stairPlanBreakStep(stepCount: number): number {
|
||||
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * BREAK_POSITION))
|
||||
}
|
||||
|
||||
export function buildStairDocumentation(
|
||||
stair: StairNode,
|
||||
entry: FloorplanStairEntry,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry[] {
|
||||
const activeLevelId = ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId
|
||||
const direction = resolveStairPlanDirection(stair, activeLevelId)
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const floorplanContext = readFloorplanContext(ctx)
|
||||
const profile: ConstructionLengthProfile =
|
||||
floorplanContext.purpose === 'document' ? 'document' : 'editor'
|
||||
const metricNotation = floorplanContext.metricNotation
|
||||
const stroke = ctx.viewState?.palette.measurementStroke ?? '#334155'
|
||||
return stair.stairType === 'straight'
|
||||
? buildStraightDocumentation(stair, entry, direction, unit, profile, metricNotation, stroke)
|
||||
: buildCurvedDocumentation(stair, direction, unit, profile, metricNotation, stroke)
|
||||
}
|
||||
|
||||
function buildStraightDocumentation(
|
||||
stair: StairNode,
|
||||
entry: FloorplanStairEntry,
|
||||
direction: StairPlanDirection,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const geometries: FloorplanGeometry[] = []
|
||||
const arrow = resolveStraightStairDirectionArrow(entry, direction)
|
||||
const arrowStart = arrow?.polyline[0]
|
||||
const arrowNext = arrow?.polyline[1]
|
||||
if (arrowStart && arrowNext) {
|
||||
const arrowDirection = normalizedDirection(arrowStart, arrowNext)
|
||||
const labelPoint = arrowDirection
|
||||
? {
|
||||
x: arrowStart.x - arrowDirection.y * 0.18,
|
||||
y: arrowStart.y + arrowDirection.x * 0.18,
|
||||
}
|
||||
: arrowStart
|
||||
geometries.push(
|
||||
annotationText(labelPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke),
|
||||
)
|
||||
}
|
||||
|
||||
let railNotePlaced = false
|
||||
for (const segmentEntry of entry.segments) {
|
||||
if (segmentEntry.segment.segmentType !== 'stair') continue
|
||||
const frame = segmentFrame(segmentEntry)
|
||||
if (!frame) continue
|
||||
const segment = segmentEntry.segment
|
||||
const riserCount = Math.max(1, Math.round(segment.stepCount))
|
||||
const riserHeight = segment.height / riserCount
|
||||
const treadDepth = segment.length / riserCount
|
||||
const rightAnchor = {
|
||||
x: frame.rightMid.x + frame.widthDirection.x * ANNOTATION_OFFSET,
|
||||
y: frame.rightMid.y + frame.widthDirection.y * ANNOTATION_OFFSET,
|
||||
}
|
||||
geometries.push(
|
||||
annotationText(
|
||||
rightAnchor,
|
||||
`${riserCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(segment.width, unit, profile, { metricNotation })}`,
|
||||
ANNOTATION_FONT_SIZE,
|
||||
stroke,
|
||||
),
|
||||
buildStraightBreakLine(segmentEntry, stroke),
|
||||
)
|
||||
|
||||
if (!railNotePlaced && stair.railingMode !== 'none') {
|
||||
const leftAnchor = {
|
||||
x: frame.leftMid.x - frame.widthDirection.x * ANNOTATION_OFFSET,
|
||||
y: frame.leftMid.y - frame.widthDirection.y * ANNOTATION_OFFSET,
|
||||
}
|
||||
geometries.push(
|
||||
annotationText(
|
||||
leftAnchor,
|
||||
`RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`,
|
||||
ANNOTATION_FONT_SIZE,
|
||||
stroke,
|
||||
),
|
||||
)
|
||||
railNotePlaced = true
|
||||
}
|
||||
}
|
||||
return geometries
|
||||
}
|
||||
|
||||
function buildCurvedDocumentation(
|
||||
stair: StairNode,
|
||||
direction: StairPlanDirection,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const stairType = stair.stairType === 'spiral' ? 'spiral' : 'curved'
|
||||
const stepCount = Math.max(stairType === 'spiral' ? 6 : 4, Math.round(stair.stepCount))
|
||||
const sweep = normalizedSweep(stair)
|
||||
const startAngle = -stair.rotation - sweep / 2
|
||||
const endAngle = startAngle + sweep
|
||||
const innerRadius = Math.max(stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius)
|
||||
const outerRadius = innerRadius + stair.width
|
||||
const walkingRadius = innerRadius + stair.width / 2
|
||||
const riserHeight = resolveStairTotalRise(stair, useScene.getState().nodes) / stepCount
|
||||
const treadDepth = (Math.abs(sweep) * walkingRadius) / stepCount
|
||||
const center = { x: stair.position[0], y: stair.position[2] }
|
||||
const noteAngle = (startAngle + endAngle) / 2
|
||||
const notePoint = arcPoint(center, outerRadius + ANNOTATION_OFFSET, noteAngle)
|
||||
const directionAngle = direction === 'up' ? startAngle + sweep * 0.18 : endAngle - sweep * 0.18
|
||||
const directionPoint = arcPoint(center, walkingRadius, directionAngle)
|
||||
const breakAngle = startAngle + sweep * BREAK_POSITION
|
||||
const geometries: FloorplanGeometry[] = [
|
||||
annotationText(
|
||||
notePoint,
|
||||
`${stepCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T(CL) ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(stair.width, unit, profile, { metricNotation })}`,
|
||||
ANNOTATION_FONT_SIZE,
|
||||
stroke,
|
||||
),
|
||||
annotationText(directionPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke),
|
||||
buildCurvedBreakLine(center, innerRadius, outerRadius, breakAngle, stroke),
|
||||
]
|
||||
if (stair.railingMode !== 'none') {
|
||||
geometries.push(
|
||||
annotationText(
|
||||
arcPoint(center, outerRadius + ANNOTATION_OFFSET * 2, noteAngle),
|
||||
`RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`,
|
||||
ANNOTATION_FONT_SIZE,
|
||||
stroke,
|
||||
),
|
||||
)
|
||||
}
|
||||
return geometries
|
||||
}
|
||||
|
||||
function buildStraightBreakLine(
|
||||
segmentEntry: FloorplanStairSegmentEntry,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.innerPolygon
|
||||
if (!(backLeft && backRight && frontRight && frontLeft)) {
|
||||
return { kind: 'group', children: [] }
|
||||
}
|
||||
const left = interpolate(backLeft, frontLeft, BREAK_POSITION)
|
||||
const right = interpolate(backRight, frontRight, BREAK_POSITION)
|
||||
const travel = normalizedDirection(backLeft, frontLeft) ?? { x: 0, y: 1 }
|
||||
return {
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
toTuple(left),
|
||||
offset(interpolate(left, right, 0.42), travel, BREAK_ZIGZAG),
|
||||
offset(interpolate(left, right, 0.5), travel, -BREAK_ZIGZAG),
|
||||
offset(interpolate(left, right, 0.58), travel, BREAK_ZIGZAG),
|
||||
toTuple(right),
|
||||
],
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: 1.5,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurvedBreakLine(
|
||||
center: Point2D,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
angle: number,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
const radial = { x: Math.cos(angle), y: Math.sin(angle) }
|
||||
const tangent = { x: -radial.y, y: radial.x }
|
||||
const pointAt = (t: number, tangentOffset = 0): FloorplanPoint => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * t
|
||||
return [
|
||||
center.x + radial.x * radius + tangent.x * tangentOffset,
|
||||
center.y + radial.y * radius + tangent.y * tangentOffset,
|
||||
]
|
||||
}
|
||||
return {
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
pointAt(0),
|
||||
pointAt(0.42, BREAK_ZIGZAG),
|
||||
pointAt(0.5, -BREAK_ZIGZAG),
|
||||
pointAt(0.58, BREAK_ZIGZAG),
|
||||
pointAt(1),
|
||||
],
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: 1.5,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
}
|
||||
}
|
||||
|
||||
function annotationText(
|
||||
point: Point2D,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fill: string,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'text',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
text,
|
||||
fontSize,
|
||||
fill,
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: fontSize * 0.22,
|
||||
paintOrder: 'stroke',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontWeight: 650,
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'central',
|
||||
upright: true,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
}
|
||||
}
|
||||
|
||||
function segmentFrame(segmentEntry: FloorplanStairSegmentEntry) {
|
||||
const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.polygon
|
||||
if (!(backLeft && backRight && frontRight && frontLeft)) return null
|
||||
const widthDirection = normalizedDirection(backLeft, backRight)
|
||||
if (!widthDirection) return null
|
||||
return {
|
||||
widthDirection,
|
||||
leftMid: interpolate(backLeft, frontLeft, 0.5),
|
||||
rightMid: interpolate(backRight, frontRight, 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedSweep(stair: StairNode): number {
|
||||
const defaultSweep = stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2
|
||||
const sweep = stair.sweepAngle ?? defaultSweep
|
||||
if (Math.abs(sweep) < Math.PI * 2) return sweep
|
||||
return Math.sign(sweep || 1) * (Math.PI * 2 - 0.001)
|
||||
}
|
||||
|
||||
function arcPoint(center: Point2D, radius: number, angle: number): Point2D {
|
||||
return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius }
|
||||
}
|
||||
|
||||
function normalizedDirection(start: Point2D, end: Point2D): Point2D | null {
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const length = Math.hypot(dx, dy)
|
||||
return length <= Number.EPSILON ? null : { x: dx / length, y: dy / length }
|
||||
}
|
||||
|
||||
function interpolate(start: Point2D, end: Point2D, t: number): Point2D {
|
||||
return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t }
|
||||
}
|
||||
|
||||
function offset(point: Point2D, direction: Point2D, amount: number): FloorplanPoint {
|
||||
return [point.x + direction.x * amount, point.y + direction.y * amount]
|
||||
}
|
||||
|
||||
function toTuple(point: Point2D): FloorplanPoint {
|
||||
return [point.x, point.y]
|
||||
}
|
||||
|
||||
function distance(first: Point2D, second: Point2D): number {
|
||||
return Math.hypot(second.x - first.x, second.y - first.y)
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
LevelNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { buildStairFloorplan } from './floorplan'
|
||||
|
||||
function textValues(geometry: FloorplanGeometry | null) {
|
||||
if (geometry?.kind !== 'group') return []
|
||||
return geometry.children.flatMap((child) =>
|
||||
child.kind === 'text' &&
|
||||
readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation'
|
||||
? [child.text]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
describe('buildStairFloorplan documentation', () => {
|
||||
test('integrates stair notes, break line, and visible treads below the break', () => {
|
||||
const segment = StairSegmentNode.parse({
|
||||
id: 'sseg_main',
|
||||
width: 1.2,
|
||||
length: 3,
|
||||
height: 2.5,
|
||||
stepCount: 10,
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_main',
|
||||
parentId: 'level_ground',
|
||||
fromLevelId: 'level_ground',
|
||||
toLevelId: 'level_upper',
|
||||
children: [segment.id],
|
||||
railingMode: 'both',
|
||||
})
|
||||
const geometry = buildStairFloorplan(stair, {
|
||||
resolve: () => undefined,
|
||||
children: [segment],
|
||||
siblings: [],
|
||||
parent: LevelNode.parse({ id: 'level_ground' }),
|
||||
} satisfies GeometryContext)
|
||||
|
||||
expect(textValues(geometry)[0]).toBe('UP')
|
||||
expect(textValues(geometry)).toContain('10 R @ 0.25m · T 0.3m · CLR W 1.2m')
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
expect(
|
||||
geometry.children.some(
|
||||
(child) =>
|
||||
child.kind === 'polyline' &&
|
||||
readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
geometry.children.filter((child) => child.kind === 'polygon' && child.fill === '#262626'),
|
||||
).toHaveLength(6)
|
||||
expect(geometry.children.some((child) => 'strokeDasharray' in child)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,15 @@ import {
|
||||
buildSvgAnnularSectorPath,
|
||||
buildSvgArcPath,
|
||||
buildSvgArrowHeadPoints,
|
||||
floorplanGeometryMetadata,
|
||||
getArcPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import {
|
||||
buildStairDocumentation,
|
||||
resolveStairPlanDirection,
|
||||
resolveStraightStairDirectionArrow,
|
||||
stairPlanBreakStep,
|
||||
} from './documentation'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan emitter for stair. The stair is the parent; its
|
||||
@@ -105,7 +112,10 @@ export function buildStairFloorplan(
|
||||
// Tread bars — one per visible step inside the segment.
|
||||
// `buildFloorplanStairEntry` already returns the thickened
|
||||
// polygons; we emit them as filled polygons.
|
||||
for (const treadBar of segmentEntry.treadBars) {
|
||||
const breakStep = stairPlanBreakStep(segmentEntry.segment.stepCount)
|
||||
for (let treadIndex = 0; treadIndex < segmentEntry.treadBars.length; treadIndex += 1) {
|
||||
if (treadIndex + 1 >= breakStep) continue
|
||||
const treadBar = segmentEntry.treadBars[treadIndex]!
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: toFloorplanPoints(treadBar),
|
||||
@@ -256,10 +266,9 @@ export function buildStairFloorplan(
|
||||
const stepBase = stairType === 'spiral' ? 6 : 4
|
||||
const stepCount = Math.max(stepBase, Math.round(stair.stepCount ?? 10))
|
||||
const stepSweep = normalizedSweepAngle / stepCount
|
||||
// For spirals only: the last ~32% of the sweep is dashed (matches
|
||||
// the legacy `dashedFromIndex = Math.floor(stepCount * 0.68)`).
|
||||
const dashedFromIndex = stairType === 'spiral' ? Math.floor(stepCount * 0.68) : Infinity
|
||||
const breakStep = stairPlanBreakStep(stepCount)
|
||||
for (let index = 0; index <= stepCount; index += 1) {
|
||||
if (index >= breakStep && index !== stepCount) continue
|
||||
const angle = sectorStartAngle + stepSweep * index
|
||||
const inner = getArcPlanPoint(stairCenter, innerRadius, angle)
|
||||
const outer = getArcPlanPoint(stairCenter, outerRadius, angle)
|
||||
@@ -268,8 +277,7 @@ export function buildStairFloorplan(
|
||||
// Curved: regular stroke everywhere, but both the starting and the
|
||||
// ending step lines are bolded (matches the legacy
|
||||
// `<FloorplanStairLayer>` curved branch).
|
||||
// Spiral: only the last step is accented + bolded; intermediate
|
||||
// steps past `dashedFromIndex` are dashed.
|
||||
// Spiral: only the last step is accented + bolded.
|
||||
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
|
||||
const stepWidth =
|
||||
stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1
|
||||
@@ -281,7 +289,6 @@ export function buildStairFloorplan(
|
||||
y2: outer.y,
|
||||
stroke: stairType === 'spiral' && isLast ? stairAccent : stairStroke,
|
||||
strokeWidth: stepWidth,
|
||||
strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
@@ -322,9 +329,18 @@ export function buildStairFloorplan(
|
||||
}
|
||||
|
||||
// 6. Direction arrow — head only, at the upper end of the sweep.
|
||||
const arrowAngle = visualSectorEndAngle - stepSweep * 0.8
|
||||
const direction = resolveStairPlanDirection(
|
||||
stair,
|
||||
ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId,
|
||||
)
|
||||
const arrowAngle =
|
||||
direction === 'up'
|
||||
? visualSectorEndAngle - stepSweep * 0.8
|
||||
: sectorStartAngle + stepSweep * 0.8
|
||||
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
|
||||
const tangentAngle = arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
|
||||
const sweepDirection = normalizedSweepAngle >= 0 ? 1 : -1
|
||||
const tangentAngle =
|
||||
arrowAngle + sweepDirection * (direction === 'up' ? Math.PI / 2 : -Math.PI / 2)
|
||||
const arrowSize = clamp(stair.width * (stairType === 'spiral' ? 0.18 : 0.16), 0.1, 0.18)
|
||||
const headPts = buildSvgArrowHeadPoints(arrowPoint, tangentAngle, arrowSize)
|
||||
children.push({
|
||||
@@ -332,6 +348,7 @@ export function buildStairFloorplan(
|
||||
points: headPts.map((p) => [p.x, p.y] as FloorplanPoint),
|
||||
fill: stairAccent,
|
||||
stroke: 'none',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
})
|
||||
|
||||
// 7. Resize arrows — mirror of the 3D `CurvedStairWidthArrow`,
|
||||
@@ -397,29 +414,38 @@ export function buildStairFloorplan(
|
||||
// the stair-segment chain in straight space and produces a malformed
|
||||
// polyline once the chain is laid around an arc.
|
||||
if (stairType === 'straight' && entry.arrow) {
|
||||
if (entry.arrow.polyline.length >= 2) {
|
||||
const direction = resolveStairPlanDirection(
|
||||
stair,
|
||||
ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId,
|
||||
)
|
||||
const directionArrow = resolveStraightStairDirectionArrow(entry, direction)
|
||||
if (directionArrow && directionArrow.polyline.length >= 2) {
|
||||
children.push({
|
||||
kind: 'polyline',
|
||||
points: toFloorplanPoints(entry.arrow.polyline),
|
||||
points: toFloorplanPoints(directionArrow.polyline),
|
||||
fill: 'none',
|
||||
stroke: stairAccent,
|
||||
strokeWidth: 0.02,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
opacity: showSelectedChrome ? 0.92 : 0.72,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
})
|
||||
}
|
||||
if (entry.arrow.head.length >= 3) {
|
||||
if (directionArrow && directionArrow.head.length >= 3) {
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: toFloorplanPoints(entry.arrow.head),
|
||||
points: toFloorplanPoints(directionArrow.head),
|
||||
fill: stairAccent,
|
||||
stroke: 'none',
|
||||
opacity: showSelectedChrome ? 0.92 : 0.72,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
children.push(...buildStairDocumentation(stair, entry, ctx))
|
||||
|
||||
// Whole-stair rotation handle — sister to the 3D `stairRotateHandle`
|
||||
// (arc-resize, curved-arrow). 2D doesn't have a dedicated curved-arrow
|
||||
// primitive, so we emit a `move-arrow` with the `'stair-rotate'`
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { StructuralGridNode } from '@pascal-app/core'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridReference,
|
||||
resolveStructuralGridSnap,
|
||||
} from './coordination'
|
||||
|
||||
const vertical = StructuralGridNode.parse({
|
||||
id: 'structural-grid_1',
|
||||
parentId: 'level_main',
|
||||
start: [2, 0],
|
||||
end: [2, 8],
|
||||
label: '1',
|
||||
})
|
||||
const horizontal = StructuralGridNode.parse({
|
||||
id: 'structural-grid_a',
|
||||
parentId: 'level_main',
|
||||
start: [0, 3],
|
||||
end: [8, 3],
|
||||
label: 'A',
|
||||
})
|
||||
|
||||
describe('structural-grid coordination', () => {
|
||||
test('snaps columns to a nearby grid intersection before an individual axis', () => {
|
||||
expect(resolveStructuralGridSnap([2.18, 3.12], [vertical, horizontal])).toMatchObject({
|
||||
point: [2, 3],
|
||||
kind: 'intersection',
|
||||
reference: 'A-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('projects onto one axis when no intersection is within range', () => {
|
||||
expect(resolveStructuralGridSnap([2.12, 6], [vertical, horizontal])).toMatchObject({
|
||||
point: [2, 6],
|
||||
kind: 'line',
|
||||
reference: '1',
|
||||
})
|
||||
})
|
||||
|
||||
test('does not snap beyond the configured distance or past an axis endpoint', () => {
|
||||
expect(resolveStructuralGridSnap([2.4, 6], [vertical, horizontal])).toBeNull()
|
||||
expect(resolveStructuralGridSnap([2.05, 8.4], [vertical], 0.25)).toBeNull()
|
||||
})
|
||||
|
||||
test('derives an associative alphabetic-numeric reference at the column center', () => {
|
||||
expect(resolveStructuralGridReference([2, 3], [vertical, horizontal])).toBe('A-1')
|
||||
expect(resolveStructuralGridReference([2, 3], [vertical, { ...horizontal, label: 'B' }])).toBe(
|
||||
'B-1',
|
||||
)
|
||||
})
|
||||
|
||||
test('collects only visible axes from the active level', () => {
|
||||
const hidden = StructuralGridNode.parse({
|
||||
...horizontal,
|
||||
id: 'structural-grid_hidden',
|
||||
visible: false,
|
||||
})
|
||||
const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal, [hidden.id]: hidden }
|
||||
expect(collectStructuralGridAxes(nodes, 'level_main').map((axis) => axis.id)).toEqual([
|
||||
vertical.id,
|
||||
horizontal.id,
|
||||
])
|
||||
expect(collectStructuralGridAxes(nodes, 'level_other')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { AnyNode, StructuralGridNode } from '@pascal-app/core'
|
||||
|
||||
export type StructuralGridPoint = readonly [x: number, z: number]
|
||||
|
||||
export type StructuralGridSnap = {
|
||||
point: [number, number]
|
||||
distance: number
|
||||
kind: 'intersection' | 'line'
|
||||
axes: StructuralGridNode[]
|
||||
reference: string
|
||||
}
|
||||
|
||||
export const STRUCTURAL_GRID_SNAP_DISTANCE_M = 0.25
|
||||
export const STRUCTURAL_GRID_REFERENCE_TOLERANCE_M = 0.02
|
||||
|
||||
const EPSILON = 1e-9
|
||||
|
||||
export function collectStructuralGridAxes(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
levelId: string | null | undefined,
|
||||
): StructuralGridNode[] {
|
||||
if (!levelId) return []
|
||||
return Object.values(nodes).filter(
|
||||
(node): node is StructuralGridNode =>
|
||||
node.type === 'structural-grid' && node.parentId === levelId && node.visible !== false,
|
||||
)
|
||||
}
|
||||
|
||||
export function formatStructuralGridReference(axes: readonly StructuralGridNode[]): string {
|
||||
const labels = [...new Set(axes.map((axis) => axis.label.trim()).filter(Boolean))]
|
||||
labels.sort((left, right) => {
|
||||
const leftFamily = structuralGridLabelSortFamily(left)
|
||||
const rightFamily = structuralGridLabelSortFamily(right)
|
||||
if (leftFamily !== rightFamily) return leftFamily - rightFamily
|
||||
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
return labels.join('-')
|
||||
}
|
||||
|
||||
export function resolveStructuralGridSnap(
|
||||
point: StructuralGridPoint,
|
||||
axes: readonly StructuralGridNode[],
|
||||
maxDistance = STRUCTURAL_GRID_SNAP_DISTANCE_M,
|
||||
): StructuralGridSnap | null {
|
||||
let nearestIntersection: StructuralGridSnap | null = null
|
||||
|
||||
for (let firstIndex = 0; firstIndex < axes.length; firstIndex += 1) {
|
||||
const first = axes[firstIndex]
|
||||
if (!first) continue
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < axes.length; secondIndex += 1) {
|
||||
const second = axes[secondIndex]
|
||||
if (!second) continue
|
||||
const intersection = segmentIntersection(first.start, first.end, second.start, second.end)
|
||||
if (!intersection) continue
|
||||
const distance = pointDistance(point, intersection)
|
||||
if (
|
||||
distance > maxDistance ||
|
||||
(nearestIntersection && distance >= nearestIntersection.distance)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
nearestIntersection = {
|
||||
point: intersection,
|
||||
distance,
|
||||
kind: 'intersection',
|
||||
axes: [first, second],
|
||||
reference: formatStructuralGridReference([first, second]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestIntersection) return nearestIntersection
|
||||
|
||||
let nearestLine: StructuralGridSnap | null = null
|
||||
for (const axis of axes) {
|
||||
const projected = closestPointOnSegment(point, axis.start, axis.end)
|
||||
const distance = pointDistance(point, projected)
|
||||
if (distance > maxDistance || (nearestLine && distance >= nearestLine.distance)) continue
|
||||
nearestLine = {
|
||||
point: projected,
|
||||
distance,
|
||||
kind: 'line',
|
||||
axes: [axis],
|
||||
reference: formatStructuralGridReference([axis]),
|
||||
}
|
||||
}
|
||||
return nearestLine
|
||||
}
|
||||
|
||||
export function resolveStructuralGridReference(
|
||||
point: StructuralGridPoint,
|
||||
axes: readonly StructuralGridNode[],
|
||||
tolerance = STRUCTURAL_GRID_REFERENCE_TOLERANCE_M,
|
||||
): string | null {
|
||||
const matching = axes.filter(
|
||||
(axis) => pointDistance(point, closestPointOnSegment(point, axis.start, axis.end)) <= tolerance,
|
||||
)
|
||||
const reference = formatStructuralGridReference(matching)
|
||||
return reference || null
|
||||
}
|
||||
|
||||
function structuralGridLabelSortFamily(label: string): number {
|
||||
if (/^[A-Za-z]+$/.test(label)) return 0
|
||||
if (/^\d+$/.test(label)) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function pointDistance(first: StructuralGridPoint, second: StructuralGridPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
function closestPointOnSegment(
|
||||
point: StructuralGridPoint,
|
||||
start: StructuralGridPoint,
|
||||
end: StructuralGridPoint,
|
||||
): [number, number] {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared <= EPSILON) return [start[0], start[1]]
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
||||
)
|
||||
return [start[0] + dx * t, start[1] + dz * t]
|
||||
}
|
||||
|
||||
function segmentIntersection(
|
||||
firstStart: StructuralGridPoint,
|
||||
firstEnd: StructuralGridPoint,
|
||||
secondStart: StructuralGridPoint,
|
||||
secondEnd: StructuralGridPoint,
|
||||
): [number, number] | null {
|
||||
const firstDx = firstEnd[0] - firstStart[0]
|
||||
const firstDz = firstEnd[1] - firstStart[1]
|
||||
const secondDx = secondEnd[0] - secondStart[0]
|
||||
const secondDz = secondEnd[1] - secondStart[1]
|
||||
const denominator = firstDx * secondDz - firstDz * secondDx
|
||||
if (Math.abs(denominator) <= EPSILON) return null
|
||||
|
||||
const offsetX = secondStart[0] - firstStart[0]
|
||||
const offsetZ = secondStart[1] - firstStart[1]
|
||||
const firstT = (offsetX * secondDz - offsetZ * secondDx) / denominator
|
||||
const secondT = (offsetX * firstDz - offsetZ * firstDx) / denominator
|
||||
if (firstT < -EPSILON || firstT > 1 + EPSILON || secondT < -EPSILON || secondT > 1 + EPSILON) {
|
||||
return null
|
||||
}
|
||||
return [firstStart[0] + firstDx * firstT, firstStart[1] + firstDz * firstT]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { structuralGridDefinition } from './definition'
|
||||
|
||||
describe('structuralGridDefinition', () => {
|
||||
test('registers as a floor-plan-only structural annotation', () => {
|
||||
expect(structuralGridDefinition.kind).toBe('structural-grid')
|
||||
expect(structuralGridDefinition.bake).toBe('strip')
|
||||
expect(structuralGridDefinition.dirtyTracking).toBe(false)
|
||||
expect(structuralGridDefinition.floorplan).toBeFunction()
|
||||
expect(structuralGridDefinition.capabilities.selectable).toBeDefined()
|
||||
expect(getFloorplanNodeExtension(structuralGridDefinition)?.preferredView).toBe('2d')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildStructuralGridFloorplan } from './floorplan'
|
||||
import { StructuralGridNode } from './schema'
|
||||
|
||||
export const structuralGridDefinition: NodeDefinition<typeof StructuralGridNode> = {
|
||||
kind: 'structural-grid',
|
||||
bake: 'strip',
|
||||
schemaVersion: 1,
|
||||
schema: StructuralGridNode,
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
preferredView: '2d',
|
||||
} satisfies FloorplanNodeExtension<StructuralGridNode>,
|
||||
},
|
||||
snapProfile: 'structural',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
start: [0, 0],
|
||||
end: [0, 5],
|
||||
label: '1',
|
||||
showStartBubble: true,
|
||||
showEndBubble: true,
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
floorplan: buildStructuralGridFloorplan,
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Start grid axis' },
|
||||
{ key: 'Left click', label: 'Finish grid axis' },
|
||||
{ key: 'Alt', label: 'Bypass snapping' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Structural Grid',
|
||||
description: 'Persistent construction grid axis with identification bubbles.',
|
||||
icon: { kind: 'url', src: '/icons/structural-grid.webp' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 72,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A floor-plan structural datum axis defined by two level-local points and a grid identifier.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, StructuralGridNode } from '@pascal-app/core'
|
||||
import {
|
||||
alphabeticGridLabel,
|
||||
nextStructuralGridLabel,
|
||||
shouldConsumeStructuralGridPointerEvent,
|
||||
snapStructuralGridAngle,
|
||||
structuralGridLabelFamily,
|
||||
} from './floorplan-tool'
|
||||
|
||||
describe('structural-grid drafting helpers', () => {
|
||||
test('assigns numbers to vertical axes and letters to horizontal axes', () => {
|
||||
expect(structuralGridLabelFamily([2, 0], [2, 8])).toBe('numeric')
|
||||
expect(structuralGridLabelFamily([0, 3], [8, 3])).toBe('alphabetic')
|
||||
})
|
||||
|
||||
test('continues labels within the active level and direction family', () => {
|
||||
const vertical = StructuralGridNode.parse({
|
||||
id: 'structural-grid_1',
|
||||
parentId: 'level_main',
|
||||
start: [1, 0],
|
||||
end: [1, 6],
|
||||
label: '1',
|
||||
})
|
||||
const horizontal = StructuralGridNode.parse({
|
||||
id: 'structural-grid_a',
|
||||
parentId: 'level_main',
|
||||
start: [0, 1],
|
||||
end: [6, 1],
|
||||
label: 'A',
|
||||
})
|
||||
const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal } as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
|
||||
expect(nextStructuralGridLabel(nodes, 'level_main', [2, 0], [2, 6])).toBe('2')
|
||||
expect(nextStructuralGridLabel(nodes, 'level_main', [0, 2], [6, 2])).toBe('B')
|
||||
expect(nextStructuralGridLabel(nodes, 'level_other', [2, 0], [2, 6])).toBe('1')
|
||||
})
|
||||
|
||||
test('supports labels beyond Z and snaps angles to 45-degree increments', () => {
|
||||
expect(alphabeticGridLabel(25)).toBe('Z')
|
||||
expect(alphabeticGridLabel(26)).toBe('AA')
|
||||
const snapped = snapStructuralGridAngle([0, 0], [4, 0.4])
|
||||
expect(snapped[1]).toBeCloseTo(0)
|
||||
expect(Math.hypot(snapped[0], snapped[1])).toBeCloseTo(Math.hypot(4, 0.4))
|
||||
})
|
||||
|
||||
test('leaves right-button drag moves available for floor-plan rotation', () => {
|
||||
expect(
|
||||
shouldConsumeStructuralGridPointerEvent({
|
||||
type: 'pointermove',
|
||||
button: -1,
|
||||
buttons: 2,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
StructuralGridNode,
|
||||
type StructuralGridNode as StructuralGridNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
clearSurfacePlanSnapFeedback,
|
||||
type FloorplanToolContext,
|
||||
isAngleSnapActive,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
resolveSurfacePlanPointSnap,
|
||||
triggerSFX,
|
||||
useFloorplanRender,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const MIN_GRID_LENGTH = 0.01
|
||||
const GRID_BUBBLE_RADIUS = 0.22
|
||||
const GRID_LABEL_SIZE = 0.18
|
||||
const ANGLE_INCREMENT = Math.PI / 4
|
||||
|
||||
type PlanPoint = [number, number]
|
||||
export type StructuralGridLabelFamily = 'numeric' | 'alphabetic'
|
||||
|
||||
export function shouldConsumeStructuralGridPointerEvent(event: {
|
||||
type: string
|
||||
button: number
|
||||
buttons: number
|
||||
}): boolean {
|
||||
if (event.type === 'pointerdown') return event.button === 0
|
||||
return (event.buttons & 0b110) === 0
|
||||
}
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
return step > 0 ? Math.round(value / step) * step : value
|
||||
}
|
||||
|
||||
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null {
|
||||
const matrix = group.getScreenCTM()
|
||||
if (!matrix) return null
|
||||
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
|
||||
return [local.x, local.y]
|
||||
}
|
||||
|
||||
export function structuralGridLabelFamily(
|
||||
start: PlanPoint,
|
||||
end: PlanPoint,
|
||||
): StructuralGridLabelFamily {
|
||||
return Math.abs(end[1] - start[1]) >= Math.abs(end[0] - start[0]) ? 'numeric' : 'alphabetic'
|
||||
}
|
||||
|
||||
export function alphabeticGridLabel(index: number): string {
|
||||
let value = Math.max(0, Math.floor(index))
|
||||
let label = ''
|
||||
do {
|
||||
label = String.fromCharCode(65 + (value % 26)) + label
|
||||
value = Math.floor(value / 26) - 1
|
||||
} while (value >= 0)
|
||||
return label
|
||||
}
|
||||
|
||||
export function nextStructuralGridLabel(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
levelId: string,
|
||||
start: PlanPoint,
|
||||
end: PlanPoint,
|
||||
): string {
|
||||
const family = structuralGridLabelFamily(start, end)
|
||||
const used = new Set(
|
||||
Object.values(nodes)
|
||||
.filter(
|
||||
(node): node is StructuralGridNodeType =>
|
||||
node.type === 'structural-grid' &&
|
||||
node.parentId === levelId &&
|
||||
structuralGridLabelFamily(node.start, node.end) === family,
|
||||
)
|
||||
.map((node) => node.label.toUpperCase()),
|
||||
)
|
||||
|
||||
for (let index = 0; ; index += 1) {
|
||||
const candidate = family === 'numeric' ? String(index + 1) : alphabeticGridLabel(index)
|
||||
if (!used.has(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
export function snapStructuralGridAngle(start: PlanPoint, point: PlanPoint): PlanPoint {
|
||||
const dx = point[0] - start[0]
|
||||
const dz = point[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < MIN_GRID_LENGTH) return point
|
||||
const angle = Math.round(Math.atan2(dz, dx) / ANGLE_INCREMENT) * ANGLE_INCREMENT
|
||||
return [start[0] + Math.cos(angle) * length, start[1] + Math.sin(angle) * length]
|
||||
}
|
||||
|
||||
export function FloorplanStructuralGridToolLayer({
|
||||
activeLevelId,
|
||||
finishTool,
|
||||
gridSnapStep,
|
||||
sceneApi,
|
||||
selectNode,
|
||||
}: FloorplanToolContext) {
|
||||
const groupRef = useRef<SVGGElement>(null)
|
||||
const startRef = useRef<PlanPoint | null>(null)
|
||||
const [start, setStart] = useState<PlanPoint | null>(null)
|
||||
const [hover, setHover] = useState<PlanPoint | null>(null)
|
||||
const renderContext = useFloorplanRender()
|
||||
|
||||
useEffect(() => {
|
||||
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'structural-grid' })
|
||||
return () =>
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'structural-grid')
|
||||
}, [])
|
||||
|
||||
const updateStart = useCallback((point: PlanPoint | null) => {
|
||||
startRef.current = point
|
||||
setStart(point)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateStart(null)
|
||||
setHover(null)
|
||||
const group = groupRef.current
|
||||
const svg = group?.ownerSVGElement
|
||||
if (!(activeLevelId && group && svg)) return
|
||||
|
||||
const consume = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const resolveEvent = (event: MouseEvent | PointerEvent): PlanPoint | null => {
|
||||
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
|
||||
if (!raw) return null
|
||||
const anglePoint =
|
||||
startRef.current && !event.altKey && isAngleSnapActive()
|
||||
? snapStructuralGridAngle(startRef.current, raw)
|
||||
: raw
|
||||
const step = !event.altKey && isGridSnapActive() ? gridSnapStep : 0
|
||||
const fallback: PlanPoint = [snap(anglePoint[0], step), snap(anglePoint[1], step)]
|
||||
const snapped = resolveSurfacePlanPointSnap({
|
||||
rawPoint: anglePoint,
|
||||
fallbackPoint: fallback,
|
||||
levelId: activeLevelId,
|
||||
magnetic: !event.altKey && isMagneticSnapActive(),
|
||||
align: isMagneticSnapActive(),
|
||||
})
|
||||
return snapped.point
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (shouldConsumeStructuralGridPointerEvent(event)) consume(event)
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (shouldConsumeStructuralGridPointerEvent(event)) consume(event)
|
||||
setHover(resolveEvent(event))
|
||||
}
|
||||
const onPointerLeave = () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
setHover(null)
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
consume(event)
|
||||
const point = resolveEvent(event)
|
||||
if (!point) return
|
||||
const currentStart = startRef.current
|
||||
if (!currentStart) {
|
||||
updateStart(point)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
return
|
||||
}
|
||||
if (Math.hypot(point[0] - currentStart[0], point[1] - currentStart[1]) < MIN_GRID_LENGTH) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, currentStart, point)
|
||||
const node = StructuralGridNode.parse({
|
||||
name: `Grid ${label}`,
|
||||
start: currentStart,
|
||||
end: point,
|
||||
label,
|
||||
})
|
||||
sceneApi.upsert(node, activeLevelId as AnyNodeId)
|
||||
selectNode(node.id)
|
||||
triggerSFX('sfx:structure-build')
|
||||
updateStart(null)
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
markToolCancelConsumed()
|
||||
if (startRef.current) {
|
||||
updateStart(null)
|
||||
return
|
||||
}
|
||||
finishTool()
|
||||
}
|
||||
const onBlur = () => clearSurfacePlanSnapFeedback()
|
||||
|
||||
svg.addEventListener('pointerdown', onPointerDown, true)
|
||||
svg.addEventListener('pointermove', onPointerMove, true)
|
||||
svg.addEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.addEventListener('click', onClick, true)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
svg.removeEventListener('pointerdown', onPointerDown, true)
|
||||
svg.removeEventListener('pointermove', onPointerMove, true)
|
||||
svg.removeEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.removeEventListener('click', onClick, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [activeLevelId, finishTool, gridSnapStep, sceneApi, selectNode, updateStart])
|
||||
|
||||
if (!activeLevelId) return null
|
||||
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
|
||||
const reticleRadius = 9 * unitsPerPixel
|
||||
const label =
|
||||
start && hover ? nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, start, hover) : null
|
||||
|
||||
const renderBubble = (point: PlanPoint, key: string) => (
|
||||
<g key={key} pointerEvents="none">
|
||||
<circle
|
||||
cx={point[0]}
|
||||
cy={point[1]}
|
||||
fill="#ffffff"
|
||||
r={GRID_BUBBLE_RADIUS}
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<g
|
||||
transform={`translate(${point[0]} ${point[1]}) rotate(${-(renderContext?.sceneRotationDeg ?? 0)})`}
|
||||
>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill="#0369a1"
|
||||
fontSize={GRID_LABEL_SIZE}
|
||||
fontWeight={700}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
|
||||
return (
|
||||
<g ref={groupRef}>
|
||||
{start && hover && label ? (
|
||||
<g pointerEvents="none">
|
||||
<line
|
||||
stroke="#0ea5e9"
|
||||
strokeDasharray="10 4 2 4"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={start[0]}
|
||||
x2={hover[0]}
|
||||
y1={start[1]}
|
||||
y2={hover[1]}
|
||||
/>
|
||||
{renderBubble(start, 'start')}
|
||||
{renderBubble(hover, 'end')}
|
||||
</g>
|
||||
) : null}
|
||||
{hover ? (
|
||||
<g pointerEvents="none">
|
||||
<circle
|
||||
cx={hover[0]}
|
||||
cy={hover[1]}
|
||||
fill="none"
|
||||
r={reticleRadius}
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<line
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover[0] - reticleRadius * 1.4}
|
||||
x2={hover[0] + reticleRadius * 1.4}
|
||||
y1={hover[1]}
|
||||
y2={hover[1]}
|
||||
/>
|
||||
<line
|
||||
stroke="#0ea5e9"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover[0]}
|
||||
x2={hover[0]}
|
||||
y1={hover[1] - reticleRadius * 1.4}
|
||||
y2={hover[1] + reticleRadius * 1.4}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorplanStructuralGridToolLayer
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type GeometryContext, StructuralGridNode } from '@pascal-app/core'
|
||||
import { buildStructuralGridFloorplan } from './floorplan'
|
||||
|
||||
const context = {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
} satisfies GeometryContext
|
||||
|
||||
describe('buildStructuralGridFloorplan', () => {
|
||||
test('draws a datum axis with labels at both ends', () => {
|
||||
const grid = StructuralGridNode.parse({
|
||||
id: 'structural-grid_axis-1',
|
||||
start: [2, 1],
|
||||
end: [2, 8],
|
||||
label: '3',
|
||||
})
|
||||
|
||||
const geometry = buildStructuralGridFloorplan(grid, context)
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
|
||||
expect(geometry.children[0]).toMatchObject({
|
||||
kind: 'line',
|
||||
x1: 2,
|
||||
y1: 1,
|
||||
x2: 2,
|
||||
y2: 8,
|
||||
strokeDasharray: '10 4 2 4',
|
||||
})
|
||||
expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(2)
|
||||
expect(JSON.stringify(geometry)).toContain('"text":"3"')
|
||||
})
|
||||
|
||||
test('respects independent endpoint-bubble visibility', () => {
|
||||
const grid = StructuralGridNode.parse({
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
label: 'A',
|
||||
showStartBubble: false,
|
||||
})
|
||||
|
||||
const geometry = buildStructuralGridFloorplan(grid, context)
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('omits a degenerate axis', () => {
|
||||
const grid = StructuralGridNode.parse({ start: [1, 1], end: [1, 1] })
|
||||
expect(buildStructuralGridFloorplan(grid, context)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
StructuralGridNode,
|
||||
} from '@pascal-app/core'
|
||||
import { withFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
|
||||
const GRID_BUBBLE_RADIUS = 0.22
|
||||
const GRID_LABEL_SIZE = 0.18
|
||||
|
||||
function bubble(point: FloorplanPoint, label: string, stroke: string): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: point[0],
|
||||
cy: point[1],
|
||||
r: GRID_BUBBLE_RADIUS,
|
||||
fill: '#ffffff',
|
||||
stroke,
|
||||
strokeWidth: 1.2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
text: label,
|
||||
fontSize: GRID_LABEL_SIZE,
|
||||
fill: stroke,
|
||||
fontWeight: 700,
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'middle',
|
||||
upright: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStructuralGridFloorplan(
|
||||
node: StructuralGridNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const length = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1])
|
||||
if (length < 0.001) return null
|
||||
|
||||
const selected = ctx.viewState?.selected ?? false
|
||||
const highlighted = ctx.viewState?.highlighted ?? false
|
||||
const palette = ctx.viewState?.palette
|
||||
const active = selected || highlighted
|
||||
const stroke = active && palette ? palette.selectedStroke : '#475569'
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'line',
|
||||
x1: node.start[0],
|
||||
y1: node.start[1],
|
||||
x2: node.end[0],
|
||||
y2: node.end[1],
|
||||
stroke,
|
||||
strokeWidth: active ? 1.6 : 1,
|
||||
strokeDasharray: '10 4 2 4',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'stroke',
|
||||
},
|
||||
]
|
||||
|
||||
if (node.showStartBubble) children.push(bubble(node.start, node.label, stroke))
|
||||
if (node.showEndBubble) children.push(bubble(node.end, node.label, stroke))
|
||||
|
||||
return withFloorplanGeometryMetadata(
|
||||
{ kind: 'group', children },
|
||||
{ annotationRole: 'structural-grid' },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { structuralGridDefinition } from './definition'
|
||||
@@ -0,0 +1 @@
|
||||
export { StructuralGridNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, WallNode, type WallNode as WallNodeType } from '@pascal-app/core'
|
||||
import { constructionDimensionStandard } from '../shared/construction-dimension-standards'
|
||||
import {
|
||||
buildLevelWallConstructionDimensionPlan,
|
||||
type PlannedConstructionDimension,
|
||||
renderPlannedConstructionDimensions,
|
||||
} from './construction-dimensions'
|
||||
import { computeWallFloorplanLevelData } from './floorplan'
|
||||
|
||||
function wall(overrides: Partial<WallNodeType>): WallNodeType {
|
||||
return WallNode.parse({
|
||||
id: 'wall',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [1, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.2,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.04,
|
||||
materialRef: 'library:cladding',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false) {
|
||||
const top = wall({
|
||||
id: 'wall_top',
|
||||
start: [0, 0],
|
||||
end: splitAtPartition ? [4, 0] : [10, 0],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const topContinuation = splitAtPartition
|
||||
? wall({
|
||||
id: 'wall_top_continuation',
|
||||
start: [4, 0],
|
||||
end: [10, 0],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
: undefined
|
||||
const right = wall({
|
||||
id: 'wall_right',
|
||||
start: [10, 0],
|
||||
end: [10, -6],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const bottom = wall({
|
||||
id: 'wall_bottom',
|
||||
start: [10, -6],
|
||||
end: [0, -6],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const left = wall({
|
||||
id: 'wall_left',
|
||||
start: [0, -6],
|
||||
end: [0, 0],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const partition = wall({
|
||||
id: 'wall_partition',
|
||||
start: [4, partitionSpansPlan ? -6 : -4],
|
||||
end: [4, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'partition-stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.12,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-finish-left',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-finish-right',
|
||||
role: 'interior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.1,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const walls = [top, ...(topContinuation ? [topContinuation] : []), right, bottom, left, partition]
|
||||
const nodes = Object.fromEntries(walls.map((candidate) => [candidate.id, candidate])) as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
return { nodes, top, walls }
|
||||
}
|
||||
|
||||
function topFacadePlan(
|
||||
datumPolicy: 'wall-face' | 'finish-face' | 'centerline' | 'structural-face',
|
||||
splitAtPartition = false,
|
||||
) {
|
||||
const { nodes, top, walls } = topFacadeFixture(splitAtPartition)
|
||||
return (
|
||||
buildLevelWallConstructionDimensionPlan(
|
||||
walls,
|
||||
nodes,
|
||||
constructionDimensionStandard({ datumPolicy }),
|
||||
).get(top.id) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
function tierEntries(
|
||||
plan: readonly PlannedConstructionDimension[],
|
||||
tier: PlannedConstructionDimension['tier'],
|
||||
) {
|
||||
return plan.filter((entry) => entry.tier === tier)
|
||||
}
|
||||
|
||||
describe('automatic wall dimension reference policy', () => {
|
||||
test('keeps exterior corner witnesses on outside stud faces in every mode', () => {
|
||||
for (const datumPolicy of ['finish-face', 'centerline', 'structural-face'] as const) {
|
||||
const overall = tierEntries(topFacadePlan(datumPolicy), 'overall')[0]
|
||||
|
||||
expect(overall?.start[0]).toBeCloseTo(-0.1)
|
||||
expect(overall?.end[0]).toBeCloseTo(10.1)
|
||||
expect(overall?.start[1]).toBeCloseTo(0.1)
|
||||
expect(overall?.end[1]).toBeCloseTo(0.1)
|
||||
}
|
||||
})
|
||||
|
||||
test('applies the selected reference only to the intersecting partition', () => {
|
||||
const intersection = (datumPolicy: 'finish-face' | 'centerline' | 'structural-face') => {
|
||||
const entries = tierEntries(topFacadePlan(datumPolicy), 'partitions')
|
||||
return entries[0]?.end[0]
|
||||
}
|
||||
|
||||
expect(intersection('finish-face')).toBeCloseTo(3.92)
|
||||
expect(intersection('centerline')).toBeCloseTo(4)
|
||||
expect(intersection('structural-face')).toBeCloseTo(3.94)
|
||||
})
|
||||
|
||||
test('keeps finished faces, centerline, and face of stud as distinct display modes', () => {
|
||||
const { nodes, top, walls } = topFacadeFixture(true)
|
||||
const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes })
|
||||
const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
const partitionChain = tierEntries(
|
||||
levelData.constructionDimensionsByReference[reference].get(top.id) ?? [],
|
||||
'partitions',
|
||||
)
|
||||
const rendered = renderPlannedConstructionDimensions(partitionChain, 'metric')
|
||||
const dimensionString = rendered[0]
|
||||
return dimensionString?.kind === 'dimension-string' ? dimensionString.segments : []
|
||||
}
|
||||
|
||||
expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([
|
||||
'3.92m',
|
||||
'0.26m',
|
||||
'6.02m',
|
||||
])
|
||||
expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m'])
|
||||
expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([
|
||||
'4.04m',
|
||||
'6.16m',
|
||||
])
|
||||
})
|
||||
|
||||
test('renders a face-of-stud partition chain with one shared witness', () => {
|
||||
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
|
||||
const partitionChain = tierEntries(topFacadePlan('structural-face'), 'partitions')
|
||||
const rendered = renderPlannedConstructionDimensions(
|
||||
partitionChain,
|
||||
'metric',
|
||||
undefined,
|
||||
'editor',
|
||||
standard,
|
||||
)
|
||||
|
||||
expect(rendered).toHaveLength(1)
|
||||
const dimensionString = rendered[0]
|
||||
expect(dimensionString?.kind).toBe('dimension-string')
|
||||
if (dimensionString?.kind !== 'dimension-string') return
|
||||
expect(dimensionString.segments).toHaveLength(2)
|
||||
expect(dimensionString.segments[0]?.end[0]).toBeCloseTo(3.94)
|
||||
expect(dimensionString.segments[1]?.start[0]).toBeCloseTo(3.94)
|
||||
})
|
||||
|
||||
test('uses only one stud face when the facade is split at the intersecting wall', () => {
|
||||
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
|
||||
const partitionChain = tierEntries(topFacadePlan('structural-face', true), 'partitions')
|
||||
const rendered = renderPlannedConstructionDimensions(
|
||||
partitionChain,
|
||||
'metric',
|
||||
undefined,
|
||||
'editor',
|
||||
standard,
|
||||
)
|
||||
const dimensionString = rendered[0]
|
||||
|
||||
expect(rendered).toHaveLength(1)
|
||||
expect(dimensionString?.kind).toBe('dimension-string')
|
||||
if (dimensionString?.kind !== 'dimension-string') return
|
||||
expect(dimensionString.segments).toHaveLength(2)
|
||||
expect(dimensionString.segments.map((segment) => segment.text)).not.toContain('0.12m')
|
||||
})
|
||||
|
||||
test('uses the same left or top stud face from opposing sides of the plan', () => {
|
||||
const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' })
|
||||
const verticalFixture = topFacadeFixture(false, true)
|
||||
const verticalPlan = buildLevelWallConstructionDimensionPlan(
|
||||
verticalFixture.walls,
|
||||
verticalFixture.nodes,
|
||||
standard,
|
||||
)
|
||||
const verticalReference = (wallId: string) =>
|
||||
tierEntries(verticalPlan.get(wallId) ?? [], 'partitions')[0]?.end
|
||||
|
||||
expect(verticalReference('wall_top')?.[0]).toBeCloseTo(3.94)
|
||||
expect(verticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94)
|
||||
|
||||
const reversedVerticalWalls = verticalFixture.walls.map((candidate) =>
|
||||
candidate.id === 'wall_partition'
|
||||
? { ...candidate, start: candidate.end, end: candidate.start }
|
||||
: candidate,
|
||||
)
|
||||
const reversedVerticalNodes = Object.fromEntries(
|
||||
reversedVerticalWalls.map((candidate) => [candidate.id, candidate]),
|
||||
) as Record<string, AnyNode>
|
||||
const reversedVerticalPlan = buildLevelWallConstructionDimensionPlan(
|
||||
reversedVerticalWalls,
|
||||
reversedVerticalNodes,
|
||||
standard,
|
||||
)
|
||||
const reversedVerticalReference = (wallId: string) =>
|
||||
tierEntries(reversedVerticalPlan.get(wallId) ?? [], 'partitions')[0]?.end
|
||||
|
||||
expect(reversedVerticalReference('wall_top')?.[0]).toBeCloseTo(3.94)
|
||||
expect(reversedVerticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94)
|
||||
|
||||
const top = wall({
|
||||
id: 'wall_horizontal_top',
|
||||
start: [0, 0],
|
||||
end: [10, 0],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const right = wall({
|
||||
id: 'wall_horizontal_right',
|
||||
start: [10, 0],
|
||||
end: [10, -6],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const bottom = wall({
|
||||
id: 'wall_horizontal_bottom',
|
||||
start: [10, -6],
|
||||
end: [0, -6],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const left = wall({
|
||||
id: 'wall_horizontal_left',
|
||||
start: [0, -6],
|
||||
end: [0, 0],
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const partition = wall({
|
||||
id: 'wall_horizontal_partition',
|
||||
start: [0, -3],
|
||||
end: [10, -3],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'horizontal-stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.12,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const horizontalWalls = [top, right, bottom, left, partition]
|
||||
const horizontalNodes = Object.fromEntries(
|
||||
horizontalWalls.map((candidate) => [candidate.id, candidate]),
|
||||
) as Record<string, AnyNode>
|
||||
const horizontalPlan = buildLevelWallConstructionDimensionPlan(
|
||||
horizontalWalls,
|
||||
horizontalNodes,
|
||||
standard,
|
||||
)
|
||||
const horizontalReference = (wallId: string) =>
|
||||
tierEntries(horizontalPlan.get(wallId) ?? [], 'partitions')[0]?.end
|
||||
|
||||
expect(horizontalReference('wall_horizontal_left')?.[1]).toBeCloseTo(-2.94)
|
||||
expect(horizontalReference('wall_horizontal_right')?.[1]).toBeCloseTo(-2.94)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { wallDefinition } from './definition'
|
||||
|
||||
describe('wallDefinition floor-plan extension', () => {
|
||||
test('owns curve eligibility for hosted openings', () => {
|
||||
const wall = wallDefinition.schema.parse({
|
||||
id: 'wall_test',
|
||||
children: ['door_test'],
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
})
|
||||
const canCurve = getFloorplanNodeExtension(wallDefinition)?.actionMenu?.canCurve
|
||||
const nodes = {
|
||||
[wall.id]: wall,
|
||||
door_test: {
|
||||
object: 'node',
|
||||
id: 'door_test',
|
||||
type: 'door',
|
||||
parentId: wall.id,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
} as AnyNode,
|
||||
} as Record<AnyNodeId, AnyNode>
|
||||
|
||||
expect(canCurve?.({ node: wall, nodes })).toBe(false)
|
||||
expect(canCurve?.({ node: { ...wall, children: [] }, nodes })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import type { AnyNodeId, NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -32,10 +33,24 @@ import { wallSlots } from './slots'
|
||||
export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
kind: 'wall',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
schema: WallNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
actionMenu: {
|
||||
canCurve: ({ node, nodes }) =>
|
||||
!node.children.some((childId) => {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) return false
|
||||
if (child.type === 'door' || child.type === 'window') return true
|
||||
if (child.type !== 'item') return false
|
||||
return child.asset?.attachTo === 'wall' || child.asset?.attachTo === 'wall-side'
|
||||
}),
|
||||
},
|
||||
} satisfies FloorplanNodeExtension<WallNode>,
|
||||
},
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
@@ -43,6 +58,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: [0, 0],
|
||||
end: [3, 0],
|
||||
frontSide: 'unknown',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, DoorNode, ItemNode, WallNode } from '@pascal-app/core'
|
||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
|
||||
describe('wallFloorplanSiblingOverrides', () => {
|
||||
test('projects live wall and opening positions without changing unrelated nodes', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_main',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [10, 0],
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_main',
|
||||
parentId: wall.id,
|
||||
position: [2, 1, 0],
|
||||
})
|
||||
const item = ItemNode.parse({
|
||||
id: 'item_main',
|
||||
parentId: 'level_main',
|
||||
position: [0, 0, 0],
|
||||
asset: {
|
||||
id: 'asset_item',
|
||||
category: 'test',
|
||||
name: 'Test item',
|
||||
thumbnail: '/test.png',
|
||||
src: '/test.glb',
|
||||
},
|
||||
})
|
||||
const nodes = { [wall.id]: wall, [door.id]: door, [item.id]: item } as Record<string, AnyNode>
|
||||
|
||||
const result = wallFloorplanSiblingOverrides({
|
||||
nodeId: wall.id,
|
||||
nodes,
|
||||
liveTransforms: new Map([[door.id, { position: [6, 1, 0], rotation: 0 }]]),
|
||||
liveOverrides: new Map([
|
||||
[wall.id, { end: [12, 0] }],
|
||||
[door.id, { position: [5, 1, 0] }],
|
||||
[item.id, { position: [3, 0, 0] }],
|
||||
]),
|
||||
})
|
||||
|
||||
expect(result).not.toBe(nodes)
|
||||
expect(result[wall.id]).toMatchObject({ end: [12, 0] })
|
||||
expect(result[door.id]).toMatchObject({ position: [6, 1, 0] })
|
||||
expect(result[item.id]).toBe(item)
|
||||
})
|
||||
})
|
||||
@@ -1,34 +1,46 @@
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import type { AnyNode, AnyNodeId, LiveTransform } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Project per-frame wall drag overrides (`{ start, end, curveOffset }`)
|
||||
* from `useLiveNodeOverrides` into a fresh `nodes` snapshot. The 2D drag
|
||||
* Project per-frame wall and opening drag overrides into a fresh `nodes`
|
||||
* snapshot. Wall overrides keep shared miters current; door and window
|
||||
* overrides keep associative construction dimensions current while an
|
||||
* opening moves or changes host. The 2D drag
|
||||
* handlers publish overrides for the moved wall plus its linked
|
||||
* neighbours; the floor-plan layer hands the merged snapshot to
|
||||
* `buildContext` so each wall's `ctx.siblings` (which feeds the
|
||||
* miter calculation) reflects the live cursor positions instead of
|
||||
* the last committed scene state.
|
||||
*
|
||||
* Only wall entries are touched; every other node is shared by
|
||||
* reference. The allocation cost is one shallow object per overridden
|
||||
* wall — the override map is small, so this is cheap. When the
|
||||
* Other node types are shared by reference. The allocation cost is one
|
||||
* shallow object per relevant override — the override map is small, so
|
||||
* this is cheap. When the
|
||||
* override map is empty (no live drag) the input is returned
|
||||
* unchanged.
|
||||
*/
|
||||
export function wallFloorplanSiblingOverrides(args: {
|
||||
nodeId: AnyNodeId
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveTransforms?: Map<string, LiveTransform>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}): Record<AnyNodeId, AnyNode> {
|
||||
const { nodes, liveOverrides } = args
|
||||
if (liveOverrides.size === 0) return nodes
|
||||
const { nodes, liveOverrides, liveTransforms } = args
|
||||
if (liveOverrides.size === 0 && !liveTransforms?.size) return nodes
|
||||
let out: Record<AnyNodeId, AnyNode> | null = null
|
||||
for (const [id, override] of liveOverrides) {
|
||||
const ids = new Set([...liveOverrides.keys(), ...(liveTransforms?.keys() ?? [])])
|
||||
for (const id of ids) {
|
||||
const existing = nodes[id as AnyNodeId]
|
||||
if (existing?.type !== 'wall') continue
|
||||
if (Object.keys(override).length === 0) continue
|
||||
if (existing?.type !== 'wall' && existing?.type !== 'door' && existing?.type !== 'window') {
|
||||
continue
|
||||
}
|
||||
const override = liveOverrides.get(id)
|
||||
const liveTransform = liveTransforms?.get(id)
|
||||
const livePosition =
|
||||
liveTransform && (existing.type === 'door' || existing.type === 'window')
|
||||
? { position: liveTransform.position }
|
||||
: undefined
|
||||
if ((!override || Object.keys(override).length === 0) && !livePosition) continue
|
||||
if (!out) out = { ...nodes }
|
||||
out[id as AnyNodeId] = { ...existing, ...override } as AnyNode
|
||||
out[id as AnyNodeId] = { ...existing, ...override, ...livePosition } as AnyNode
|
||||
}
|
||||
return out ?? nodes
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
type GeometryContext,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions, readFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { buildWallFloorplan } from './floorplan'
|
||||
|
||||
const palette: FloorplanPalette = {
|
||||
selectedStroke: '#334155',
|
||||
selectedFill: '#ffffff',
|
||||
selectedHatch: '#334155',
|
||||
wallHoverStroke: '#334155',
|
||||
endpointHandleFill: '#ffffff',
|
||||
endpointHandleStroke: '#334155',
|
||||
endpointHandleHoverStroke: '#334155',
|
||||
endpointHandleActiveFill: '#334155',
|
||||
endpointHandleActiveStroke: '#334155',
|
||||
curveHandleFill: '#ffffff',
|
||||
curveHandleStroke: '#334155',
|
||||
curveHandleHoverStroke: '#334155',
|
||||
measurementStroke: '#334155',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#111827',
|
||||
}
|
||||
|
||||
function context(
|
||||
purpose: 'edit' | 'document',
|
||||
selected = false,
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
wallDimensionReference: 'finished-faces' | 'centerline' | 'stud-faces' = 'finished-faces',
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({
|
||||
metricNotation,
|
||||
purpose,
|
||||
wallDimensionReference,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
|
||||
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
|
||||
}
|
||||
|
||||
describe('buildWallFloorplan render purpose', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_main',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.1,
|
||||
frontSide: 'exterior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
|
||||
test('keeps thin walls legible in edit mode but uses modeled thickness in documents', () => {
|
||||
const edit = buildWallFloorplan(wall, context('edit'))
|
||||
const document = buildWallFloorplan(wall, context('document'))
|
||||
const editPolygon = edit && flatten(edit).find((entry) => entry.kind === 'polygon')
|
||||
const documentPolygon = document && flatten(document).find((entry) => entry.kind === 'polygon')
|
||||
|
||||
expect(editPolygon?.kind).toBe('polygon')
|
||||
expect(documentPolygon?.kind).toBe('polygon')
|
||||
if (editPolygon?.kind !== 'polygon' || documentPolygon?.kind !== 'polygon') return
|
||||
|
||||
const editThickness =
|
||||
Math.max(...editPolygon.points.map((point) => point[1])) -
|
||||
Math.min(...editPolygon.points.map((point) => point[1]))
|
||||
const documentThickness =
|
||||
Math.max(...documentPolygon.points.map((point) => point[1])) -
|
||||
Math.min(...documentPolygon.points.map((point) => point[1]))
|
||||
expect(editThickness).toBeCloseTo(0.13)
|
||||
expect(documentThickness).toBeCloseTo(0.1)
|
||||
expect(readFloorplanGeometryMetadata(editPolygon).annotationObstacle).toBe('outline')
|
||||
expect(readFloorplanGeometryMetadata(documentPolygon).annotationObstacle).toBe('outline')
|
||||
})
|
||||
|
||||
test('uses document metric notation only for document output', () => {
|
||||
const edit = buildWallFloorplan(wall, context('edit'))
|
||||
const document = buildWallFloorplan(wall, context('document'))
|
||||
const texts = (geometry: FloorplanGeometry | null) =>
|
||||
geometry
|
||||
? flatten(geometry).flatMap((entry) =>
|
||||
entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [],
|
||||
)
|
||||
: []
|
||||
|
||||
expect(texts(edit)).toContain('4m')
|
||||
expect(texts(document)).toContain('4000')
|
||||
})
|
||||
|
||||
test('uses the live millimeter notation in edit mode', () => {
|
||||
const edit = buildWallFloorplan(wall, context('edit', false, 'millimeters'))
|
||||
const texts = edit
|
||||
? flatten(edit).flatMap((entry) =>
|
||||
entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [],
|
||||
)
|
||||
: []
|
||||
|
||||
expect(texts).toContain('4000')
|
||||
})
|
||||
|
||||
test('keeps standalone wall witnesses on the stud face in every intersection mode', () => {
|
||||
const assemblyWall = WallNode.parse({
|
||||
...wall,
|
||||
thickness: undefined,
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
materialRef: 'library:cladding',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const witnessY = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
const geometry = buildWallFloorplan(assemblyWall, context('edit', false, 'meters', reference))
|
||||
const dimension = geometry
|
||||
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
|
||||
: undefined
|
||||
return dimension?.kind === 'dimension-string' ? dimension.segments[0]?.start[1] : undefined
|
||||
}
|
||||
|
||||
expect(witnessY('finished-faces')).toBeCloseTo(0.05)
|
||||
expect(witnessY('centerline')).toBeCloseTo(0.05)
|
||||
expect(witnessY('stud-faces')).toBeCloseTo(0.05)
|
||||
})
|
||||
|
||||
test('uses total assembly thickness and emits construction graphics for modeled layers', () => {
|
||||
const assemblyWall = WallNode.parse({
|
||||
...wall,
|
||||
thickness: undefined,
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'block-core',
|
||||
role: 'concrete-block',
|
||||
side: 'core',
|
||||
thickness: 0.19,
|
||||
materialRef: 'library:cmu',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-furring',
|
||||
role: 'furring',
|
||||
side: 'interior',
|
||||
thickness: 0.025,
|
||||
materialRef: 'library:furring',
|
||||
datumEligible: [],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-air-space',
|
||||
role: 'air-space',
|
||||
side: 'exterior',
|
||||
thickness: 0.025,
|
||||
materialRef: '',
|
||||
datumEligible: [],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const document = buildWallFloorplan(assemblyWall, context('document'))
|
||||
const entries = document ? flatten(document) : []
|
||||
const polygons = entries.filter((entry) => entry.kind === 'polygon')
|
||||
const mainPolygon = polygons[0]
|
||||
|
||||
expect(mainPolygon?.kind).toBe('polygon')
|
||||
if (mainPolygon?.kind !== 'polygon') return
|
||||
|
||||
const documentThickness =
|
||||
Math.max(...mainPolygon.points.map((point) => point[1])) -
|
||||
Math.min(...mainPolygon.points.map((point) => point[1]))
|
||||
expect(documentThickness).toBeCloseTo(0.346)
|
||||
|
||||
const layerPolygons = polygons.slice(1)
|
||||
expect(layerPolygons).toHaveLength(5)
|
||||
expect(
|
||||
layerPolygons.every((entry) => entry.kind === 'polygon' && entry.pointerEvents === 'none'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
layerPolygons.map((entry) => (entry.kind === 'polygon' ? entry.fill : undefined)),
|
||||
).toEqual(['#cbd5e1', '#fde68a', '#f8fafc', '#ffffff', '#fca5a5'])
|
||||
|
||||
const lines = entries.filter((entry) => entry.kind === 'line')
|
||||
expect(lines.some((entry) => entry.kind === 'line' && entry.stroke === '#991b1b')).toBe(true)
|
||||
expect(
|
||||
lines.some(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.stroke === '#64748b' &&
|
||||
entry.strokeDasharray === '0.035 0.025',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
lines.some(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.stroke === '#92400e' &&
|
||||
entry.strokeDasharray === '0.04 0.02',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
lines.filter(
|
||||
(entry) =>
|
||||
entry.kind === 'line' && entry.stroke === '#111827' && entry.strokeWidth === 0.85,
|
||||
),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('shows an orthogonal depth dimension for a curved wall without a radius leader', () => {
|
||||
const curved = WallNode.parse({ ...wall, curveOffset: 1 })
|
||||
const geometry = buildWallFloorplan(curved, context('edit'))
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toBeUndefined()
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-string')).toMatchObject({
|
||||
kind: 'dimension-string',
|
||||
segments: [{ text: '1m' }],
|
||||
})
|
||||
})
|
||||
|
||||
test('places selected move arrows on the curved wall midpoint', () => {
|
||||
const curved = WallNode.parse({ ...wall, curveOffset: 1 })
|
||||
const geometry = buildWallFloorplan(curved, context('edit', true))
|
||||
const arrows = geometry ? flatten(geometry).filter((entry) => entry.kind === 'move-arrow') : []
|
||||
|
||||
expect(arrows).toHaveLength(2)
|
||||
expect(arrows[0]).toMatchObject({ kind: 'move-arrow', angle: Math.PI / 2 })
|
||||
expect(arrows[1]).toMatchObject({ kind: 'move-arrow', angle: -Math.PI / 2 })
|
||||
if (arrows[0]?.kind !== 'move-arrow' || arrows[1]?.kind !== 'move-arrow') return
|
||||
expect(arrows[0].point[0]).toBeCloseTo(2)
|
||||
expect(arrows[0].point[1]).toBeCloseTo(-0.885)
|
||||
expect(arrows[1].point[0]).toBeCloseTo(2)
|
||||
expect(arrows[1].point[1]).toBeCloseTo(-1.115)
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,23 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallCurveLength,
|
||||
getWallAssemblyThickness,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallPlanFootprint,
|
||||
isCurvedWall,
|
||||
type WallAssemblyLayer,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
|
||||
import { constructionDimensionStandard } from '../shared/construction-dimension-standards'
|
||||
import {
|
||||
buildCurvedWallConstructionDimensions,
|
||||
buildLevelWallConstructionDimensionPlan,
|
||||
buildWallConstructionDimensions,
|
||||
renderPlannedConstructionDimensions,
|
||||
type WallConstructionDimensionPlan,
|
||||
} from './construction-dimensions'
|
||||
|
||||
// Same constants the legacy `getFloorplanWall` uses (editor/lib/floorplan/walls.ts).
|
||||
// Slightly exaggerates thin walls so the 2D plan stays legible without
|
||||
@@ -18,9 +28,13 @@ import {
|
||||
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
|
||||
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
||||
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
||||
const FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING = 0.06
|
||||
const WALL_DIMENSION_REFERENCES = ['finished-faces', 'centerline', 'stud-faces'] as const
|
||||
|
||||
type WallDimensionReference = (typeof WALL_DIMENSION_REFERENCES)[number]
|
||||
|
||||
function floorplanWallThickness(wall: WallNode): number {
|
||||
const baseThickness = wall.thickness ?? 0.1
|
||||
const baseThickness = getWallAssemblyThickness(wall)
|
||||
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
|
||||
return Math.min(
|
||||
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
|
||||
@@ -32,17 +46,48 @@ function exaggerateWallThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: floorplanWallThickness(wall) }
|
||||
}
|
||||
|
||||
function formatLengthMetric(meters: number): string {
|
||||
return `${Number.parseFloat(meters.toFixed(2))}m`
|
||||
function wallWithModeledAssemblyThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: getWallAssemblyThickness(wall) }
|
||||
}
|
||||
|
||||
export type WallFloorplanLevelData = {
|
||||
miters: WallMiterData
|
||||
documentMiters: WallMiterData
|
||||
constructionDimensionsByReference: Record<WallDimensionReference, WallConstructionDimensionPlan>
|
||||
}
|
||||
|
||||
export function computeWallFloorplanLevelData({
|
||||
siblings,
|
||||
nodes,
|
||||
}: {
|
||||
siblings: ReadonlyArray<WallNode>
|
||||
nodes: Record<string, AnyNode>
|
||||
}): WallMiterData {
|
||||
return calculateLevelMiters(siblings.map(exaggerateWallThickness))
|
||||
}): WallFloorplanLevelData {
|
||||
const walls = siblings.map(exaggerateWallThickness)
|
||||
return {
|
||||
miters: calculateLevelMiters(walls),
|
||||
documentMiters: calculateLevelMiters([...siblings]),
|
||||
constructionDimensionsByReference: {
|
||||
'finished-faces': buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({
|
||||
datumPolicy: 'wall-face',
|
||||
intersectionReferencePolicy: 'both-faces',
|
||||
}),
|
||||
),
|
||||
centerline: buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({ datumPolicy: 'centerline' }),
|
||||
),
|
||||
'stud-faces': buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({ datumPolicy: 'structural-face' }),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,26 +100,29 @@ export function computeWallFloorplanLevelData({
|
||||
* wall body easily.
|
||||
* 4. Two endpoint handles (start + end) when selected — the registry
|
||||
* layer hosts the 5-circle stack + hover transitions + 2D drag.
|
||||
* 5. A small dimension label at the midpoint when selected.
|
||||
* 5. Exterior facade strings plus interior wall spans and hosted-opening widths.
|
||||
*
|
||||
* `ctx.levelData` provides the shared level miter graph when the floor-plan
|
||||
* dispatcher precomputes it; `ctx.siblings` remains the fallback path for
|
||||
* direct builder callers.
|
||||
*/
|
||||
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const self = exaggerateWallThickness(node)
|
||||
const { metricNotation, purpose, wallDimensionReference } = readFloorplanContext(ctx)
|
||||
const documentMode = purpose === 'document'
|
||||
const wallForPurpose = (wall: WallNode) =>
|
||||
documentMode ? wallWithModeledAssemblyThickness(wall) : exaggerateWallThickness(wall)
|
||||
const self = wallForPurpose(node)
|
||||
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
|
||||
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
|
||||
// a direct builder caller with no shared data — pays the O(N) exaggerate +
|
||||
// level-wide miter calc per wall; the dispatcher path is O(1) here, which is
|
||||
// what keeps a wall drag from being O(N²) across the level.
|
||||
const levelData = ctx.levelData as WallFloorplanLevelData | undefined
|
||||
const miters =
|
||||
(ctx.levelData as WallMiterData | undefined) ??
|
||||
(documentMode ? levelData?.documentMiters : levelData?.miters) ??
|
||||
calculateLevelMiters([
|
||||
self,
|
||||
...ctx.siblings
|
||||
.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
||||
.map(exaggerateWallThickness),
|
||||
...ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall').map(wallForPurpose),
|
||||
])
|
||||
|
||||
const polygon = getWallPlanFootprint(self, miters)
|
||||
@@ -109,6 +157,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
|
||||
// Once the wall is selected, the body keeps catching the pointer
|
||||
// so the cursor stays neutral (no drag/pointer affordance from
|
||||
// the slab below leaking through), but only the side-arrows and
|
||||
@@ -118,6 +167,56 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
},
|
||||
]
|
||||
|
||||
children.push(...buildWallAssemblyFloorplanGraphics(self))
|
||||
|
||||
const dimensionStroke =
|
||||
isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155')
|
||||
const dimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: wallDimensionDatumPolicy(wallDimensionReference),
|
||||
metricNotation,
|
||||
})
|
||||
const exteriorCornerDimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: 'structural-face',
|
||||
metricNotation,
|
||||
})
|
||||
if (isCurvedWall(node)) {
|
||||
children.push(
|
||||
...buildCurvedWallConstructionDimensions(self, {
|
||||
unit: view?.unit ?? 'metric',
|
||||
stroke: dimensionStroke,
|
||||
profile: documentMode ? 'document' : 'editor',
|
||||
standard: exteriorCornerDimensionStandard,
|
||||
siblings: ctx.siblings.filter(
|
||||
(sibling): sibling is AnyNode & WallNode => sibling.type === 'wall',
|
||||
),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
const planned = levelData?.constructionDimensionsByReference[wallDimensionReference].get(
|
||||
node.id,
|
||||
)
|
||||
if (planned) {
|
||||
children.push(
|
||||
...renderPlannedConstructionDimensions(
|
||||
planned,
|
||||
view?.unit ?? 'metric',
|
||||
dimensionStroke,
|
||||
documentMode ? 'document' : 'editor',
|
||||
dimensionStandard,
|
||||
),
|
||||
)
|
||||
} else if (!levelData) {
|
||||
children.push(
|
||||
...buildWallConstructionDimensions(self, ctx, {
|
||||
unit: view?.unit ?? 'metric',
|
||||
stroke: dimensionStroke,
|
||||
profile: documentMode ? 'document' : 'editor',
|
||||
standard: exteriorCornerDimensionStandard,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Selection hatch overlay — only when the wall is *the* selected item
|
||||
// (not when it's just marquee-highlighted), matching the legacy.
|
||||
if (isSelected && palette) {
|
||||
@@ -172,19 +271,18 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const wallLength = Math.hypot(dx, dz)
|
||||
if (wallLength > 1e-6) {
|
||||
const midX = (node.start[0] + node.end[0]) / 2
|
||||
const midZ = (node.start[1] + node.end[1]) / 2
|
||||
const midpoint = getWallMidpointHandlePoint(node)
|
||||
const nx = -dz / wallLength
|
||||
const nz = dx / wallLength
|
||||
const offset = floorplanWallThickness(node) / 2 + 0.05
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [midX + nx * offset, midZ + nz * offset],
|
||||
point: [midpoint.x + nx * offset, midpoint.y + nz * offset],
|
||||
angle: Math.atan2(nz, nx),
|
||||
})
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [midX - nx * offset, midZ - nz * offset],
|
||||
point: [midpoint.x - nx * offset, midpoint.y - nz * offset],
|
||||
angle: Math.atan2(-nz, -nx),
|
||||
})
|
||||
}
|
||||
@@ -206,67 +304,438 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
payload: { wallId: node.id },
|
||||
})
|
||||
}
|
||||
|
||||
// Length measurement. Curved walls use the simple rounded label
|
||||
// (the chord-vs-arc thing is hard to express with a dimension line);
|
||||
// straight walls get the full architect's overlay with extension
|
||||
// marks + ticks, offset to the side facing away from the level
|
||||
// centroid (matches the legacy `getWallMeasurementOverlay`).
|
||||
const length = getWallCurveLength(node)
|
||||
if (length >= 0.1) {
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const midX = (node.start[0] + node.end[0]) / 2
|
||||
const midZ = (node.start[1] + node.end[1]) / 2
|
||||
|
||||
if (isCurvedWall(node)) {
|
||||
children.push({
|
||||
kind: 'dimension-label',
|
||||
cx: midX,
|
||||
cy: midZ,
|
||||
text: formatLengthMetric(length),
|
||||
angle: Math.atan2(dz, dx),
|
||||
})
|
||||
} else {
|
||||
// Outward unit normal = perpendicular to (dx, dz), choose the
|
||||
// side facing away from other walls' centroid so the dimension
|
||||
// line sits outside the building.
|
||||
const nx = -dz / length
|
||||
const nz = dx / length
|
||||
const wallSiblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall')
|
||||
const centroid = wallCentroid([node, ...wallSiblings])
|
||||
const cx = midX - centroid[0]
|
||||
const cz = midZ - centroid[1]
|
||||
const facingAway = cx * nx + cz * nz >= 0 ? 1 : -1
|
||||
children.push({
|
||||
kind: 'dimension',
|
||||
start: [node.start[0], node.start[1]],
|
||||
end: [node.end[0], node.end[1]],
|
||||
offsetNormal: [nx * facingAway, nz * facingAway],
|
||||
offsetDistance: 0.75,
|
||||
extensionOvershoot: 0.12,
|
||||
text: formatLengthMetric(length),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
function wallCentroid(walls: WallNode[]): [number, number] {
|
||||
// Mean of every wall endpoint — cheap approximation of "where the
|
||||
// building lives" so we can offset the dimension line away from it.
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
let count = 0
|
||||
for (const wall of walls) {
|
||||
sumX += wall.start[0] + wall.end[0]
|
||||
sumZ += wall.start[1] + wall.end[1]
|
||||
count += 2
|
||||
function wallDimensionDatumPolicy(reference: WallDimensionReference) {
|
||||
switch (reference) {
|
||||
case 'centerline':
|
||||
return 'centerline' as const
|
||||
case 'stud-faces':
|
||||
return 'structural-face' as const
|
||||
case 'finished-faces':
|
||||
return 'wall-face' as const
|
||||
}
|
||||
if (count === 0) return [0, 0]
|
||||
return [sumX / count, sumZ / count]
|
||||
}
|
||||
|
||||
type WallAssemblyLayerSpan = {
|
||||
layer: WallAssemblyLayer
|
||||
interiorOffset: number
|
||||
exteriorOffset: number
|
||||
}
|
||||
|
||||
function buildWallAssemblyFloorplanGraphics(wall: WallNode): FloorplanGeometry[] {
|
||||
if (isCurvedWall(wall)) return []
|
||||
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const spans = getWallAssemblyLayerSpans(wall)
|
||||
if (spans.length === 0) return []
|
||||
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length <= 1e-6) return []
|
||||
|
||||
const tx = dx / length
|
||||
const ty = dy / length
|
||||
const nx = -ty
|
||||
const ny = tx
|
||||
const startX = wall.start[0]
|
||||
const startY = wall.start[1]
|
||||
const endX = wall.end[0]
|
||||
const endY = wall.end[1]
|
||||
|
||||
const graphics: FloorplanGeometry[] = []
|
||||
for (const span of spans) {
|
||||
const style = wallAssemblyLayerGraphicStyle(span.layer)
|
||||
const points = wallLayerPolygon(startX, startY, endX, endY, nx, ny, span)
|
||||
graphics.push({
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: style.fill,
|
||||
stroke: style.stroke,
|
||||
strokeWidth: style.strokeWidth,
|
||||
fillOpacity: style.fillOpacity,
|
||||
opacity: style.opacity,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
graphics.push(
|
||||
...buildWallAssemblyLayerHatchLines({
|
||||
span,
|
||||
style,
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
graphics.push(...buildWallAssemblyFaceLines(startX, startY, endX, endY, nx, ny, spans))
|
||||
return graphics
|
||||
}
|
||||
|
||||
function getWallAssemblyLayerSpans(wall: WallNode): WallAssemblyLayerSpan[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const coreInteriorFace = -coreThickness / 2
|
||||
const coreExteriorFace = coreThickness / 2
|
||||
const spans: WallAssemblyLayerSpan[] = []
|
||||
|
||||
let coreOffset = coreInteriorFace
|
||||
for (const layer of coreLayers) {
|
||||
const interiorOffset = coreOffset
|
||||
const exteriorOffset = coreOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
||||
coreOffset = exteriorOffset
|
||||
}
|
||||
|
||||
let interiorOffset = coreInteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
||||
const exteriorOffset = interiorOffset
|
||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
||||
interiorOffset = nextInteriorOffset
|
||||
}
|
||||
|
||||
let exteriorOffset = coreExteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
||||
const interiorFaceOffset = exteriorOffset
|
||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
||||
exteriorOffset = nextExteriorOffset
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
type WallAssemblyLayerGraphicStyle = {
|
||||
fill: string
|
||||
stroke: string
|
||||
strokeWidth: number
|
||||
fillOpacity: number
|
||||
opacity?: number
|
||||
hatch?: 'diagonal' | 'cross' | 'brick' | 'air' | 'furring'
|
||||
hatchStroke: string
|
||||
hatchDasharray?: string
|
||||
}
|
||||
|
||||
function wallAssemblyLayerGraphicStyle(layer: WallAssemblyLayer): WallAssemblyLayerGraphicStyle {
|
||||
switch (layer.role) {
|
||||
case 'structure':
|
||||
return {
|
||||
fill: '#475569',
|
||||
stroke: '#111827',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.34,
|
||||
hatch: 'diagonal',
|
||||
hatchStroke: '#0f172a',
|
||||
}
|
||||
case 'concrete-block':
|
||||
case 'structural-masonry':
|
||||
return {
|
||||
fill: '#cbd5e1',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.82,
|
||||
hatch: 'cross',
|
||||
hatchStroke: '#475569',
|
||||
}
|
||||
case 'solid-concrete':
|
||||
return {
|
||||
fill: '#94a3b8',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.78,
|
||||
hatch: 'diagonal',
|
||||
hatchStroke: '#64748b',
|
||||
}
|
||||
case 'masonry-veneer':
|
||||
return {
|
||||
fill: '#fca5a5',
|
||||
stroke: '#7f1d1d',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.45,
|
||||
hatch: 'brick',
|
||||
hatchStroke: '#991b1b',
|
||||
}
|
||||
case 'air-space':
|
||||
return {
|
||||
fill: '#ffffff',
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.15,
|
||||
hatch: 'air',
|
||||
hatchStroke: '#64748b',
|
||||
hatchDasharray: '0.035 0.025',
|
||||
}
|
||||
case 'furring':
|
||||
return {
|
||||
fill: '#fde68a',
|
||||
stroke: '#92400e',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.42,
|
||||
hatch: 'furring',
|
||||
hatchStroke: '#92400e',
|
||||
hatchDasharray: '0.04 0.02',
|
||||
}
|
||||
case 'interior-finish':
|
||||
case 'exterior-finish':
|
||||
case 'exterior-sheathing':
|
||||
return {
|
||||
fill: '#f8fafc',
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 0.003,
|
||||
fillOpacity: 0.72,
|
||||
hatch: layer.role === 'exterior-sheathing' ? 'diagonal' : undefined,
|
||||
hatchStroke: '#94a3b8',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wallLayerPolygon(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
span: WallAssemblyLayerSpan,
|
||||
): FloorplanPoint[] {
|
||||
return [
|
||||
[startX + nx * span.interiorOffset, startY + ny * span.interiorOffset],
|
||||
[endX + nx * span.interiorOffset, endY + ny * span.interiorOffset],
|
||||
[endX + nx * span.exteriorOffset, endY + ny * span.exteriorOffset],
|
||||
[startX + nx * span.exteriorOffset, startY + ny * span.exteriorOffset],
|
||||
]
|
||||
}
|
||||
|
||||
function buildWallAssemblyLayerHatchLines({
|
||||
span,
|
||||
style,
|
||||
startX,
|
||||
startY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
length,
|
||||
}: {
|
||||
span: WallAssemblyLayerSpan
|
||||
style: WallAssemblyLayerGraphicStyle
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
tx: number
|
||||
ty: number
|
||||
nx: number
|
||||
ny: number
|
||||
length: number
|
||||
}): FloorplanGeometry[] {
|
||||
if (!style.hatch) return []
|
||||
|
||||
const layerWidth = span.exteriorOffset - span.interiorOffset
|
||||
if (layerWidth <= 1e-6) return []
|
||||
|
||||
const interval = Math.max(FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING, layerWidth * 1.8)
|
||||
const insetAlong = Math.min(0.035, length * 0.08)
|
||||
const lines: FloorplanGeometry[] = []
|
||||
|
||||
if (style.hatch === 'air') {
|
||||
const midOffset = (span.interiorOffset + span.exteriorOffset) / 2
|
||||
lines.push(
|
||||
wallAssemblyLine(
|
||||
startX + tx * insetAlong,
|
||||
startY + ty * insetAlong,
|
||||
startX + tx * (length - insetAlong),
|
||||
startY + ty * (length - insetAlong),
|
||||
nx,
|
||||
ny,
|
||||
midOffset,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
if (style.hatch === 'brick') {
|
||||
for (let along = interval; along < length; along += interval) {
|
||||
lines.push(
|
||||
wallCrossLine(startX, startY, tx, ty, nx, ny, along, span, style.hatchStroke, undefined),
|
||||
)
|
||||
}
|
||||
const thirds = [
|
||||
span.interiorOffset + layerWidth / 3,
|
||||
span.interiorOffset + (layerWidth * 2) / 3,
|
||||
]
|
||||
for (const offset of thirds) {
|
||||
lines.push(
|
||||
wallAssemblyLine(
|
||||
startX + tx * insetAlong,
|
||||
startY + ty * insetAlong,
|
||||
startX + tx * (length - insetAlong),
|
||||
startY + ty * (length - insetAlong),
|
||||
nx,
|
||||
ny,
|
||||
offset,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
if (style.hatch === 'furring') {
|
||||
for (let along = interval; along < length; along += interval) {
|
||||
lines.push(
|
||||
wallCrossLine(
|
||||
startX,
|
||||
startY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
along,
|
||||
span,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const emitDiagonal = (flip: boolean) => {
|
||||
for (let along = interval / 2; along < length; along += interval) {
|
||||
const centerOffset = (span.interiorOffset + span.exteriorOffset) / 2
|
||||
const halfAlong = Math.min(interval * 0.35, length * 0.08)
|
||||
const halfAcross = layerWidth * 0.42
|
||||
const sign = flip ? -1 : 1
|
||||
lines.push({
|
||||
kind: 'line',
|
||||
x1: startX + tx * Math.max(0, along - halfAlong) + nx * (centerOffset - sign * halfAcross),
|
||||
y1: startY + ty * Math.max(0, along - halfAlong) + ny * (centerOffset - sign * halfAcross),
|
||||
x2:
|
||||
startX +
|
||||
tx * Math.min(length, along + halfAlong) +
|
||||
nx * (centerOffset + sign * halfAcross),
|
||||
y2:
|
||||
startY +
|
||||
ty * Math.min(length, along + halfAlong) +
|
||||
ny * (centerOffset + sign * halfAcross),
|
||||
stroke: style.hatchStroke,
|
||||
strokeWidth: 0.55,
|
||||
strokeDasharray: style.hatchDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitDiagonal(false)
|
||||
if (style.hatch === 'cross') emitDiagonal(true)
|
||||
return lines
|
||||
}
|
||||
|
||||
function wallAssemblyLine(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
offset: number,
|
||||
stroke: string,
|
||||
strokeDasharray: string | undefined,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: x1 + nx * offset,
|
||||
y1: y1 + ny * offset,
|
||||
x2: x2 + nx * offset,
|
||||
y2: y2 + ny * offset,
|
||||
stroke,
|
||||
strokeWidth: 0.5,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
function wallCrossLine(
|
||||
startX: number,
|
||||
startY: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
along: number,
|
||||
span: WallAssemblyLayerSpan,
|
||||
stroke: string,
|
||||
strokeDasharray: string | undefined,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: startX + tx * along + nx * span.interiorOffset,
|
||||
y1: startY + ty * along + ny * span.interiorOffset,
|
||||
x2: startX + tx * along + nx * span.exteriorOffset,
|
||||
y2: startY + ty * along + ny * span.exteriorOffset,
|
||||
stroke,
|
||||
strokeWidth: 0.5,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
function buildWallAssemblyFaceLines(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
spans: WallAssemblyLayerSpan[],
|
||||
): FloorplanGeometry[] {
|
||||
const offsets = new Set<number>()
|
||||
for (const span of spans) {
|
||||
offsets.add(span.interiorOffset)
|
||||
offsets.add(span.exteriorOffset)
|
||||
}
|
||||
|
||||
const sortedOffsets = [...offsets].sort((a, b) => a - b)
|
||||
const minOffset = sortedOffsets[0]
|
||||
const maxOffset = sortedOffsets.at(-1)
|
||||
|
||||
return sortedOffsets.map((offset) => ({
|
||||
kind: 'line',
|
||||
x1: startX + nx * offset,
|
||||
y1: startY + ny * offset,
|
||||
x2: endX + nx * offset,
|
||||
y2: endY + ny * offset,
|
||||
stroke: offset === minOffset || offset === maxOffset ? '#111827' : '#64748b',
|
||||
strokeWidth: offset === minOffset || offset === maxOffset ? 0.85 : 0.45,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { WallNode } from '@pascal-app/core'
|
||||
import { matchWallMeasurementFeature } from './measurement'
|
||||
import {
|
||||
matchWallMeasurementFeature,
|
||||
resolveWallMeasurementFeature,
|
||||
wallMeasurementFeatures,
|
||||
} from './measurement'
|
||||
|
||||
describe('matchWallMeasurementFeature', () => {
|
||||
test('keeps an exact plan corner bound to the wall endpoint instead of its face', () => {
|
||||
@@ -17,4 +21,30 @@ describe('matchWallMeasurementFeature', () => {
|
||||
|
||||
expect(matchWallMeasurementFeature(wall, [4, 1, 0.1], 0.2)?.featureId).toBe('wall:face:left')
|
||||
})
|
||||
|
||||
test('publishes a stable center feature only for curved walls', () => {
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
|
||||
|
||||
expect(
|
||||
wallMeasurementFeatures(curved).find((feature) => feature.id === 'wall:curve:center'),
|
||||
).toMatchObject({
|
||||
snapKind: 'center',
|
||||
geometry: { kind: 'point', point: [2, 0, 1.5] },
|
||||
})
|
||||
expect(
|
||||
wallMeasurementFeatures(straight).find((feature) => feature.id === 'wall:curve:center'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves the curved-wall center from the current wall shape', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 0.5 })
|
||||
|
||||
expect(
|
||||
resolveWallMeasurementFeature(wall, {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:curve:center',
|
||||
}),
|
||||
).toMatchObject({ geometry: { kind: 'point', point: [2, 0, 3.75] } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
getWallArcData,
|
||||
getWallCurveFrameAt,
|
||||
getWallThickness,
|
||||
type MeasurementFeature,
|
||||
@@ -14,6 +15,7 @@ const point = (x: number, y: number, z: number) => [x, y, z] as [number, number,
|
||||
|
||||
export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
|
||||
const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes)
|
||||
const arc = getWallArcData(wall)
|
||||
const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y))
|
||||
const midpoint = getWallCurveFrameAt(wall, 0.5).point
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
@@ -63,6 +65,17 @@ export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
|
||||
priority: 90,
|
||||
geometry: { kind: 'point', point: point(midpoint.x, 0, midpoint.y) },
|
||||
},
|
||||
...(arc
|
||||
? [
|
||||
{
|
||||
id: 'wall:curve:center',
|
||||
label: 'Wall arc center',
|
||||
snapKind: 'center' as const,
|
||||
priority: 90,
|
||||
geometry: { kind: 'point' as const, point: point(arc.center.x, 0, arc.center.y) },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'wall:face:left',
|
||||
label: 'Wall face',
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
WALL_CROWN_DEFAULT,
|
||||
WALL_FACE_BAND_DEFAULT,
|
||||
WALL_SKIRTING_DEFAULT,
|
||||
type WallAssemblyLayer,
|
||||
type WallAssemblyLayerRole,
|
||||
type WallDimensionDatum,
|
||||
type WallNode,
|
||||
type WallTrimProfile,
|
||||
} from '@pascal-app/core'
|
||||
@@ -34,7 +37,7 @@ import {
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Spline } from 'lucide-react'
|
||||
import { Plus, Spline, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
@@ -298,6 +301,8 @@ export default function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<WallAssemblySection node={node} onUpdate={handleUpdate} unit={unit} unitLabel={unitLabel} />
|
||||
|
||||
<WallFaceBandSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
@@ -352,6 +357,165 @@ export default function WallPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
const WALL_ASSEMBLY_ROLE_OPTIONS: Array<{ label: string; value: WallAssemblyLayerRole }> = [
|
||||
{ label: 'Structure', value: 'structure' },
|
||||
{ label: 'Interior finish', value: 'interior-finish' },
|
||||
{ label: 'Exterior sheathing', value: 'exterior-sheathing' },
|
||||
{ label: 'Exterior finish', value: 'exterior-finish' },
|
||||
{ label: 'Masonry veneer', value: 'masonry-veneer' },
|
||||
{ label: 'Air space', value: 'air-space' },
|
||||
{ label: 'Concrete block', value: 'concrete-block' },
|
||||
{ label: 'Structural masonry', value: 'structural-masonry' },
|
||||
{ label: 'Solid concrete', value: 'solid-concrete' },
|
||||
{ label: 'Furring', value: 'furring' },
|
||||
]
|
||||
|
||||
const WALL_DATUM_OPTIONS: Array<{ label: string; value: WallDimensionDatum }> = [
|
||||
{ label: 'Structural', value: 'structural-face' },
|
||||
{ label: 'Finish', value: 'finish-face' },
|
||||
{ label: 'Veneer', value: 'veneer-face' },
|
||||
]
|
||||
|
||||
function WallAssemblySection({
|
||||
node,
|
||||
onUpdate,
|
||||
unit,
|
||||
unitLabel,
|
||||
}: {
|
||||
node: WallNode
|
||||
onUpdate: (updates: Partial<WallNode>) => void
|
||||
unit: 'metric' | 'imperial'
|
||||
unitLabel: string
|
||||
}) {
|
||||
const layers = node.assemblyLayers ?? []
|
||||
const updateLayer = (index: number, patch: Partial<WallAssemblyLayer>) =>
|
||||
onUpdate({
|
||||
assemblyLayers: layers.map((layer, layerIndex) =>
|
||||
layerIndex === index ? { ...layer, ...patch } : layer,
|
||||
),
|
||||
})
|
||||
const addLayer = () => {
|
||||
const number = layers.length + 1
|
||||
onUpdate({
|
||||
assemblyLayers: [
|
||||
...layers,
|
||||
{
|
||||
id: `layer-${number}`,
|
||||
role: layers.length === 0 ? 'structure' : 'exterior-finish',
|
||||
side: layers.length === 0 ? 'core' : 'exterior',
|
||||
thickness: 0.1,
|
||||
materialRef: '',
|
||||
datumEligible: layers.length === 0 ? ['structural-face'] : ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection title="Wall assembly">
|
||||
<div className="space-y-2 px-1 pb-1">
|
||||
{layers.map((layer, index) => (
|
||||
<div className="space-y-2 rounded-lg border border-border/50 p-2" key={layer.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-border/50 bg-[#2C2C2E] px-2 text-xs outline-none"
|
||||
maxLength={80}
|
||||
onBlur={(event) => {
|
||||
const id = event.currentTarget.value.trim()
|
||||
if (id && id !== layer.id) updateLayer(index, { id })
|
||||
}}
|
||||
defaultValue={layer.id}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Remove ${layer.id}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
assemblyLayers: layers.filter((_, layerIndex) => layerIndex !== index),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
className="h-7 rounded-md border border-border/50 bg-[#2C2C2E] px-1.5 text-xs outline-none"
|
||||
onChange={(event) =>
|
||||
updateLayer(index, { role: event.currentTarget.value as WallAssemblyLayerRole })
|
||||
}
|
||||
value={layer.role}
|
||||
>
|
||||
{WALL_ASSEMBLY_ROLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-7 rounded-md border border-border/50 bg-[#2C2C2E] px-1.5 text-xs outline-none"
|
||||
onChange={(event) =>
|
||||
updateLayer(index, {
|
||||
side: event.currentTarget.value as WallAssemblyLayer['side'],
|
||||
})
|
||||
}
|
||||
value={layer.side}
|
||||
>
|
||||
<option value="core">Core</option>
|
||||
<option value="interior">Interior</option>
|
||||
<option value="exterior">Exterior</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Thickness</span>
|
||||
<input
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-border/50 bg-[#2C2C2E] px-2 font-mono outline-none"
|
||||
min={0.001}
|
||||
onBlur={(event) => {
|
||||
const parsed = Number.parseFloat(event.currentTarget.value)
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
updateLayer(index, { thickness: linearControlValueToMeters(parsed, unit) })
|
||||
}
|
||||
}}
|
||||
step={unit === 'imperial' ? 0.01 : 0.001}
|
||||
type="number"
|
||||
defaultValue={Math.round(metersToLinearUnit(layer.thickness, unit) * 1000) / 1000}
|
||||
/>
|
||||
<span className="text-muted-foreground">{unitLabel}</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{WALL_DATUM_OPTIONS.map((option) => (
|
||||
<label className="flex items-center gap-1 text-[10px]" key={option.value}>
|
||||
<input
|
||||
checked={layer.datumEligible.includes(option.value)}
|
||||
onChange={(event) =>
|
||||
updateLayer(index, {
|
||||
datumEligible: event.currentTarget.checked
|
||||
? [...layer.datumEligible, option.value]
|
||||
: layer.datumEligible.filter((datum) => datum !== option.value),
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/50 text-xs hover:bg-muted"
|
||||
onClick={addLayer}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3.5" /> Add assembly layer
|
||||
</button>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
function WallFaceBandSection({
|
||||
node,
|
||||
onUpdate,
|
||||
|
||||
@@ -8,5 +8,20 @@
|
||||
* imports a single canonical type.
|
||||
*/
|
||||
|
||||
export type { WallNode as WallNodeType } from '@pascal-app/core'
|
||||
export { WallNode } from '@pascal-app/core'
|
||||
export type {
|
||||
WallAssemblyDatumReference,
|
||||
WallAssemblyDatumSide,
|
||||
WallAssemblyLayer,
|
||||
WallNode as WallNodeType,
|
||||
} from '@pascal-app/core'
|
||||
export {
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyLayers,
|
||||
getWallAssemblyThickness,
|
||||
getWallDatumEligibleLayers,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WallAssemblyLayerRole,
|
||||
WallDimensionDatum,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -234,6 +234,7 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
thickness: DRAFT_WALL_THICKNESS,
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
WallNode,
|
||||
WindowNode as WindowNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import {
|
||||
buildWindowFloorplanSchedule,
|
||||
computeWindowFloorplanLevelData,
|
||||
} from '../shared/opening-documentation'
|
||||
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
@@ -161,9 +166,14 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
kind: 'window',
|
||||
snapProfile: 'item',
|
||||
facingIndicator: true,
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: WindowNode,
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
schedule: buildWindowFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<WindowNodeType>,
|
||||
},
|
||||
|
||||
// Same schema-driven defaults trick as door: parse a stub, strip
|
||||
// id/type. Window also has many fields with zod `.default()` set.
|
||||
@@ -211,6 +221,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
|
||||
// + thickness — same shape as door.
|
||||
floorplan: buildWindowFloorplan,
|
||||
computeFloorplanLevelData: computeWindowFloorplanLevelData,
|
||||
floorplanDependsOnSiblings: true,
|
||||
// Opening symbols position from `ctx.parent` (the host wall); merge the
|
||||
// walls' live drag overrides so the symbol tracks a wall / group drag in
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
buildOpeningMarkAnnotation,
|
||||
type OpeningFloorplanLevelData,
|
||||
} from '../shared/opening-documentation'
|
||||
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
|
||||
|
||||
/**
|
||||
@@ -102,6 +107,7 @@ export function buildWindowFloorplan(
|
||||
strokeWidth: showSelectedChrome ? 1.9 : 1.25,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
|
||||
},
|
||||
// Inset glass-pane outline.
|
||||
{
|
||||
@@ -169,5 +175,13 @@ export function buildWindowFloorplan(
|
||||
}
|
||||
}
|
||||
|
||||
const markAnnotation = buildOpeningMarkAnnotation(
|
||||
node,
|
||||
wall,
|
||||
ctx.levelData as OpeningFloorplanLevelData | undefined,
|
||||
{ stroke: showSelectedChrome ? '#f97316' : '#334155' },
|
||||
)
|
||||
if (markAnnotation) children.push(markAnnotation)
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { OpeningDocumentationFields } from '../shared/opening-documentation-fields'
|
||||
|
||||
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
||||
if (typeof current === 'number' && typeof next === 'number') {
|
||||
@@ -220,6 +221,8 @@ export default function WindowPanel() {
|
||||
parentId: node.parentId,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
roughOpeningWidth: node.roughOpeningWidth,
|
||||
roughOpeningHeight: node.roughOpeningHeight,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
@@ -413,6 +416,21 @@ export default function WindowPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Documentation">
|
||||
<OpeningDocumentationFields
|
||||
constructionType={node.constructionType}
|
||||
dimensionReference={node.dimensionReference}
|
||||
finishOpeningHeight={node.finishOpeningHeight}
|
||||
finishOpeningWidth={node.finishOpeningWidth}
|
||||
mark={node.mark}
|
||||
masonryOpeningHeight={node.masonryOpeningHeight}
|
||||
masonryOpeningWidth={node.masonryOpeningWidth}
|
||||
onChange={handleUpdate}
|
||||
roughOpeningHeight={node.roughOpeningHeight}
|
||||
roughOpeningWidth={node.roughOpeningWidth}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{showWindowTypeSection && (
|
||||
<PanelSection title="Window Type">
|
||||
<div className="grid grid-cols-2 gap-2 px-1 pt-1">
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export { WindowNode } from '@pascal-app/core'
|
||||
export {
|
||||
WindowConstructionType,
|
||||
WindowDimensionReference,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveAutoZonePolygon,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildZoneFloorplan } from './floorplan'
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { zoneFloorplanMoveTarget } from './floorplan-move'
|
||||
import { zoneParametrics } from './parametrics'
|
||||
import { zoneQuickMeasurement } from './quick-measurement'
|
||||
import { buildRoomFloorplanSchedule } from './room-documentation'
|
||||
import { ZoneNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -25,9 +27,14 @@ import { ZoneNode } from './schema'
|
||||
export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
kind: 'zone',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: ZoneNode,
|
||||
category: 'site',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
schedule: buildRoomFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<ZoneNode>,
|
||||
},
|
||||
|
||||
defaults: () => {
|
||||
const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' })
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type FloorplanGeometry, type GeometryContext, ZoneNode } from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { buildZoneFloorplan } from './floorplan'
|
||||
|
||||
const context = {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
} satisfies GeometryContext
|
||||
|
||||
function textChildren(geometry: FloorplanGeometry | null) {
|
||||
if (geometry?.kind !== 'group') return []
|
||||
return geometry.children.filter((child) => child.kind === 'text')
|
||||
}
|
||||
|
||||
describe('buildZoneFloorplan room documentation', () => {
|
||||
test('keeps a generic zone label unchanged', () => {
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_landscape',
|
||||
name: 'Courtyard',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
})
|
||||
|
||||
expect(textChildren(buildZoneFloorplan(zone, context))).toEqual([
|
||||
expect.objectContaining({ kind: 'text', text: 'Courtyard', upright: true }),
|
||||
])
|
||||
})
|
||||
|
||||
test('centers room name, number, finish, and height information as room annotations', () => {
|
||||
const room = ZoneNode.parse({
|
||||
id: 'zone_office',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
roomNumber: '101',
|
||||
floorFinish: 'Timber',
|
||||
wallFinish: 'Paint',
|
||||
ceilingFinish: 'ACT',
|
||||
ceilingHeight: 2.7,
|
||||
occupancy: 'Business',
|
||||
})
|
||||
|
||||
const labels = textChildren(buildZoneFloorplan(room, context))
|
||||
expect(labels.map((label) => ('text' in label ? label.text : ''))).toEqual([
|
||||
'Office',
|
||||
'101',
|
||||
'FL: Timber · WL: Paint · CL: ACT',
|
||||
'CH: 2.7m · Business',
|
||||
])
|
||||
expect(labels.every((label) => label.kind === 'text' && label.upright)).toBe(true)
|
||||
expect(
|
||||
labels.every((label) => readFloorplanGeometryMetadata(label).annotationRole === 'room-label'),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
resolveAutoZonePolygon,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
|
||||
import {
|
||||
type ConstructionLengthProfile,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
import { buildRoomClearDimensions } from './room-clear-dimensions'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for zone. Zones are colored polygons —
|
||||
@@ -21,6 +27,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
if (!ring || ring.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const floorplanContext = readFloorplanContext(ctx)
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
@@ -28,7 +35,8 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
|
||||
const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint)
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color
|
||||
const fillOpacity = isSelected ? 0.28 : 0.16
|
||||
const isRoom = node.spaceRole === 'room'
|
||||
const fillOpacity = isRoom ? (isSelected ? 0.12 : 0.04) : isSelected ? 0.28 : 0.16
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
@@ -91,9 +99,22 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
// it). Mirrors the legacy `FloorplanZoneLabel` so the look is
|
||||
// consistent. Centered on the polygon's area-weighted centroid; the
|
||||
// bbox-center fallback handles degenerate rings without throwing.
|
||||
const [cx, cy] = polygonCentroid(ring)
|
||||
const name = node.name?.trim()
|
||||
if (name) {
|
||||
const [cx, cy] = polygonCentroid(ring)
|
||||
if (isRoom) {
|
||||
children.push(
|
||||
...buildRoomLabels(
|
||||
node,
|
||||
cx,
|
||||
cy,
|
||||
view?.unit ?? 'metric',
|
||||
floorplanContext.purpose === 'document' ? 'document' : 'editor',
|
||||
floorplanContext.metricNotation,
|
||||
stroke,
|
||||
),
|
||||
)
|
||||
children.push(...buildRoomClearDimensions(node, ctx))
|
||||
} else if (name) {
|
||||
children.push({
|
||||
kind: 'text',
|
||||
x: cx,
|
||||
@@ -119,6 +140,61 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
}
|
||||
|
||||
const ZONE_LABEL_FONT_SIZE = 0.2
|
||||
const ROOM_NAME_FONT_SIZE = 0.2
|
||||
const ROOM_NUMBER_FONT_SIZE = 0.16
|
||||
const ROOM_DETAIL_FONT_SIZE = 0.11
|
||||
const ROOM_LABEL_LINE_SPACING = 0.18
|
||||
|
||||
function buildRoomLabels(
|
||||
node: ZoneNode,
|
||||
x: number,
|
||||
y: number,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: 'meters' | 'millimeters',
|
||||
color: string,
|
||||
): FloorplanGeometry[] {
|
||||
const lines: Array<{ text: string; fontSize: number; fontWeight: number }> = []
|
||||
const name = node.name.trim()
|
||||
if (name) lines.push({ text: name, fontSize: ROOM_NAME_FONT_SIZE, fontWeight: 700 })
|
||||
if (node.roomNumber) {
|
||||
lines.push({ text: node.roomNumber, fontSize: ROOM_NUMBER_FONT_SIZE, fontWeight: 600 })
|
||||
}
|
||||
|
||||
const finishes = [
|
||||
node.floorFinish ? `FL: ${node.floorFinish}` : '',
|
||||
node.wallFinish ? `WL: ${node.wallFinish}` : '',
|
||||
node.ceilingFinish ? `CL: ${node.ceilingFinish}` : '',
|
||||
].filter(Boolean)
|
||||
if (finishes.length > 0) {
|
||||
lines.push({ text: finishes.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 })
|
||||
}
|
||||
|
||||
const roomDetails = [
|
||||
`CH: ${formatConstructionLength(node.ceilingHeight, unit, profile, { metricNotation })}`,
|
||||
]
|
||||
if (node.occupancy) roomDetails.push(node.occupancy)
|
||||
lines.push({ text: roomDetails.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 })
|
||||
|
||||
const startY = y - ((lines.length - 1) * ROOM_LABEL_LINE_SPACING) / 2
|
||||
return lines.map((line, index) => ({
|
||||
kind: 'text',
|
||||
x,
|
||||
y: startY + index * ROOM_LABEL_LINE_SPACING,
|
||||
text: line.text,
|
||||
fontSize: line.fontSize,
|
||||
fill: color,
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: line.fontSize * 0.18,
|
||||
paintOrder: 'stroke',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontWeight: line.fontWeight,
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'central',
|
||||
upright: true,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Area-weighted centroid of a simple polygon (Shoelace formula). Falls
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
MetricControl,
|
||||
PanelSection,
|
||||
ToggleControl,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
type Point2D = readonly [number, number]
|
||||
@@ -151,6 +153,167 @@ function QuantityRow({
|
||||
)
|
||||
}
|
||||
|
||||
function RoomTextField({
|
||||
label,
|
||||
onCommit,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
onCommit: (value: string) => void
|
||||
value: string
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
const cancelRef = useRef(false)
|
||||
|
||||
useEffect(() => setDraft(value), [value])
|
||||
|
||||
const commit = () => {
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false
|
||||
setDraft(value)
|
||||
return
|
||||
}
|
||||
const next = draft.trim()
|
||||
if (next !== value) onCommit(next)
|
||||
else setDraft(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex h-10 items-center gap-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-right text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={commit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
if (event.key === 'Escape') {
|
||||
cancelRef.current = true
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
type="text"
|
||||
value={draft}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomSelect({
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
onChange: (value: string) => void
|
||||
options: ReadonlyArray<{ label: string; value: string }>
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex h-10 items-center justify-between gap-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<select
|
||||
className="min-w-0 rounded-md border border-border/50 bg-[#232325] px-2 py-1 text-foreground text-xs outline-none"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) {
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const update = (patch: Partial<ZoneNode>) => updateNode(zone.id, patch)
|
||||
const isRoom = zone.spaceRole === 'room'
|
||||
|
||||
return (
|
||||
<PanelSection title="Room documentation">
|
||||
<ToggleControl
|
||||
checked={isRoom}
|
||||
label="Architectural room"
|
||||
onChange={(checked) => update({ spaceRole: checked ? 'room' : 'generic' })}
|
||||
/>
|
||||
{isRoom ? (
|
||||
<>
|
||||
<RoomTextField
|
||||
label="Room name"
|
||||
onCommit={(name) => update({ name })}
|
||||
value={zone.name}
|
||||
/>
|
||||
<RoomTextField
|
||||
label="Room number"
|
||||
onCommit={(roomNumber) => update({ roomNumber })}
|
||||
value={zone.roomNumber}
|
||||
/>
|
||||
<RoomSelect
|
||||
label="Enclosure"
|
||||
onChange={(enclosureStatus) =>
|
||||
update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] })
|
||||
}
|
||||
options={[
|
||||
{ label: 'Auto-detect', value: 'auto' },
|
||||
{ label: 'Enclosed', value: 'enclosed' },
|
||||
{ label: 'Open', value: 'open' },
|
||||
]}
|
||||
value={zone.enclosureStatus}
|
||||
/>
|
||||
<RoomTextField
|
||||
label="Occupancy / use"
|
||||
onCommit={(occupancy) => update({ occupancy })}
|
||||
value={zone.occupancy}
|
||||
/>
|
||||
<RoomTextField
|
||||
label="Floor finish"
|
||||
onCommit={(floorFinish) => update({ floorFinish })}
|
||||
value={zone.floorFinish}
|
||||
/>
|
||||
<RoomTextField
|
||||
label="Wall finish"
|
||||
onCommit={(wallFinish) => update({ wallFinish })}
|
||||
value={zone.wallFinish}
|
||||
/>
|
||||
<RoomTextField
|
||||
label="Ceiling finish"
|
||||
onCommit={(ceilingFinish) => update({ ceilingFinish })}
|
||||
value={zone.ceilingFinish}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Ceiling height"
|
||||
max={20}
|
||||
min={0.1}
|
||||
onChange={(ceilingHeight) => update({ ceilingHeight })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={zone.ceilingHeight}
|
||||
/>
|
||||
<RoomSelect
|
||||
label="Clear dimensions"
|
||||
onChange={(clearDimensionPolicy) =>
|
||||
update({
|
||||
clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Inside faces', value: 'inside-faces' },
|
||||
{ label: 'Finish faces', value: 'finish-faces' },
|
||||
]}
|
||||
value={zone.clearDimensionPolicy}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ZoneQuantitiesPanel() {
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
@@ -193,48 +356,53 @@ export default function ZoneQuantitiesPanel() {
|
||||
if (!effectiveZone || !report) return null
|
||||
|
||||
return (
|
||||
<PanelSection title="Zone quantities">
|
||||
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
|
||||
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
|
||||
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
|
||||
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
|
||||
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
|
||||
</span>
|
||||
<>
|
||||
<RoomDocumentationPanel zone={effectiveZone} />
|
||||
<PanelSection
|
||||
title={effectiveZone.spaceRole === 'room' ? 'Room quantities' : 'Zone quantities'}
|
||||
>
|
||||
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
|
||||
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
|
||||
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
|
||||
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
|
||||
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
|
||||
<span className="text-cyan-800">A</span>
|
||||
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
|
||||
<span className="ml-auto text-slate-600">P</span>
|
||||
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
|
||||
<span className="text-cyan-800">A</span>
|
||||
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
|
||||
<span className="ml-auto text-slate-600">P</span>
|
||||
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
|
||||
|
||||
<ZonePlanSketch
|
||||
edgeLengths={report.edgeLengths}
|
||||
polygon={effectiveZone.polygon}
|
||||
unit={unit}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<QuantityRow
|
||||
abbreviation="Aw"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Wall surface"
|
||||
quantity={report.wallSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="Af"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Floor surface"
|
||||
quantity={report.floorSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="V"
|
||||
format={(value) => formatVolumeLabel(value, unit, 2)}
|
||||
label="Volume"
|
||||
quantity={report.volume}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZonePlanSketch
|
||||
edgeLengths={report.edgeLengths}
|
||||
polygon={effectiveZone.polygon}
|
||||
unit={unit}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<QuantityRow
|
||||
abbreviation="Aw"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Wall surface"
|
||||
quantity={report.wallSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="Af"
|
||||
format={(value) => formatAreaLabel(value, unit, 2)}
|
||||
label="Floor surface"
|
||||
quantity={report.floorSurface}
|
||||
/>
|
||||
<QuantityRow
|
||||
abbreviation="V"
|
||||
format={(value) => formatVolumeLabel(value, unit, 2)}
|
||||
label="Volume"
|
||||
quantity={report.volume}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
WallNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions } from '@pascal-app/editor'
|
||||
import { buildRoomClearDimensions } from './room-clear-dimensions'
|
||||
|
||||
function enclosure(points: Array<[number, number]>) {
|
||||
const walls = points.map((start, index) =>
|
||||
WallNode.parse({
|
||||
id: `wall_${index}`,
|
||||
parentId: 'level_main',
|
||||
start,
|
||||
end: points[(index + 1) % points.length],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
)
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
parentId: 'level_main',
|
||||
name: 'Office',
|
||||
polygon: points,
|
||||
autoFromWalls: true,
|
||||
boundaryWallIds: walls.map((wall) => wall.id),
|
||||
spaceRole: 'room',
|
||||
clearDimensionPolicy: 'inside-faces',
|
||||
})
|
||||
const nodes = Object.fromEntries([...walls, zone].map((node) => [node.id, node])) as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
const context = {
|
||||
resolve: (id) => nodes[id],
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected: false,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
unit: 'metric',
|
||||
palette: {
|
||||
measurementStroke: '#123456',
|
||||
} as NonNullable<GeometryContext['viewState']>['palette'],
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
|
||||
} satisfies GeometryContext
|
||||
return { context, nodes, walls, zone }
|
||||
}
|
||||
|
||||
function withFinishAssembly(wall: WallNode): WallNode {
|
||||
return WallNode.parse({
|
||||
...wall,
|
||||
thickness: undefined,
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: `${wall.id}_core`,
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.2,
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: `${wall.id}_interior-finish`,
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: `${wall.id}_exterior-finish`,
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.02,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function dimensions(geometry: FloorplanGeometry[]) {
|
||||
return geometry.filter(
|
||||
(entry): entry is Extract<FloorplanGeometry, { kind: 'dimension' }> =>
|
||||
entry.kind === 'dimension',
|
||||
)
|
||||
}
|
||||
|
||||
describe('buildRoomClearDimensions', () => {
|
||||
test('dimensions the proven inside faces of a rectangular room', () => {
|
||||
const { context, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
])
|
||||
const result = dimensions(buildRoomClearDimensions(zone, context))
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((entry) => entry.text).sort()).toEqual(['2.8m', '3.8m'])
|
||||
expect(result.every((entry) => entry.stroke === '#123456')).toBe(true)
|
||||
expect(result[0]?.start[0]).toBeCloseTo(1.316)
|
||||
expect(result[0]?.start[1]).toBeCloseTo(0.1)
|
||||
expect(result[0]?.end[0]).toBeCloseTo(1.316)
|
||||
expect(result[0]?.end[1]).toBeCloseTo(2.9)
|
||||
})
|
||||
|
||||
test('preserves clear spans when the room is rotated', () => {
|
||||
const angle = Math.PI / 6
|
||||
const rotate = ([x, y]: [number, number]): [number, number] => [
|
||||
x * Math.cos(angle) - y * Math.sin(angle),
|
||||
x * Math.sin(angle) + y * Math.cos(angle),
|
||||
]
|
||||
const { context, zone } = enclosure(
|
||||
[
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
].map(rotate),
|
||||
)
|
||||
|
||||
expect(
|
||||
dimensions(buildRoomClearDimensions(zone, context))
|
||||
.map((entry) => entry.text)
|
||||
.sort(),
|
||||
).toEqual(['2.8m', '3.8m'])
|
||||
})
|
||||
|
||||
test('consolidates collinear wall segments before proving the clear rectangle', () => {
|
||||
const { context, zone } = enclosure([
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
])
|
||||
|
||||
expect(
|
||||
dimensions(buildRoomClearDimensions(zone, context))
|
||||
.map((entry) => entry.text)
|
||||
.sort(),
|
||||
).toEqual(['2.8m', '3.8m'])
|
||||
})
|
||||
|
||||
test('dimensions finish faces when every boundary wall has assembly finish datums', () => {
|
||||
const { context, nodes, walls, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
])
|
||||
const assembledWalls = walls.map(withFinishAssembly)
|
||||
const assembledNodes = { ...nodes }
|
||||
for (const wall of assembledWalls) assembledNodes[wall.id] = wall
|
||||
|
||||
const result = dimensions(
|
||||
buildRoomClearDimensions(
|
||||
{ ...zone, clearDimensionPolicy: 'finish-faces' },
|
||||
{
|
||||
...context,
|
||||
resolve: (id) => assembledNodes[id],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m'])
|
||||
})
|
||||
|
||||
test('adds a room-to-room finish-face dimension for adjacent rectangular rooms', () => {
|
||||
const walls = [
|
||||
WallNode.parse({ id: 'wall_a_bottom', parentId: 'level_main', start: [0, 0], end: [4, 0] }),
|
||||
WallNode.parse({ id: 'wall_shared', parentId: 'level_main', start: [4, 0], end: [4, 3] }),
|
||||
WallNode.parse({ id: 'wall_a_top', parentId: 'level_main', start: [4, 3], end: [0, 3] }),
|
||||
WallNode.parse({ id: 'wall_a_left', parentId: 'level_main', start: [0, 3], end: [0, 0] }),
|
||||
WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }),
|
||||
WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }),
|
||||
WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }),
|
||||
].map(withFinishAssembly)
|
||||
const zoneA = ZoneNode.parse({
|
||||
id: 'zone_a',
|
||||
parentId: 'level_main',
|
||||
name: 'A',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
autoFromWalls: true,
|
||||
boundaryWallIds: ['wall_a_bottom', 'wall_shared', 'wall_a_top', 'wall_a_left'],
|
||||
spaceRole: 'room',
|
||||
clearDimensionPolicy: 'finish-faces',
|
||||
})
|
||||
const zoneB = ZoneNode.parse({
|
||||
id: 'zone_b',
|
||||
parentId: 'level_main',
|
||||
name: 'B',
|
||||
polygon: [
|
||||
[4, 0],
|
||||
[8, 0],
|
||||
[8, 3],
|
||||
[4, 3],
|
||||
],
|
||||
autoFromWalls: true,
|
||||
boundaryWallIds: ['wall_b_bottom', 'wall_b_right', 'wall_b_top', 'wall_shared'],
|
||||
spaceRole: 'room',
|
||||
clearDimensionPolicy: 'finish-faces',
|
||||
})
|
||||
const nodes = Object.fromEntries(
|
||||
[...walls, zoneA, zoneB].map((node) => [node.id, node]),
|
||||
) as Record<string, AnyNode>
|
||||
const context = {
|
||||
resolve: (id) => nodes[id],
|
||||
children: [],
|
||||
siblings: [zoneB],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected: false,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
unit: 'metric',
|
||||
palette: {
|
||||
measurementStroke: '#123456',
|
||||
} as NonNullable<GeometryContext['viewState']>['palette'],
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ purpose: 'edit' }),
|
||||
} satisfies GeometryContext
|
||||
|
||||
const result = dimensions(buildRoomClearDimensions(zoneA, context))
|
||||
|
||||
expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m', 'R-R 0.24m'])
|
||||
expect(result.find((entry) => entry.text.startsWith('R-R'))?.text).toBe('R-R 0.24m')
|
||||
})
|
||||
|
||||
test('dimensions proven rectilinear room bays beyond simple rectangles', () => {
|
||||
const { context, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 2],
|
||||
[2, 2],
|
||||
[2, 4],
|
||||
[0, 4],
|
||||
])
|
||||
|
||||
const result = dimensions(buildRoomClearDimensions(zone, context))
|
||||
|
||||
expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m'])
|
||||
})
|
||||
|
||||
test('suppresses dimensions when the requested datum cannot be proven', () => {
|
||||
const { context, nodes, walls, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
])
|
||||
|
||||
expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([])
|
||||
expect(
|
||||
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
|
||||
).toEqual([])
|
||||
expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([])
|
||||
expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([])
|
||||
|
||||
const missingWallNodes = { ...nodes }
|
||||
delete missingWallNodes[walls[0]!.id]
|
||||
expect(
|
||||
buildRoomClearDimensions(zone, {
|
||||
...context,
|
||||
resolve: (id) => missingWallNodes[id],
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('suppresses dimensions for a proven enclosure that is not rectangular', () => {
|
||||
const { context, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 2],
|
||||
[2, 3],
|
||||
[0, 2],
|
||||
])
|
||||
|
||||
expect(buildRoomClearDimensions(zone, context)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,609 @@
|
||||
import {
|
||||
detectSpacesForLevel,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallAssemblyFaceOffsets,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
type SpaceBoundaryFace,
|
||||
type WallNode,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { readFloorplanContext } from '@pascal-app/editor'
|
||||
import {
|
||||
type ConstructionLengthProfile,
|
||||
type ConstructionMetricNotation,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
|
||||
const LINE_TOLERANCE = 1e-4
|
||||
const ANGLE_TOLERANCE = 1e-3
|
||||
const MIN_CLEAR_SPAN = 0.3
|
||||
const MIN_ROOM_TO_ROOM_SPAN = 0.03
|
||||
const FIRST_DIMENSION_POSITION = 0.32
|
||||
const SECOND_DIMENSION_POSITION = 0.68
|
||||
const EXTENSION_OVERSHOOT = 0.08
|
||||
|
||||
type FaceLine = {
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint
|
||||
}
|
||||
|
||||
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
|
||||
type ClearDimensionPolicy = Extract<
|
||||
ZoneNode['clearDimensionPolicy'],
|
||||
'inside-faces' | 'finish-faces'
|
||||
>
|
||||
|
||||
export function buildRoomClearDimensions(
|
||||
node: ZoneNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry[] {
|
||||
if (
|
||||
node.spaceRole !== 'room' ||
|
||||
(node.clearDimensionPolicy !== 'inside-faces' &&
|
||||
node.clearDimensionPolicy !== 'finish-faces') ||
|
||||
node.enclosureStatus === 'open' ||
|
||||
!node.autoFromWalls ||
|
||||
!node.parentId ||
|
||||
node.boundaryWallIds.length < 3
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const walls = node.boundaryWallIds.flatMap((id) => {
|
||||
const resolved = ctx.resolve(id)
|
||||
return resolved &&
|
||||
typeof resolved === 'object' &&
|
||||
'type' in resolved &&
|
||||
resolved.type === 'wall'
|
||||
? [resolved as WallNode]
|
||||
: []
|
||||
})
|
||||
if (walls.length !== node.boundaryWallIds.length) return []
|
||||
|
||||
const boundaryIds = new Set(node.boundaryWallIds)
|
||||
const space = detectSpacesForLevel(node.parentId, walls).spaces.find(
|
||||
(candidate) =>
|
||||
candidate.wallIds.length === boundaryIds.size &&
|
||||
candidate.wallIds.every((id) => boundaryIds.has(id)),
|
||||
)
|
||||
if (!space) return []
|
||||
|
||||
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
|
||||
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy)
|
||||
if (!faceLines) return []
|
||||
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const floorplanContext = readFloorplanContext(ctx)
|
||||
const profile: ConstructionLengthProfile =
|
||||
floorplanContext.purpose === 'document' ? 'document' : 'editor'
|
||||
const metricNotation = floorplanContext.metricNotation
|
||||
const stroke = ctx.viewState?.palette.measurementStroke ?? '#475569'
|
||||
const rectangle = resolveClearFaceRectangle(faceLines)
|
||||
const dimensions = rectangle
|
||||
? buildRectangleClearDimensions(rectangle, unit, profile, metricNotation, stroke)
|
||||
: buildRectilinearClearDimensions(faceLines, unit, profile, metricNotation, stroke)
|
||||
if (dimensions.length === 0) return []
|
||||
return [
|
||||
...dimensions,
|
||||
...buildRoomToRoomClearDimensions(
|
||||
node,
|
||||
ctx,
|
||||
space.boundaryFaces,
|
||||
wallsById,
|
||||
unit,
|
||||
profile,
|
||||
metricNotation,
|
||||
stroke,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function resolveClearFaceLines(
|
||||
boundaryFaces: readonly SpaceBoundaryFace[],
|
||||
wallsById: ReadonlyMap<string, WallNode>,
|
||||
policy: ClearDimensionPolicy,
|
||||
): FaceLine[] | null {
|
||||
const faceLines: FaceLine[] = []
|
||||
for (const boundary of boundaryFaces) {
|
||||
const wall = wallsById.get(boundary.wallId)
|
||||
if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null
|
||||
const line = offsetBoundaryFace(boundary, wall, policy)
|
||||
if (!line) return null
|
||||
faceLines.push(line)
|
||||
}
|
||||
|
||||
const merged = mergeCollinearFaces(faceLines)
|
||||
return merged.length >= 4 ? merged : null
|
||||
}
|
||||
|
||||
function resolveClearFaceRectangle(
|
||||
merged: readonly FaceLine[],
|
||||
): [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] | null {
|
||||
if (merged.length !== 4) return null
|
||||
|
||||
const vertices = merged.map((line, index) => {
|
||||
const previous = merged[(index + merged.length - 1) % merged.length]!
|
||||
return intersectLines(previous, line)
|
||||
})
|
||||
if (vertices.some((vertex) => vertex === null)) return null
|
||||
const rectangle = vertices as [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint]
|
||||
const directions = rectangle.map((start, index) =>
|
||||
normalizedDirection(start, rectangle[(index + 1) % rectangle.length]!),
|
||||
)
|
||||
if (directions.some((direction) => direction === null)) return null
|
||||
const [first, second, third, fourth] = directions as [
|
||||
FloorplanPoint,
|
||||
FloorplanPoint,
|
||||
FloorplanPoint,
|
||||
FloorplanPoint,
|
||||
]
|
||||
if (
|
||||
Math.abs(dot(first, second)) > ANGLE_TOLERANCE ||
|
||||
Math.abs(dot(second, third)) > ANGLE_TOLERANCE ||
|
||||
Math.abs(dot(third, fourth)) > ANGLE_TOLERANCE ||
|
||||
Math.abs(dot(fourth, first)) > ANGLE_TOLERANCE ||
|
||||
dot(first, third) > -1 + ANGLE_TOLERANCE ||
|
||||
dot(second, fourth) > -1 + ANGLE_TOLERANCE
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return rectangle
|
||||
}
|
||||
|
||||
function buildRectangleClearDimensions(
|
||||
rectangle: [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint],
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const first = dimensionAcrossOppositeFaces(
|
||||
rectangle[0],
|
||||
rectangle[1],
|
||||
rectangle[3],
|
||||
rectangle[2],
|
||||
FIRST_DIMENSION_POSITION,
|
||||
unit,
|
||||
profile,
|
||||
metricNotation,
|
||||
stroke,
|
||||
)
|
||||
const second = dimensionAcrossOppositeFaces(
|
||||
rectangle[1],
|
||||
rectangle[2],
|
||||
rectangle[0],
|
||||
rectangle[3],
|
||||
SECOND_DIMENSION_POSITION,
|
||||
unit,
|
||||
profile,
|
||||
metricNotation,
|
||||
stroke,
|
||||
)
|
||||
return first && second ? [first, second] : []
|
||||
}
|
||||
|
||||
function buildRectilinearClearDimensions(
|
||||
faceLines: readonly FaceLine[],
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const vertices = clearFacePolygon(faceLines)
|
||||
if (!vertices || !isRectilinearPolygon(vertices)) return []
|
||||
|
||||
const dimensions: FloorplanGeometry[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let firstIndex = 0; firstIndex < faceLines.length; firstIndex++) {
|
||||
const first = faceLines[firstIndex]!
|
||||
const firstDirection = normalizedDirection(first.start, first.end)
|
||||
if (!firstDirection) return []
|
||||
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < faceLines.length; secondIndex++) {
|
||||
const second = faceLines[secondIndex]!
|
||||
const secondDirection = normalizedDirection(second.start, second.end)
|
||||
if (!secondDirection) return []
|
||||
if (Math.abs(dot(firstDirection, secondDirection)) < 1 - ANGLE_TOLERANCE) continue
|
||||
|
||||
const dimension = dimensionBetweenOverlappingParallelFaces(
|
||||
first,
|
||||
second,
|
||||
firstDirection,
|
||||
vertices,
|
||||
unit,
|
||||
profile,
|
||||
metricNotation,
|
||||
stroke,
|
||||
)
|
||||
if (!dimension) continue
|
||||
const key = dimensionKey(dimension)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
dimensions.push(dimension)
|
||||
}
|
||||
}
|
||||
return dimensions
|
||||
}
|
||||
|
||||
function offsetBoundaryFace(
|
||||
boundary: SpaceBoundaryFace,
|
||||
wall: WallNode,
|
||||
policy: ClearDimensionPolicy,
|
||||
): FaceLine | null {
|
||||
const first = boundary.points[0]
|
||||
const last = boundary.points[boundary.points.length - 1]
|
||||
if (!(first && last)) return null
|
||||
|
||||
const wallDirection = normalizedDirection(wall.start, wall.end)
|
||||
if (!wallDirection) return null
|
||||
const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]]
|
||||
const side = boundary.face === 'front' ? 1 : -1
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
const offset =
|
||||
policy === 'finish-faces'
|
||||
? resolveFinishFaceOffset(wall, side)
|
||||
: side > 0
|
||||
? faces.exterior
|
||||
: faces.interior
|
||||
if (offset === null) return null
|
||||
return {
|
||||
start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset],
|
||||
end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null {
|
||||
if ((wall.assemblyLayers ?? []).length === 0) return null
|
||||
const references = resolveWallAssemblyDatumReferences(wall).filter(
|
||||
(reference) => reference.datum === 'finish-face',
|
||||
)
|
||||
const matching = references
|
||||
.filter((reference) => Math.sign(reference.offset) === side)
|
||||
.map((reference) => reference.offset)
|
||||
if (matching.length === 0) return null
|
||||
return side > 0 ? Math.max(...matching) : Math.min(...matching)
|
||||
}
|
||||
|
||||
function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null {
|
||||
const vertices = faceLines.map((line, index) => {
|
||||
const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]!
|
||||
return intersectLines(previous, line)
|
||||
})
|
||||
return vertices.some((vertex) => vertex === null) ? null : (vertices as FloorplanPoint[])
|
||||
}
|
||||
|
||||
function isRectilinearPolygon(vertices: readonly FloorplanPoint[]): boolean {
|
||||
if (vertices.length < 4) return false
|
||||
const directions = vertices.map((start, index) =>
|
||||
normalizedDirection(start, vertices[(index + 1) % vertices.length]!),
|
||||
)
|
||||
if (directions.some((direction) => direction === null)) return false
|
||||
for (let index = 0; index < directions.length; index++) {
|
||||
const current = directions[index]!
|
||||
const next = directions[(index + 1) % directions.length]!
|
||||
if (Math.abs(dot(current, next)) > ANGLE_TOLERANCE) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function dimensionBetweenOverlappingParallelFaces(
|
||||
first: FaceLine,
|
||||
second: FaceLine,
|
||||
direction: FloorplanPoint,
|
||||
polygon: readonly FloorplanPoint[],
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): DimensionGeometry | null {
|
||||
const firstStart = dot(first.start, direction)
|
||||
const firstEnd = dot(first.end, direction)
|
||||
const secondStart = dot(second.start, direction)
|
||||
const secondEnd = dot(second.end, direction)
|
||||
const overlapStart = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd))
|
||||
const overlapEnd = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd))
|
||||
if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null
|
||||
|
||||
const projection = (overlapStart + overlapEnd) / 2
|
||||
const start = projectPointToLineProjection(first, direction, projection)
|
||||
const end = projectPointToLineProjection(second, direction, projection)
|
||||
const midpoint: FloorplanPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
|
||||
if (!pointInPolygon(midpoint, polygon)) return null
|
||||
|
||||
const axis = normalizedDirection(start, end)
|
||||
if (!axis) return null
|
||||
const length = distance(start, end)
|
||||
if (length < MIN_CLEAR_SPAN) return null
|
||||
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start,
|
||||
end,
|
||||
offsetNormal: [-axis[1], axis[0]],
|
||||
offsetDistance: 0,
|
||||
extensionOvershoot: EXTENSION_OVERSHOOT,
|
||||
text: formatConstructionLength(length, unit, profile, { metricNotation }),
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(point: FloorplanPoint, polygon: readonly FloorplanPoint[]): boolean {
|
||||
let inside = false
|
||||
for (
|
||||
let index = 0, previousIndex = polygon.length - 1;
|
||||
index < polygon.length;
|
||||
previousIndex = index++
|
||||
) {
|
||||
const current = polygon[index]!
|
||||
const previous = polygon[previousIndex]!
|
||||
const intersects =
|
||||
current[1] > point[1] !== previous[1] > point[1] &&
|
||||
point[0] <
|
||||
((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) +
|
||||
current[0]
|
||||
if (intersects) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
function dimensionKey(dimension: DimensionGeometry): string {
|
||||
const first = `${roundKey(dimension.start[0])},${roundKey(dimension.start[1])}`
|
||||
const second = `${roundKey(dimension.end[0])},${roundKey(dimension.end[1])}`
|
||||
return first < second ? `${first}|${second}` : `${second}|${first}`
|
||||
}
|
||||
|
||||
function roundKey(value: number): number {
|
||||
return Math.round(value / LINE_TOLERANCE)
|
||||
}
|
||||
|
||||
function buildRoomToRoomClearDimensions(
|
||||
node: ZoneNode,
|
||||
ctx: GeometryContext,
|
||||
boundaryFaces: readonly SpaceBoundaryFace[],
|
||||
wallsById: ReadonlyMap<string, WallNode>,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
if (node.clearDimensionPolicy !== 'finish-faces') return []
|
||||
|
||||
const neighboringRooms = ctx.siblings.filter(
|
||||
(sibling): sibling is ZoneNode =>
|
||||
sibling.type === 'zone' &&
|
||||
sibling.id !== node.id &&
|
||||
String(node.id) < String(sibling.id) &&
|
||||
sibling.spaceRole === 'room' &&
|
||||
sibling.clearDimensionPolicy === 'finish-faces' &&
|
||||
sibling.enclosureStatus !== 'open' &&
|
||||
sibling.autoFromWalls &&
|
||||
sibling.parentId === node.parentId,
|
||||
)
|
||||
if (neighboringRooms.length === 0) return []
|
||||
|
||||
const dimensions: FloorplanGeometry[] = []
|
||||
const currentBoundaryByWallId = new Map(
|
||||
boundaryFaces.map((boundary) => [boundary.wallId, boundary]),
|
||||
)
|
||||
|
||||
for (const neighbor of neighboringRooms) {
|
||||
const sharedWallIds = neighbor.boundaryWallIds.filter((wallId) =>
|
||||
currentBoundaryByWallId.has(wallId),
|
||||
)
|
||||
if (sharedWallIds.length === 0) continue
|
||||
|
||||
const neighborWalls = neighbor.boundaryWallIds.flatMap((id) => {
|
||||
const resolved = ctx.resolve(id)
|
||||
return resolved &&
|
||||
typeof resolved === 'object' &&
|
||||
'type' in resolved &&
|
||||
resolved.type === 'wall'
|
||||
? [resolved as WallNode]
|
||||
: []
|
||||
})
|
||||
if (neighborWalls.length !== neighbor.boundaryWallIds.length) continue
|
||||
const neighborWallIds = new Set(neighbor.boundaryWallIds)
|
||||
const neighborSpace = detectSpacesForLevel(neighbor.parentId ?? '', neighborWalls).spaces.find(
|
||||
(candidate) =>
|
||||
candidate.wallIds.length === neighborWallIds.size &&
|
||||
candidate.wallIds.every((id) => neighborWallIds.has(id)),
|
||||
)
|
||||
if (!neighborSpace) continue
|
||||
const neighborBoundaryByWallId = new Map(
|
||||
neighborSpace.boundaryFaces.map((boundary) => [boundary.wallId, boundary]),
|
||||
)
|
||||
|
||||
for (const wallId of sharedWallIds) {
|
||||
const wall = wallsById.get(wallId)
|
||||
const currentBoundary = currentBoundaryByWallId.get(wallId)
|
||||
const neighborBoundary = neighborBoundaryByWallId.get(wallId)
|
||||
if (!(wall && currentBoundary && neighborBoundary)) continue
|
||||
const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces')
|
||||
const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces')
|
||||
if (!(currentLine && neighborLine)) continue
|
||||
const dimension = dimensionAcrossSharedRoomWall(
|
||||
currentLine,
|
||||
neighborLine,
|
||||
unit,
|
||||
profile,
|
||||
metricNotation,
|
||||
stroke,
|
||||
)
|
||||
if (dimension) dimensions.push(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
return dimensions
|
||||
}
|
||||
|
||||
function dimensionAcrossSharedRoomWall(
|
||||
currentLine: FaceLine,
|
||||
neighborLine: FaceLine,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): DimensionGeometry | null {
|
||||
const direction = normalizedDirection(currentLine.start, currentLine.end)
|
||||
if (!direction) return null
|
||||
const neighborDirection = normalizedDirection(neighborLine.start, neighborLine.end)
|
||||
if (!neighborDirection || Math.abs(dot(direction, neighborDirection)) < 1 - ANGLE_TOLERANCE) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentStart = dot(currentLine.start, direction)
|
||||
const currentEnd = dot(currentLine.end, direction)
|
||||
const neighborStart = dot(neighborLine.start, direction)
|
||||
const neighborEnd = dot(neighborLine.end, direction)
|
||||
const overlapStart = Math.max(
|
||||
Math.min(currentStart, currentEnd),
|
||||
Math.min(neighborStart, neighborEnd),
|
||||
)
|
||||
const overlapEnd = Math.min(
|
||||
Math.max(currentStart, currentEnd),
|
||||
Math.max(neighborStart, neighborEnd),
|
||||
)
|
||||
if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null
|
||||
|
||||
const projection = (overlapStart + overlapEnd) / 2
|
||||
const start = projectPointToLineProjection(currentLine, direction, projection)
|
||||
const end = projectPointToLineProjection(neighborLine, direction, projection)
|
||||
const clear = distance(start, end)
|
||||
if (clear < MIN_ROOM_TO_ROOM_SPAN) return null
|
||||
const axis = normalizedDirection(start, end)
|
||||
if (!axis) return null
|
||||
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start,
|
||||
end,
|
||||
offsetNormal: [-axis[1], axis[0]],
|
||||
offsetDistance: 0,
|
||||
extensionOvershoot: EXTENSION_OVERSHOOT,
|
||||
text: `R-R ${formatConstructionLength(clear, unit, profile, { metricNotation })}`,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
function projectPointToLineProjection(
|
||||
line: FaceLine,
|
||||
direction: FloorplanPoint,
|
||||
projection: number,
|
||||
): FloorplanPoint {
|
||||
const originProjection = dot(line.start, direction)
|
||||
return [
|
||||
line.start[0] + direction[0] * (projection - originProjection),
|
||||
line.start[1] + direction[1] * (projection - originProjection),
|
||||
]
|
||||
}
|
||||
|
||||
function mergeCollinearFaces(lines: readonly FaceLine[]): FaceLine[] {
|
||||
const merged: FaceLine[] = []
|
||||
for (const line of lines) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous && canMerge(previous, line)) previous.end = line.end
|
||||
else merged.push({ ...line })
|
||||
}
|
||||
|
||||
while (merged.length > 1) {
|
||||
const first = merged[0]!
|
||||
const last = merged[merged.length - 1]!
|
||||
if (!canMerge(last, first)) break
|
||||
first.start = last.start
|
||||
merged.pop()
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function canMerge(first: FaceLine, second: FaceLine): boolean {
|
||||
const firstDirection = normalizedDirection(first.start, first.end)
|
||||
const secondDirection = normalizedDirection(second.start, second.end)
|
||||
if (!(firstDirection && secondDirection)) return false
|
||||
return (
|
||||
dot(firstDirection, secondDirection) > 1 - ANGLE_TOLERANCE &&
|
||||
pointLineDistance(second.start, first) <= LINE_TOLERANCE
|
||||
)
|
||||
}
|
||||
|
||||
function intersectLines(first: FaceLine, second: FaceLine): FloorplanPoint | null {
|
||||
const firstDirection: FloorplanPoint = [
|
||||
first.end[0] - first.start[0],
|
||||
first.end[1] - first.start[1],
|
||||
]
|
||||
const secondDirection: FloorplanPoint = [
|
||||
second.end[0] - second.start[0],
|
||||
second.end[1] - second.start[1],
|
||||
]
|
||||
const denominator = cross(firstDirection, secondDirection)
|
||||
if (Math.abs(denominator) <= LINE_TOLERANCE) return null
|
||||
const delta: FloorplanPoint = [second.start[0] - first.start[0], second.start[1] - first.start[1]]
|
||||
const parameter = cross(delta, secondDirection) / denominator
|
||||
return [
|
||||
first.start[0] + firstDirection[0] * parameter,
|
||||
first.start[1] + firstDirection[1] * parameter,
|
||||
]
|
||||
}
|
||||
|
||||
function dimensionAcrossOppositeFaces(
|
||||
firstStart: FloorplanPoint,
|
||||
firstEnd: FloorplanPoint,
|
||||
oppositeStart: FloorplanPoint,
|
||||
oppositeEnd: FloorplanPoint,
|
||||
position: number,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
metricNotation: ConstructionMetricNotation,
|
||||
stroke: string,
|
||||
): FloorplanGeometry | null {
|
||||
const start = interpolate(firstStart, firstEnd, position)
|
||||
const end = interpolate(oppositeStart, oppositeEnd, position)
|
||||
const direction = normalizedDirection(start, end)
|
||||
if (!direction) return null
|
||||
const length = distance(start, end)
|
||||
if (length < MIN_CLEAR_SPAN) return null
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start,
|
||||
end,
|
||||
offsetNormal: [-direction[1], direction[0]],
|
||||
offsetDistance: 0,
|
||||
extensionOvershoot: EXTENSION_OVERSHOOT,
|
||||
text: formatConstructionLength(length, unit, profile, { metricNotation }),
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedDirection(
|
||||
start: readonly [number, number],
|
||||
end: readonly [number, number],
|
||||
): FloorplanPoint | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
return length <= LINE_TOLERANCE ? null : [dx / length, dy / length]
|
||||
}
|
||||
|
||||
function pointLineDistance(point: FloorplanPoint, line: FaceLine): number {
|
||||
const direction = normalizedDirection(line.start, line.end)
|
||||
if (!direction) return Number.POSITIVE_INFINITY
|
||||
return Math.abs(cross(direction, [point[0] - line.start[0], point[1] - line.start[1]]))
|
||||
}
|
||||
|
||||
function interpolate(start: FloorplanPoint, end: FloorplanPoint, t: number): FloorplanPoint {
|
||||
return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t]
|
||||
}
|
||||
|
||||
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
function dot(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return first[0] * second[0] + first[1] * second[1]
|
||||
}
|
||||
|
||||
function cross(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return first[0] * second[1] - first[1] * second[0]
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
||||
import { buildRoomFloorplanSchedule } from './room-documentation'
|
||||
|
||||
function room(overrides: Partial<ZoneNode> = {}) {
|
||||
return ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
parentId: 'level_main',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
roomNumber: '101',
|
||||
floorFinish: 'Timber',
|
||||
wallFinish: 'Paint',
|
||||
ceilingFinish: 'ACT',
|
||||
ceilingHeight: 2.7,
|
||||
occupancy: 'Business',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function nodesFor(zones: ZoneNode[]) {
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_main',
|
||||
children: zones.map((zone) => zone.id),
|
||||
})
|
||||
return Object.fromEntries([level, ...zones].map((node) => [node.id, node])) as Record<
|
||||
string,
|
||||
AnyNode
|
||||
>
|
||||
}
|
||||
|
||||
describe('buildRoomFloorplanSchedule', () => {
|
||||
test('includes only architectural rooms and formats their documented values', () => {
|
||||
const office = room({ id: 'zone_office', roomNumber: '102' })
|
||||
const lobby = room({
|
||||
id: 'zone_lobby',
|
||||
name: 'Lobby',
|
||||
roomNumber: '101',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[5, 0],
|
||||
[5, 2],
|
||||
[0, 2],
|
||||
],
|
||||
floorFinish: '',
|
||||
})
|
||||
const courtyard = room({
|
||||
id: 'zone_courtyard',
|
||||
name: 'Courtyard',
|
||||
roomNumber: '100',
|
||||
spaceRole: 'generic',
|
||||
})
|
||||
const zones = [office, lobby, courtyard]
|
||||
|
||||
const schedule = buildRoomFloorplanSchedule({
|
||||
siblings: zones,
|
||||
nodes: nodesFor(zones),
|
||||
levelId: 'level_main',
|
||||
unit: 'metric',
|
||||
})
|
||||
|
||||
expect(schedule?.title).toBe('ROOM SCHEDULE')
|
||||
expect(schedule?.rows.map((row) => row.id)).toEqual(['zone_lobby', 'zone_office'])
|
||||
expect(schedule?.rows[0]?.cells).toMatchObject({
|
||||
number: '101',
|
||||
name: 'Lobby',
|
||||
area: '10.00 m²',
|
||||
floorFinish: '—',
|
||||
wallFinish: 'Paint',
|
||||
ceilingFinish: 'ACT',
|
||||
ceilingHeight: '2700',
|
||||
occupancy: 'Business',
|
||||
enclosure: 'Open',
|
||||
})
|
||||
})
|
||||
|
||||
test('formats imperial schedule values', () => {
|
||||
const office = room({
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
],
|
||||
})
|
||||
const schedule = buildRoomFloorplanSchedule({
|
||||
siblings: [office],
|
||||
nodes: nodesFor([office]),
|
||||
levelId: 'level_main',
|
||||
unit: 'imperial',
|
||||
})
|
||||
|
||||
expect(schedule?.rows[0]?.cells).toMatchObject({
|
||||
area: '10.8 ft²',
|
||||
ceilingHeight: `8'-10 5/16"`,
|
||||
})
|
||||
})
|
||||
|
||||
test('reports missing and duplicate room numbers plus unproven enclosure claims', () => {
|
||||
const unnumbered = room({
|
||||
id: 'zone_unnumbered',
|
||||
name: 'Storage',
|
||||
roomNumber: '',
|
||||
})
|
||||
const duplicateA = room({ id: 'zone_a', roomNumber: 'A01' })
|
||||
const duplicateB = room({
|
||||
id: 'zone_b',
|
||||
name: 'Meeting',
|
||||
roomNumber: 'a01',
|
||||
enclosureStatus: 'enclosed',
|
||||
})
|
||||
const zones = [unnumbered, duplicateA, duplicateB]
|
||||
const schedule = buildRoomFloorplanSchedule({
|
||||
siblings: zones,
|
||||
nodes: nodesFor(zones),
|
||||
levelId: 'level_main',
|
||||
unit: 'metric',
|
||||
})
|
||||
|
||||
expect(schedule?.issues).toEqual([
|
||||
'Room Storage has no room number',
|
||||
'Room a01 is marked enclosed but not proven',
|
||||
'Duplicate room number A01 (2 rooms)',
|
||||
])
|
||||
})
|
||||
|
||||
test('returns no schedule when the level has no architectural rooms', () => {
|
||||
const zone = room({ spaceRole: 'generic' })
|
||||
expect(
|
||||
buildRoomFloorplanSchedule({
|
||||
siblings: [zone],
|
||||
nodes: nodesFor([zone]),
|
||||
levelId: 'level_main',
|
||||
unit: 'metric',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
deriveZoneQuantityReport,
|
||||
resolveAutoZonePolygon,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanSchedule } from '@pascal-app/editor'
|
||||
import {
|
||||
type ConstructionLengthProfile,
|
||||
type ConstructionLinearUnit,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
|
||||
const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671
|
||||
const ROOM_NUMBER_COLLATOR = new Intl.Collator('en', { numeric: true, sensitivity: 'base' })
|
||||
|
||||
export function buildRoomFloorplanSchedule(args: {
|
||||
siblings: ReadonlyArray<ZoneNode>
|
||||
nodes: Readonly<Record<string, AnyNode>>
|
||||
levelId: string
|
||||
unit: ConstructionLinearUnit
|
||||
profile?: ConstructionLengthProfile
|
||||
}): FloorplanSchedule | null {
|
||||
const rooms = args.siblings
|
||||
.filter((zone) => zone.spaceRole === 'room')
|
||||
.map((zone) => {
|
||||
const polygon = resolveAutoZonePolygon(zone, (id) => args.nodes[id])
|
||||
const resolvedZone = polygon === zone.polygon ? zone : { ...zone, polygon }
|
||||
return { zone: resolvedZone, report: deriveZoneQuantityReport(resolvedZone, args.nodes) }
|
||||
})
|
||||
.sort((a, b) => compareRooms(a.zone, b.zone))
|
||||
|
||||
if (rooms.length === 0) return null
|
||||
|
||||
return {
|
||||
id: 'rooms',
|
||||
title: 'ROOM SCHEDULE',
|
||||
columns: [
|
||||
{ key: 'number', label: 'NO.', weight: 0.7 },
|
||||
{ key: 'name', label: 'ROOM NAME', weight: 1.35 },
|
||||
{ key: 'area', label: 'AREA', weight: 0.9 },
|
||||
{ key: 'floorFinish', label: 'FLOOR FINISH', weight: 1.15 },
|
||||
{ key: 'wallFinish', label: 'WALL FINISH', weight: 1.15 },
|
||||
{ key: 'ceilingFinish', label: 'CEILING FINISH', weight: 1.15 },
|
||||
{ key: 'ceilingHeight', label: 'CLG. HT.', weight: 0.9 },
|
||||
{ key: 'occupancy', label: 'OCCUPANCY / USE', weight: 1.25 },
|
||||
{ key: 'enclosure', label: 'ENCLOSURE', weight: 0.9 },
|
||||
],
|
||||
rows: rooms.map(({ zone, report }) => ({
|
||||
id: zone.id,
|
||||
cells: {
|
||||
number: valueOrDash(zone.roomNumber),
|
||||
name: valueOrDash(zone.name),
|
||||
area: formatRoomArea(report.footprintArea, args.unit),
|
||||
floorFinish: valueOrDash(zone.floorFinish),
|
||||
wallFinish: valueOrDash(zone.wallFinish),
|
||||
ceilingFinish: valueOrDash(zone.ceilingFinish),
|
||||
ceilingHeight: formatConstructionLength(
|
||||
zone.ceilingHeight,
|
||||
args.unit,
|
||||
args.profile ?? 'document',
|
||||
),
|
||||
occupancy: valueOrDash(zone.occupancy),
|
||||
enclosure: resolveEnclosure(zone, report.classification),
|
||||
},
|
||||
})),
|
||||
issues: collectRoomScheduleIssues(rooms),
|
||||
}
|
||||
}
|
||||
|
||||
function compareRooms(a: ZoneNode, b: ZoneNode): number {
|
||||
const numberComparison = ROOM_NUMBER_COLLATOR.compare(a.roomNumber.trim(), b.roomNumber.trim())
|
||||
if (numberComparison !== 0) return numberComparison
|
||||
const nameComparison = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
|
||||
return nameComparison !== 0 ? nameComparison : a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
function valueOrDash(value: string): string {
|
||||
return value.trim() || '—'
|
||||
}
|
||||
|
||||
function formatRoomArea(squareMeters: number, unit: ConstructionLinearUnit): string {
|
||||
if (!Number.isFinite(squareMeters)) return '—'
|
||||
if (unit === 'metric') return `${squareMeters.toFixed(2)} m²`
|
||||
return `${(squareMeters * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²`
|
||||
}
|
||||
|
||||
function resolveEnclosure(zone: ZoneNode, classification: 'footprint' | 'enclosed-room'): string {
|
||||
if (zone.enclosureStatus === 'enclosed') return 'Enclosed'
|
||||
if (zone.enclosureStatus === 'open') return 'Open'
|
||||
return classification === 'enclosed-room' ? 'Enclosed' : 'Open'
|
||||
}
|
||||
|
||||
function collectRoomScheduleIssues(
|
||||
rooms: ReadonlyArray<{
|
||||
zone: ZoneNode
|
||||
report: { classification: 'footprint' | 'enclosed-room' }
|
||||
}>,
|
||||
): string[] {
|
||||
const issues: string[] = []
|
||||
const numberedRooms = new Map<string, ZoneNode[]>()
|
||||
|
||||
for (const { zone, report } of rooms) {
|
||||
const number = zone.roomNumber.trim()
|
||||
if (!number) {
|
||||
issues.push(`Room ${zone.name.trim() || zone.id} has no room number`)
|
||||
} else {
|
||||
const normalized = number.toLocaleUpperCase()
|
||||
const duplicates = numberedRooms.get(normalized)
|
||||
if (duplicates) duplicates.push(zone)
|
||||
else numberedRooms.set(normalized, [zone])
|
||||
}
|
||||
|
||||
if (zone.enclosureStatus === 'enclosed' && report.classification !== 'enclosed-room') {
|
||||
issues.push(`Room ${number || zone.name.trim() || zone.id} is marked enclosed but not proven`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [normalizedNumber, duplicateRooms] of numberedRooms) {
|
||||
if (duplicateRooms.length < 2) continue
|
||||
issues.push(`Duplicate room number ${normalizedNumber} (${duplicateRooms.length} rooms)`)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
Reference in New Issue
Block a user