editor: improve floorplan modes and annotations (#549)
* 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 * fix(editor): anchor floorplan cursor to snapped point * fix(nodes): preview floorplan edits through live overrides * feat(editor): add context-aware floorplan modes * fix(nodes): render crisp wall selection hatching * fix(editor): cap floorplan handles at extreme zoom * fix(editor): keep zone labels upright after rotation * refactor(editor): make referenced annotations registry-driven * refactor(nodes): colocate contextual dimension builders * fix(editor): use mode-driven angle snapping * fix(nodes): use mode-driven move snapping * chore(editor): update react scan tooling * refactor(floorplan): streamline construction documentation * refactor(floorplan): remove wall assembly roadmap * fix(floorplan): migrate retired scene data --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
daa1f3e99b
commit
ab76686b8b
@@ -2,15 +2,15 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { buildingDefinition } from './definition'
|
||||
|
||||
describe('buildingDefinition', () => {
|
||||
test('tracks drawing-sheet child support in the schema version', () => {
|
||||
test('accepts level and elevator children', () => {
|
||||
expect(buildingDefinition.kind).toBe('building')
|
||||
expect(buildingDefinition.schemaVersion).toBe(2)
|
||||
expect(buildingDefinition.schemaVersion).toBe(3)
|
||||
expect(
|
||||
buildingDefinition.schema.safeParse({
|
||||
id: 'building_default',
|
||||
type: 'building',
|
||||
...buildingDefinition.defaults(),
|
||||
children: ['level_main', 'drawing-sheet_a101'],
|
||||
children: ['level_main', 'elevator_main'],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
|
||||
*/
|
||||
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
kind: 'building',
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
schema: BuildingNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
type FloorplanAffordance,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
|
||||
@@ -59,9 +61,10 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
|
||||
let lastPatch: Partial<ColumnNode> = {}
|
||||
|
||||
const commitPatch = (patch: Partial<ColumnNode>) => {
|
||||
const previewPatch = (patch: Partial<ColumnNode>) => {
|
||||
lastPatch = patch
|
||||
useScene.getState().updateNode(columnId, patch)
|
||||
useLiveNodeOverrides.getState().set(columnId, patch)
|
||||
useScene.getState().markDirty(columnId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -71,37 +74,37 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
const projDelta = currentProj - initialProj
|
||||
switch (dim) {
|
||||
case 'width':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
width: Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'depth':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
depth: Math.max(MIN_COLUMN_DEPTH, initialDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'uniform': {
|
||||
const next = Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta)
|
||||
commitPatch({ width: next, depth: next })
|
||||
previewPatch({ width: next, depth: next })
|
||||
return
|
||||
}
|
||||
case 'radius':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
radius: Math.max(MIN_COLUMN_RADIUS, initialRadius + projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-width':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceWidth: Math.max(MIN_BRACE_DIMENSION, initialBraceWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-depth':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceDepth: Math.max(MIN_BRACE_DIMENSION, initialBraceDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-bottom-spread':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceBottomSpread: Math.max(
|
||||
MIN_BRACE_BOTTOM_SPREAD,
|
||||
initialBraceBottomSpread + 2 * projDelta,
|
||||
@@ -109,7 +112,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
})
|
||||
return
|
||||
case 'brace-top-spread':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
@@ -120,6 +123,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useLiveNodeOverrides.getState().clear(columnId)
|
||||
useScene.getState().updateNode(columnId, lastPatch)
|
||||
}
|
||||
},
|
||||
@@ -147,21 +151,23 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(columnId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(columnId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(columnId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(columnId)
|
||||
useScene.getState().updateNode(columnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const constructionDimensionDefinition: NodeDefinition<typeof Construction
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
availableModes: ['expert'],
|
||||
resolveForDrawing: resolveConstructionDimensionForDrawing,
|
||||
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeConstructionDimensionChainMode,
|
||||
normalizeConstructionDimensionMode,
|
||||
resolveConstructionDimensionDraftDirection,
|
||||
shouldConsumeConstructionDimensionPointerEvent,
|
||||
} from './floorplan-tool'
|
||||
|
||||
describe('continuous construction-dimension drafting', () => {
|
||||
@@ -21,6 +22,41 @@ describe('continuous construction-dimension drafting', () => {
|
||||
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps an endpoint-to-face dimension aligned with the same straight wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_diagonal',
|
||||
start: [0, 0],
|
||||
end: [4, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
[
|
||||
[4, 0, 4],
|
||||
[0.9292893219, 0, 1.0707106781],
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 4],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [0.9292893219, 0, 1.0707106781],
|
||||
},
|
||||
],
|
||||
{ [wall.id]: wall },
|
||||
)
|
||||
|
||||
expect(direction?.[0]).toBeCloseTo(-Math.SQRT1_2)
|
||||
expect(direction?.[1]).toBeCloseTo(-Math.SQRT1_2)
|
||||
})
|
||||
|
||||
test('previews one adjacent dimension for every witness interval', () => {
|
||||
const geometry = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
@@ -61,7 +97,7 @@ describe('continuous construction-dimension drafting', () => {
|
||||
]
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
|
||||
).toMatchObject({ text: 'R 2m' })
|
||||
).toMatchObject({ text: 'R 1m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
|
||||
).toMatchObject({ text: 'Ø 2m' })
|
||||
@@ -123,14 +159,14 @@ describe('continuous construction-dimension drafting', () => {
|
||||
|
||||
test('only requests a label baseline for modes that use one', () => {
|
||||
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(false)
|
||||
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', () => {
|
||||
test('keeps radius manual while deriving chord and center drafts from one curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
@@ -138,16 +174,7 @@ describe('continuous construction-dimension drafting', () => {
|
||||
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, 'radius')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
|
||||
{ reference: { featureId: 'wall:start' } },
|
||||
{ reference: { featureId: 'wall:end' } },
|
||||
@@ -177,4 +204,14 @@ describe('continuous construction-dimension drafting', () => {
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
|
||||
})
|
||||
|
||||
test('leaves middle-button drag moves available for floor-plan panning', () => {
|
||||
expect(
|
||||
shouldConsumeConstructionDimensionPointerEvent({
|
||||
type: 'pointermove',
|
||||
button: -1,
|
||||
buttons: 4,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { resolveCircularConstructionDimensionLayout } from './geometry'
|
||||
import {
|
||||
alignConstructionDimensionDirectionToSharedWall,
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
|
||||
const SEMANTIC_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_BYPASS_DISTANCE = 0.012
|
||||
@@ -123,12 +126,18 @@ function registryTargetNodeId(target: EventTarget | null): string | null {
|
||||
|
||||
export function resolveConstructionDimensionDraftDirection(
|
||||
points: readonly MeasurementPoint[],
|
||||
anchors?: readonly MeasurementAnchor[],
|
||||
nodes?: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
): [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]
|
||||
if (magnitude <= MIN_DIMENSION_LENGTH) return null
|
||||
const measuredDirection: [number, number] = [dx / magnitude, dz / magnitude]
|
||||
return anchors && nodes
|
||||
? alignConstructionDimensionDirectionToSharedWall(measuredDirection, anchors, (id) => nodes[id])
|
||||
: measuredDirection
|
||||
}
|
||||
|
||||
export function buildConstructionDimensionPreviewGeometries(
|
||||
@@ -137,6 +146,7 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
unit: 'metric' | 'imperial',
|
||||
mode: ConstructionDimensionMode = 'linear',
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
directionOverride?: readonly [number, number] | null,
|
||||
): FloorplanGeometry[] {
|
||||
if (mode === 'arc-length' || mode === 'angular') {
|
||||
const layout = resolveCircularConstructionDimensionLayout(mode, points)
|
||||
@@ -278,7 +288,7 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
]
|
||||
}
|
||||
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(points)
|
||||
const direction = directionOverride ?? resolveConstructionDimensionDraftDirection(points)
|
||||
if (!direction) return []
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const project = (point: MeasurementPoint): [number, number] => {
|
||||
@@ -292,7 +302,11 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
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 rawText = formatLinearMeasurement(
|
||||
mode === 'radius' ? value / 2 : value,
|
||||
unit,
|
||||
metricNotation,
|
||||
)
|
||||
const text =
|
||||
mode === 'radius'
|
||||
? `R ${rawText}`
|
||||
@@ -337,7 +351,16 @@ export function normalizeConstructionDimensionMode(value: unknown): Construction
|
||||
}
|
||||
|
||||
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
|
||||
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
return ['linear', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
}
|
||||
|
||||
export function shouldConsumeConstructionDimensionPointerEvent(event: {
|
||||
type: string
|
||||
button: number
|
||||
buttons: number
|
||||
}): boolean {
|
||||
if (event.type === 'pointerdown') return event.button === 0
|
||||
return (event.buttons & 0b110) === 0
|
||||
}
|
||||
|
||||
function wallFeatureAnchor(
|
||||
@@ -368,7 +391,6 @@ export function buildCurvedWallConstructionDimensionDraft(
|
||||
wallFeatureAnchor(wall, featureId, fallback)
|
||||
|
||||
switch (mode) {
|
||||
case 'radius':
|
||||
case 'center-mark':
|
||||
return {
|
||||
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
|
||||
@@ -461,7 +483,11 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
)
|
||||
}
|
||||
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
|
||||
const direction = resolveConstructionDimensionDraftDirection(current.points)
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
current.points,
|
||||
current.anchors,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
const originPoint = baselinePoint ?? current.points.at(-1)
|
||||
if (!(direction && originPoint)) return false
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
@@ -510,10 +536,10 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
if (current.stage === 'baseline') commitDraft(current, associated.point)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0) consume(event)
|
||||
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
consume(event)
|
||||
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
|
||||
setHover(resolveEvent(event))
|
||||
}
|
||||
const onPointerLeave = () => {
|
||||
@@ -633,19 +659,31 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
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 preview = useMemo(() => {
|
||||
if (draft.stage !== 'baseline' || !hover) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
draft.points,
|
||||
draft.anchors,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
return buildConstructionDimensionPreviewGeometries(
|
||||
draft.points,
|
||||
hover.point,
|
||||
unit,
|
||||
dimensionMode,
|
||||
metricNotation,
|
||||
direction,
|
||||
)
|
||||
}, [
|
||||
dimensionMode,
|
||||
draft.anchors,
|
||||
draft.points,
|
||||
draft.stage,
|
||||
hover,
|
||||
metricNotation,
|
||||
sceneApi,
|
||||
unit,
|
||||
])
|
||||
const witnessDraftPoints =
|
||||
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ConstructionDimensionNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
LevelNode,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
WallNode,
|
||||
@@ -134,47 +135,92 @@ describe('buildConstructionDimensionFloorplan', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves wall anchors against the selected assembly datum', () => {
|
||||
test('straightens an existing endpoint-to-face baseline onto its referenced wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_assembly',
|
||||
id: 'wall_existing_diagonal',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
end: [4, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 4],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [0.9292893219, 0, 1.0707106781],
|
||||
},
|
||||
],
|
||||
baseline: {
|
||||
origin: [5, 5],
|
||||
direction: [-0.7235724834, -0.6902484349],
|
||||
},
|
||||
datumPolicy: 'structural-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 }),
|
||||
)
|
||||
const segment = dimensionSegments(
|
||||
buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall })),
|
||||
)[0]
|
||||
const dx = (segment?.dimensionEnd?.[0] ?? 0) - (segment?.dimensionStart?.[0] ?? 0)
|
||||
const dy = (segment?.dimensionEnd?.[1] ?? 0) - (segment?.dimensionStart?.[1] ?? 0)
|
||||
const length = Math.hypot(dx, dy)
|
||||
|
||||
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)
|
||||
expect(dx / length).toBeCloseTo(-Math.SQRT1_2)
|
||||
expect(dy / length).toBeCloseTo(-Math.SQRT1_2)
|
||||
})
|
||||
|
||||
test('extends a wall-face dimension to the connected wall edge', () => {
|
||||
const measuredWall = WallNode.parse({
|
||||
id: 'wall_measured',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const connectedWall = WallNode.parse({
|
||||
id: 'wall_connected',
|
||||
parentId: 'level_main',
|
||||
start: [4, 0],
|
||||
end: [4, 3],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_main',
|
||||
children: [measuredWall.id, connectedWall.id],
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: measuredWall.id, featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: measuredWall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 0],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy: 'wall-face',
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(
|
||||
node,
|
||||
context({
|
||||
[level.id]: level,
|
||||
[measuredWall.id]: measuredWall,
|
||||
[connectedWall.id]: connectedWall,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(dimensionSegments(geometry)[0]?.end).toEqual([4.1, 0.1])
|
||||
})
|
||||
|
||||
test('uses millimetre notation in document output', () => {
|
||||
@@ -349,59 +395,23 @@ describe('buildConstructionDimensionFloorplan', () => {
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('renders radius notation with a leader and center mark', () => {
|
||||
test('renders radius like diameter while showing half the picked span', () => {
|
||||
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',
|
||||
expect(dimensionSegments(geometry)[0]).toMatchObject({
|
||||
text: 'R 1m',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
end: [2, 0],
|
||||
})
|
||||
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' })
|
||||
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
|
||||
})
|
||||
|
||||
test('renders diameter and repeated-feature notation', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanGeometry,
|
||||
@@ -11,10 +12,9 @@ import type {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallArcData,
|
||||
getWallCurveFrameAt,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
getWallThickness,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
readFloorplanContext,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../shared/construction-length'
|
||||
import { buildDimensionStringGeometry } from '../shared/dimension-string'
|
||||
import {
|
||||
alignConstructionDimensionDirectionToSharedWall,
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
@@ -73,11 +74,23 @@ export function buildConstructionDimensionFloorplan(
|
||||
|
||||
switch (node.mode) {
|
||||
case 'linear':
|
||||
case 'chord':
|
||||
case 'chord': {
|
||||
const alignedNode = {
|
||||
...displayNode,
|
||||
baseline: {
|
||||
...displayNode.baseline,
|
||||
direction: alignConstructionDimensionDirectionToSharedWall(
|
||||
displayNode.baseline.direction,
|
||||
displayNode.anchors,
|
||||
(id) => ctx.resolve(id),
|
||||
),
|
||||
},
|
||||
}
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
buildLinearOrChord(alignedNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
}
|
||||
case 'radius':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
@@ -127,13 +140,19 @@ function resolveDimensionAnchor(
|
||||
const frame = getWallCurveFrameAt(referenced, t)
|
||||
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
|
||||
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
|
||||
const endpointExtension = wallEndpointDatumExtension(
|
||||
referenced,
|
||||
anchor.reference.featureId,
|
||||
node.datumPolicy,
|
||||
ctx,
|
||||
)
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
point: [
|
||||
frame.point.x + frame.normal.x * offset,
|
||||
frame.point.x + frame.normal.x * offset + frame.tangent.x * endpointExtension,
|
||||
resolved.point[1],
|
||||
frame.point.y + frame.normal.y * offset,
|
||||
frame.point.y + frame.normal.y * offset + frame.tangent.y * endpointExtension,
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -184,17 +203,61 @@ function wallDatumOffset(
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
if (policy === 'centerline') return 0
|
||||
if (policy === 'wall-face') {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
return side > 0 ? faces.exterior : faces.interior
|
||||
return (getWallThickness(wall) / 2) * side
|
||||
}
|
||||
|
||||
function wallEndpointDatumExtension(
|
||||
wall: WallNode,
|
||||
featureId: string,
|
||||
policy: ConstructionDimensionNode['datumPolicy'],
|
||||
ctx: GeometryContext,
|
||||
): number {
|
||||
if (policy === 'centerline' || (featureId !== 'wall:start' && featureId !== 'wall:end')) {
|
||||
return 0
|
||||
}
|
||||
|
||||
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)
|
||||
const parent = wall.parentId ? ctx.resolve<AnyNode>(wall.parentId as AnyNodeId) : undefined
|
||||
const childIds =
|
||||
parent && 'children' in parent && Array.isArray(parent.children)
|
||||
? (parent.children as AnyNodeId[])
|
||||
: []
|
||||
const endpoint = featureId === 'wall:start' ? wall.start : wall.end
|
||||
const frame = getWallCurveFrameAt(wall, featureId === 'wall:start' ? 0 : 1)
|
||||
const projections = [0]
|
||||
|
||||
for (const childId of childIds) {
|
||||
const candidate = ctx.resolve<WallNode>(childId)
|
||||
if (
|
||||
candidate?.type !== 'wall' ||
|
||||
candidate.id === wall.id ||
|
||||
getWallArcData(candidate) ||
|
||||
(!pointsCoincide(endpoint, candidate.start) && !pointsCoincide(endpoint, candidate.end))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dx = candidate.end[0] - candidate.start[0]
|
||||
const dz = candidate.end[1] - candidate.start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= EPSILON) continue
|
||||
const normal: FloorplanPoint = [-dz / length, dx / length]
|
||||
for (const side of [-1, 1] as const) {
|
||||
const candidateOffset = wallDatumOffset(candidate, policy, side)
|
||||
projections.push(
|
||||
normal[0] * candidateOffset * frame.tangent.x +
|
||||
normal[1] * candidateOffset * frame.tangent.y,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return featureId === 'wall:start' ? Math.min(...projections) : Math.max(...projections)
|
||||
}
|
||||
|
||||
function pointsCoincide(
|
||||
left: readonly [number, number],
|
||||
right: readonly [number, number],
|
||||
): boolean {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1]) <= 0.03
|
||||
}
|
||||
|
||||
function buildLinearOrChord(
|
||||
@@ -252,23 +315,29 @@ function buildRadius(
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('radius', points)
|
||||
if (!layout) return null
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
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[] = [
|
||||
styledPolyline([layout.center, layout.start, labelPoint], stroke),
|
||||
...openArrow(layout.start, layout.center, stroke),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
dimensionGeometry(
|
||||
node,
|
||||
layout.start,
|
||||
layout.end,
|
||||
layout.start,
|
||||
layout.end,
|
||||
normal,
|
||||
notation(
|
||||
node,
|
||||
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
angle(layout.start, labelPoint),
|
||||
stroke,
|
||||
),
|
||||
hitLine(layout.start, layout.end),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
@@ -531,10 +600,6 @@ function styledLine(
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -651,10 +716,6 @@ 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))}°`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanPoint,
|
||||
MeasurementPoint,
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionMode,
|
||||
type ConstructionDimensionNode,
|
||||
type FloorplanPoint,
|
||||
getWallArcData,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPoint,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ConstructionDimensionSegmentLayout = {
|
||||
@@ -24,6 +28,55 @@ export type ConstructionDimensionLayout = {
|
||||
|
||||
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
export function alignConstructionDimensionDirectionToSharedWall(
|
||||
direction: readonly [number, number],
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
resolve: (id: AnyNodeId) => AnyNode | undefined,
|
||||
): [number, number] {
|
||||
const firstAnchor = anchors[0]
|
||||
const secondAnchor = anchors[1]
|
||||
if (
|
||||
!firstAnchor ||
|
||||
!secondAnchor ||
|
||||
Array.isArray(firstAnchor) ||
|
||||
Array.isArray(secondAnchor) ||
|
||||
firstAnchor.reference.nodeId !== secondAnchor.reference.nodeId
|
||||
) {
|
||||
return [direction[0], direction[1]]
|
||||
}
|
||||
|
||||
const wall = resolve(firstAnchor.reference.nodeId as AnyNodeId)
|
||||
if (
|
||||
wall?.type !== 'wall' ||
|
||||
getWallArcData(wall) ||
|
||||
!supportsStraightWallDirection(firstAnchor.reference.featureId) ||
|
||||
!supportsStraightWallDirection(secondAnchor.reference.featureId)
|
||||
) {
|
||||
return [direction[0], direction[1]]
|
||||
}
|
||||
|
||||
const wallDx = wall.end[0] - wall.start[0]
|
||||
const wallDz = wall.end[1] - wall.start[1]
|
||||
const wallLength = Math.hypot(wallDx, wallDz)
|
||||
if (wallLength <= 1e-9) return [direction[0], direction[1]]
|
||||
const wallDirection: [number, number] = [wallDx / wallLength, wallDz / wallLength]
|
||||
return direction[0] * wallDirection[0] + direction[1] * wallDirection[1] < 0
|
||||
? [-wallDirection[0], -wallDirection[1]]
|
||||
: wallDirection
|
||||
}
|
||||
|
||||
function supportsStraightWallDirection(featureId: string): boolean {
|
||||
return [
|
||||
'wall:start',
|
||||
'wall:end',
|
||||
'wall:centerline',
|
||||
'wall:midpoint',
|
||||
'wall:face:left',
|
||||
'wall:face:right',
|
||||
'wall:top-centerline',
|
||||
].includes(featureId)
|
||||
}
|
||||
|
||||
export type CircularConstructionDimensionLayout = {
|
||||
center: FloorplanPoint
|
||||
start: FloorplanPoint
|
||||
@@ -44,7 +97,7 @@ export function resolveCircularConstructionDimensionLayout(
|
||||
const first = project(anchors[0]!)
|
||||
const second = project(anchors[1]!)
|
||||
|
||||
if (mode === 'diameter') {
|
||||
if (mode === 'diameter' || mode === 'radius') {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { DoorNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import { buildWallHostedOpeningContextualDimensions } from '../wall/contextual-dimensions'
|
||||
|
||||
export function buildDoorContextualDimensions(
|
||||
node: DoorNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
return buildWallHostedOpeningContextualDimensions(node, ctx, {
|
||||
showClearancesWhileMoving: false,
|
||||
useExteriorNormal: false,
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
|
||||
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
|
||||
import { buildDoorContextualDimensions } from './contextual-dimensions'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
import { doorWidthAffordance } from './floorplan-affordances'
|
||||
@@ -174,6 +175,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildDoorContextualDimensions,
|
||||
schedule: buildDoorFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<DoorNodeType>,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type DoorNode,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -23,11 +24,8 @@ type DoorWidthPayload = { side: 'start' | 'end' }
|
||||
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
|
||||
* edge stays fixed.
|
||||
*
|
||||
* Uses the scene-write preview pattern (writes directly to `useScene`
|
||||
* each tick): the registry layer's `effectiveNode` only merges live
|
||||
* overrides for walls, so an override-based preview wouldn't show on
|
||||
* doors. The dispatcher snapshots / pauses history at start, so per-tick
|
||||
* scene writes still collapse to one undoable entry on commit.
|
||||
* Preview state stays in the live override store so the scene graph is
|
||||
* written only once, when the drag commits.
|
||||
*/
|
||||
export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
|
||||
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
@@ -86,18 +84,11 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
|
||||
const newDoorX = anchorX + growDir * (newWidth / 2)
|
||||
lastWidth = newWidth
|
||||
lastDoorX = newDoorX
|
||||
// Scene-write preview so the 2D plan + 3D viewer both pick up
|
||||
// the change immediately. The dispatcher paused history at
|
||||
// session start, so per-tick writes don't pollute undo.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: doorId,
|
||||
data: {
|
||||
width: newWidth,
|
||||
position: [newDoorX, initialDoorY, initialDoorZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
useLiveNodeOverrides.getState().set(doorId, {
|
||||
width: newWidth,
|
||||
position: [newDoorX, initialDoorY, initialDoorZ],
|
||||
})
|
||||
useScene.getState().markDirty(doorId)
|
||||
},
|
||||
canCommit() {
|
||||
// Width is always clamped to >= MIN_DOOR_WIDTH inside apply, so
|
||||
@@ -110,6 +101,7 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
|
||||
// fields that differ from the pre-drag snapshot — if the user
|
||||
// drags back to the original size by accident, the diff is empty
|
||||
// and the door would otherwise revert to its starting state).
|
||||
useLiveNodeOverrides.getState().clear(doorId)
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: doorId,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_door-plan',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
function buildDoor(values: Partial<DoorNode> = {}): FloorplanGeometry[] {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_plan',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
...values,
|
||||
})
|
||||
const geometry = buildDoorFloorplan(door, {
|
||||
children: [],
|
||||
parent: wall,
|
||||
resolve: () => undefined,
|
||||
siblings: [],
|
||||
} as GeometryContext)
|
||||
expect(geometry?.kind).toBe('group')
|
||||
return geometry?.kind === 'group' ? geometry.children : []
|
||||
}
|
||||
|
||||
describe('buildDoorFloorplan documentation symbols', () => {
|
||||
test('shows hinge, strike, panic hardware, and an arched overhead line', () => {
|
||||
const geometry = buildDoor({
|
||||
doorType: 'hinged',
|
||||
openingShape: 'arch',
|
||||
archHeight: 0.45,
|
||||
panicBar: true,
|
||||
})
|
||||
|
||||
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(2)
|
||||
expect(
|
||||
geometry.some(
|
||||
(item) =>
|
||||
item.kind === 'line' && item.strokeLinecap === 'square' && item.strokeWidth === 2.2,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('documents a rounded frameless opening without swing hardware', () => {
|
||||
const geometry = buildDoor({
|
||||
openingKind: 'opening',
|
||||
openingShape: 'rounded',
|
||||
cornerRadius: 0.2,
|
||||
panicBar: true,
|
||||
})
|
||||
|
||||
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(0)
|
||||
expect(geometry.some((item) => item.kind === 'line' && item.strokeLinecap === 'square')).toBe(
|
||||
false,
|
||||
)
|
||||
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,11 @@ import type {
|
||||
GeometryContext,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
readFloorplanContext,
|
||||
readFloorplanGeometryMetadata,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from '@pascal-app/editor'
|
||||
import {
|
||||
buildOpeningMarkAnnotation,
|
||||
type OpeningFloorplanLevelData,
|
||||
@@ -41,10 +45,9 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
|
||||
* mounted on). Returns null when the parent isn't a wall (orphaned
|
||||
* doors during placement etc.).
|
||||
*
|
||||
* Skipped vs the full legacy for now: hinge / strike cubes (small
|
||||
* indicator squares at the rotation pivots), rounded-opening shape
|
||||
* variants, panic bar markers. Those are rare visual variations the
|
||||
* follow-up port can revisit.
|
||||
* Swing leaves include hinge / strike indicators and panic hardware
|
||||
* when present. Rounded and arched heads are shown as dashed overhead
|
||||
* lines because their geometry sits above the horizontal plan cut.
|
||||
*/
|
||||
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const wall = ctx.parent as WallNode | null
|
||||
@@ -123,6 +126,27 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
},
|
||||
]
|
||||
|
||||
if (node.openingShape !== 'rectangle') {
|
||||
const overheadRise =
|
||||
Math.min(width, node.openingShape === 'arch' ? node.archHeight : node.cornerRadius) * 0.2
|
||||
const overheadSide = swingDirection === 'inward' ? 1 : -1
|
||||
const startX = cx - dirX * halfWidth
|
||||
const startZ = cz - dirZ * halfWidth
|
||||
const endX = cx + dirX * halfWidth
|
||||
const endZ = cz + dirZ * halfWidth
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: `M ${startX} ${startZ} Q ${cx + perpX * overheadRise * overheadSide} ${cz + perpZ * overheadRise * overheadSide} ${endX} ${endZ}`,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
strokeOpacity: 0.8,
|
||||
strokeDasharray: '4 3',
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
|
||||
// Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
|
||||
// solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
|
||||
// closed-leaf vector (hinge → strike, whose length is the swing
|
||||
@@ -192,6 +216,47 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
|
||||
const markerSize = Math.min(0.05, Math.max(0.025, radius * 0.05))
|
||||
const markerHalf = markerSize / 2
|
||||
const hardwareMarkers: Array<[number, number]> = [
|
||||
[hX, hZ],
|
||||
[closedTipX, closedTipZ],
|
||||
]
|
||||
for (const [markerX, markerZ] of hardwareMarkers) {
|
||||
children.push({
|
||||
kind: 'rect',
|
||||
x: markerX - markerHalf,
|
||||
y: markerZ - markerHalf,
|
||||
width: markerSize,
|
||||
height: markerSize,
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.4 : 1,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
|
||||
if (node.panicBar) {
|
||||
const leafX = (tipX - hX) / radius
|
||||
const leafZ = (tipZ - hZ) / radius
|
||||
const barCenterX = hX + leafX * radius * 0.7
|
||||
const barCenterZ = hZ + leafZ * radius * 0.7
|
||||
const barHalfLength = Math.min(0.12, Math.max(0.06, depth * 0.75))
|
||||
const barX = -leafZ * barHalfLength
|
||||
const barZ = leafX * barHalfLength
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: barCenterX - barX,
|
||||
y1: barCenterZ - barZ,
|
||||
x2: barCenterX + barX,
|
||||
y2: barCenterZ + barZ,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2.6 : 2.2,
|
||||
strokeLinecap: 'square',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
|
||||
@@ -721,7 +786,7 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
// Placement-measurement dimensions — distances to adjacent openings
|
||||
// (or wall ends) on each side. Only visible while actively moving
|
||||
// (the user clicked Move or grabbed the orange dot).
|
||||
if (view?.moving) {
|
||||
if (view?.moving && readFloorplanContext(ctx).automaticDimensions) {
|
||||
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
|
||||
children.push(dim)
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
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.',
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { drawingSheetDefinition } from './definition'
|
||||
@@ -1,18 +0,0 @@
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
} from '@pascal-app/core'
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
type FloorplanAffordance,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
const MIN_ELEVATOR_DIM = 0.6
|
||||
@@ -15,9 +17,7 @@ type ElevatorResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
|
||||
* `linear-resize` handles declared in `definition.ts` — `anchor: 'center'`
|
||||
* means dragging outward on either +X or -X edge grows `width` by 2×
|
||||
* the elevator-local cursor offset while `position` stays put. Same for
|
||||
* +Z / -Z and `depth`. Writes directly to scene each tick (door pattern);
|
||||
* the registry dispatcher snapshots / pauses history at start so the
|
||||
* per-tick writes collapse into one undoable entry on commit.
|
||||
* +Z / -Z and `depth`.
|
||||
*/
|
||||
export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
@@ -49,14 +49,16 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
const delta = (currentLocal - initialLocal) * side
|
||||
const newValue = Math.max(MIN_ELEVATOR_DIM, initialValue + 2 * delta)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
.set(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
useScene.getState().markDirty(elevatorId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(elevatorId)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
@@ -69,9 +71,7 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
* Elevator rotation drag (floor-plan). Sister to the 3D `arc-resize`
|
||||
* handle declared in `definition.ts`. Same `- delta` sign convention as
|
||||
* the 3D path so dragging the cursor in the same direction in both views
|
||||
* produces the same rotation. Writes directly to scene during the drag;
|
||||
* the registry dispatcher captures a snapshot first and re-applies the
|
||||
* single tracked update on pointer-up.
|
||||
* produces the same rotation.
|
||||
*/
|
||||
export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
@@ -84,21 +84,23 @@ export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [elevatorId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(elevatorId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(elevatorId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(elevatorId)
|
||||
useScene.getState().updateNode(elevatorId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
|
||||
|
||||
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
|
||||
let lastPatches = new Map<AnyNodeId, Partial<FenceNode>>()
|
||||
let lastStart = originalStart
|
||||
let lastEnd = originalEnd
|
||||
|
||||
return {
|
||||
affectedIds,
|
||||
@@ -314,39 +317,63 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end,
|
||||
}))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { start: nextStart, end: nextEnd } },
|
||||
...linkedUpdates.map((u) => ({
|
||||
id: u.id,
|
||||
data: { start: u.start, end: u.end },
|
||||
})),
|
||||
lastStart = nextStart
|
||||
lastEnd = nextEnd
|
||||
const nextPatches = new Map<AnyNodeId, Partial<FenceNode>>([
|
||||
[node.id, { start: nextStart, end: nextEnd }],
|
||||
...linkedUpdates.map(
|
||||
(update) =>
|
||||
[update.id, { start: update.start, end: update.end }] as [
|
||||
AnyNodeId,
|
||||
Partial<FenceNode>,
|
||||
],
|
||||
),
|
||||
])
|
||||
// Re-elect the slab lift host as the endpoint drags (uncapped max
|
||||
// election — 2D has no camera ray). This legacy write path commits
|
||||
// via the dispatcher's snapshot diff, so patching per tick both
|
||||
// previews the lift and lands it in the committed diff. Fences run
|
||||
// no per-frame election: `supportSlabId` IS the lift.
|
||||
const patchedNodes = useScene.getState().nodes
|
||||
const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => {
|
||||
// election — 2D has no camera ray). Fences run no per-frame
|
||||
// election: `supportSlabId` IS the lift.
|
||||
const patchedNodes = { ...sceneNodes }
|
||||
for (const [id, patch] of nextPatches) {
|
||||
patchedNodes[id] = { ...patchedNodes[id], ...patch } as AnyNode
|
||||
}
|
||||
for (const id of nextPatches.keys()) {
|
||||
const fence = patchedNodes[id]
|
||||
if (fence?.type !== 'fence') return []
|
||||
if (fence?.type !== 'fence') continue
|
||||
const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes)
|
||||
return patch.supportSlabId === (fence as FenceNode).supportSlabId
|
||||
? []
|
||||
: [{ id, data: patch }]
|
||||
})
|
||||
if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches)
|
||||
if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) {
|
||||
nextPatches.set(id, { ...nextPatches.get(id), ...patch })
|
||||
}
|
||||
}
|
||||
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
const scene = useScene.getState()
|
||||
for (const linked of linkedOriginals) {
|
||||
if (!nextPatches.has(linked.id)) {
|
||||
overrides.clear(linked.id)
|
||||
scene.markDirty(linked.id)
|
||||
}
|
||||
}
|
||||
for (const [id, patch] of nextPatches) {
|
||||
overrides.set(id, patch)
|
||||
scene.markDirty(id)
|
||||
}
|
||||
lastPatches = nextPatches
|
||||
},
|
||||
canCommit() {
|
||||
// Pointer-up always runs canCommit — drop the alignment guide here
|
||||
// so it doesn't linger after a commit / reject.
|
||||
useAlignmentGuides.getState().clear()
|
||||
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
|
||||
return (
|
||||
!!finalFence &&
|
||||
finalFence.type === 'fence' &&
|
||||
isSegmentLongEnough(finalFence.start, finalFence.end)
|
||||
return isSegmentLongEnough(lastStart, lastEnd)
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNodes(
|
||||
Array.from(lastPatches, ([id, data]) => ({
|
||||
id,
|
||||
data,
|
||||
})),
|
||||
)
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of affectedIds) overrides.clear(id)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -155,7 +155,6 @@ function toMiterWall(segment: SegmentLike): WallNode {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
thickness: segment.thickness,
|
||||
|
||||
@@ -10,7 +10,6 @@ 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'
|
||||
@@ -94,7 +93,6 @@ export const builtinPlugin: Plugin = {
|
||||
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,
|
||||
@@ -141,7 +139,6 @@ 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'
|
||||
@@ -163,35 +160,6 @@ 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'
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type ItemNode as ItemNodeType,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildItemFloorplan } from './floorplan'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildItemContextualDimensions, buildItemFloorplan } from './floorplan'
|
||||
import { itemFloorplanMoveTarget } from './floorplan-move'
|
||||
import { itemPaint } from './paint'
|
||||
import { itemParametrics } from './parametrics'
|
||||
@@ -172,6 +173,11 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
schema: ItemNode,
|
||||
category: 'furnish',
|
||||
surfaceRole: 'furnishing',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildItemContextualDimensions,
|
||||
} satisfies FloorplanNodeExtension<ItemNodeType>,
|
||||
},
|
||||
|
||||
// Defaults shape is cast: the schema requires a fully-typed `asset`
|
||||
// field, but in practice items are always created from the catalog
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
movingFootprintAnchors,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -203,6 +204,7 @@ function buildWallItemSession(
|
||||
original: resolveItemPlanPoint(node, useScene.getState().nodes),
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
@@ -234,25 +236,24 @@ function buildWallItemSession(
|
||||
const halfW = width / 2
|
||||
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: [clampedX, startLocalY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: [clampedX, startLocalY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item' && !!live.parentId
|
||||
return !!lastPatch?.parentId
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -278,6 +279,7 @@ function buildFloorItemSession(
|
||||
const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node)
|
||||
// Alignment candidates gathered once — scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, node.id)
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint }) {
|
||||
@@ -300,22 +302,23 @@ function buildFloorItemSession(
|
||||
const sourceY = node.position[1]
|
||||
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: nextPosition,
|
||||
// Keep parent as the level we resolved at session-start. If
|
||||
// somehow it's null (e.g. orphaned item), fall back to the
|
||||
// existing parent so we don't write `null` and detach.
|
||||
parentId: startLevelId ?? node.parentId,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: nextPosition,
|
||||
// Keep parent as the level we resolved at session-start. If
|
||||
// somehow it's null (e.g. orphaned item), fall back to the
|
||||
// existing parent so we don't write `null` and detach.
|
||||
parentId: startLevelId ?? node.parentId,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item'
|
||||
return lastPatch !== null
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -337,6 +340,7 @@ function buildSurfaceItemSession(
|
||||
resolveItemPlanPoint(node, useScene.getState().nodes),
|
||||
node,
|
||||
)
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint }) {
|
||||
@@ -348,19 +352,20 @@ function buildSurfaceItemSession(
|
||||
const sourceY = node.position[1]
|
||||
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: nextPosition,
|
||||
parentId: surface ? surface.id : node.parentId,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: nextPosition,
|
||||
parentId: surface ? surface.id : node.parentId,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item'
|
||||
return lastPatch !== null
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
roofFacePointToSegment,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
import { formatLinearMeasurement, readFloorplanMetricNotationOverride } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for item.
|
||||
@@ -153,6 +154,58 @@ function resolveItemTransform(
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildItemContextualDimensions(
|
||||
node: ItemNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const transform = resolveItemTransform(node, ctx)
|
||||
if (!transform) return null
|
||||
const [width, , depth] = getScaledDimensions(node)
|
||||
if (width <= 1e-6 || depth <= 1e-6) return null
|
||||
|
||||
const centerLocalZ = node.asset.attachTo === 'wall-side' ? depth / 2 : 0
|
||||
const [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
|
||||
const cx = transform.x + centerOffsetX
|
||||
const cy = transform.y + centerOffsetY
|
||||
const halfWidth = width / 2
|
||||
const halfDepth = depth / 2
|
||||
const point = (x: number, y: number): FloorplanPoint => {
|
||||
const [rx, ry] = rotateVec(x, y, transform.rotation)
|
||||
return [cx + rx, cy + ry]
|
||||
}
|
||||
const widthNormal = rotateVec(0, -1, transform.rotation)
|
||||
const depthNormal = rotateVec(1, 0, transform.rotation)
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const metricNotation = readFloorplanMetricNotationOverride(ctx) ?? 'meters'
|
||||
const stroke = ctx.viewState?.palette?.selectedStroke ?? '#2563eb'
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: point(-halfWidth, -halfDepth),
|
||||
end: point(halfWidth, -halfDepth),
|
||||
offsetNormal: widthNormal,
|
||||
offsetDistance: 0.28,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(width, unit, metricNotation),
|
||||
stroke,
|
||||
},
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: point(halfWidth, -halfDepth),
|
||||
end: point(halfWidth, halfDepth),
|
||||
offsetNormal: depthNormal,
|
||||
offsetDistance: 0.28,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(depth, unit, metricNotation),
|
||||
stroke,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const transform = resolveItemTransform(node, ctx)
|
||||
if (!transform) return null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { measurementDefinition } from './definition'
|
||||
|
||||
describe('measurementDefinition', () => {
|
||||
@@ -21,6 +22,9 @@ describe('measurementDefinition', () => {
|
||||
)
|
||||
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
|
||||
expect(measurementDefinition.parametrics).toBeUndefined()
|
||||
expect(
|
||||
getFloorplanNodeExtension(measurementDefinition)?.referencedSelectionAnnotationRole,
|
||||
).toBe('measurement')
|
||||
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
|
||||
'Left click',
|
||||
'Enter',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
import { measurementMoveVertexAffordance } from './floorplan-affordance'
|
||||
import { MeasurementNode } from './schema'
|
||||
@@ -40,6 +41,11 @@ export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
|
||||
},
|
||||
floorplan: buildMeasurementFloorplan,
|
||||
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
referencedSelectionAnnotationRole: 'measurement',
|
||||
} satisfies FloorplanNodeExtension,
|
||||
},
|
||||
floorplanAffordances: {
|
||||
'move-measurement-vertex': measurementMoveVertexAffordance,
|
||||
},
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
@@ -91,14 +92,16 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
|
||||
const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue
|
||||
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
.set(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
@@ -125,20 +128,22 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(segmentId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
@@ -186,12 +191,14 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
|
||||
let localX = dx * cosRoof + dz * sinRoof
|
||||
let localZ = -dx * sinRoof + dz * cosRoof
|
||||
lastLocal = [localX, initialY, localZ]
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
useLiveNodeOverrides.getState().set(segmentId, { position: lastLocal })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -1,513 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
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,311 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
DoorNode,
|
||||
type GeometryContext,
|
||||
ItemNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions } from '@pascal-app/editor'
|
||||
import { buildDoorContextualDimensions } from '../door/contextual-dimensions'
|
||||
import { buildItemContextualDimensions } from '../item/floorplan'
|
||||
import { buildWallContextualDimensions } from '../wall/contextual-dimensions'
|
||||
import { buildWindowContextualDimensions } from '../window/contextual-dimensions'
|
||||
import { buildZoneContextualDimensions } from '../zone/contextual-dimensions'
|
||||
|
||||
function context(
|
||||
parent: GeometryContext['parent'] = null,
|
||||
siblings: GeometryContext['siblings'] = [],
|
||||
moving = false,
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings,
|
||||
parent,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
metricNotation: 'meters',
|
||||
purpose: 'edit',
|
||||
wallDimensionReference: 'centerline',
|
||||
}),
|
||||
viewState: moving
|
||||
? {
|
||||
selected: true,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: true,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('contextual floor-plan dimensions', () => {
|
||||
test('uses modeled centerline length for a straight wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_primary',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
text: '4m',
|
||||
})
|
||||
})
|
||||
|
||||
test('places a selected corner-wall dimension on its exterior side', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_corner',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [0, -1],
|
||||
})
|
||||
})
|
||||
|
||||
test('infers the outside of an unclassified perimeter wall from its connected plan', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_right',
|
||||
start: [4, 0],
|
||||
end: [4, 6],
|
||||
})
|
||||
const siblings = [
|
||||
WallNode.parse({ id: 'wall_top', start: [0, 0], end: [4, 0] }),
|
||||
WallNode.parse({ id: 'wall_bottom', start: [4, 6], end: [0, 6] }),
|
||||
WallNode.parse({ id: 'wall_left', start: [0, 6], end: [0, 0] }),
|
||||
]
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, siblings))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [1, 0],
|
||||
})
|
||||
})
|
||||
|
||||
test('shows one internal-wall dimension between the connected stud faces', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_internal',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
thickness: 0.1,
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [-2, 0],
|
||||
end: [2, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [-2, 4],
|
||||
end: [2, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'dimension',
|
||||
start: [0, 0.1],
|
||||
end: [0, 3.9],
|
||||
offsetNormal: [-1, 0],
|
||||
text: '3.8m',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('measures a selected perimeter wall between connected stud centerlines', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_perimeter',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [-2, 0],
|
||||
end: [2, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [-2, 4],
|
||||
end: [2, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
offsetNormal: [1, 0],
|
||||
text: '4m',
|
||||
})
|
||||
})
|
||||
|
||||
test('uses arc length for a curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curved',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
const geometry = buildWallContextualDimensions(wall, context())
|
||||
|
||||
expect(geometry).toMatchObject({ kind: 'dimension-label' })
|
||||
expect(geometry && 'text' in geometry ? Number.parseFloat(geometry.text) : 0).toBeGreaterThan(4)
|
||||
})
|
||||
|
||||
test('shows only an opening width along its host wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 0.9,
|
||||
})
|
||||
|
||||
expect(buildDoorContextualDimensions(door, context(wall))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [1.55, 0],
|
||||
end: [2.45, 0],
|
||||
text: '0.9m',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows a selected window width on the exterior side', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [0, -2],
|
||||
end: [0, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [6, -2],
|
||||
end: [6, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildWindowContextualDimensions(window, context(wall, [startWall, endWall])),
|
||||
).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [0, -1],
|
||||
start: [1.5, 0],
|
||||
end: [2.5, 0],
|
||||
text: '1m',
|
||||
})
|
||||
})
|
||||
|
||||
test('updates both window clearances from its live wall-local position', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [0, -2],
|
||||
end: [0, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [6, -2],
|
||||
end: [6, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const draggedWindow = WindowNode.parse({
|
||||
id: 'window_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [3, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
const geometry = buildWindowContextualDimensions(
|
||||
draggedWindow,
|
||||
context(wall, [startWall, endWall], true),
|
||||
)
|
||||
|
||||
expect(
|
||||
geometry?.kind === 'dimension-string' ? geometry.segments.map((segment) => segment.text) : [],
|
||||
).toEqual(['2.4m', '1m', '2.4m'])
|
||||
})
|
||||
|
||||
test('shows room area at the polygon centroid', () => {
|
||||
const room = ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
})
|
||||
|
||||
expect(buildZoneContextualDimensions(room, context())).toMatchObject({
|
||||
kind: 'dimension-label',
|
||||
cx: 2,
|
||||
cy: 1.5,
|
||||
text: '12.0m²',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows item width and depth without placement chains', () => {
|
||||
const item = ItemNode.parse({
|
||||
id: 'item_primary',
|
||||
position: [2, 0, 3],
|
||||
scale: [2, 1, 1],
|
||||
asset: {
|
||||
id: 'table',
|
||||
category: 'furniture',
|
||||
name: 'Table',
|
||||
thumbnail: '',
|
||||
src: 'asset://table',
|
||||
dimensions: [1.2, 0.8, 0.6],
|
||||
},
|
||||
})
|
||||
const geometry = buildItemContextualDimensions(item, context())
|
||||
|
||||
expect(geometry?.kind).toBe('group')
|
||||
expect(
|
||||
geometry?.kind === 'group'
|
||||
? geometry.children.map((child) => ('text' in child ? child.text : null))
|
||||
: [],
|
||||
).toEqual(['2.4m', '0.6m'])
|
||||
})
|
||||
})
|
||||
@@ -1,340 +0,0 @@
|
||||
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',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,495 +0,0 @@
|
||||
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,144 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { columnResizeAffordance } from '../column/floorplan-affordances'
|
||||
import { doorWidthAffordance } from '../door/floorplan-affordances'
|
||||
import { spawnRotateAffordance } from '../spawn/floorplan-affordances'
|
||||
import { windowWidthAffordance } from '../window/floorplan-affordances'
|
||||
|
||||
globalThis.requestAnimationFrame ??= (callback) => {
|
||||
callback(0)
|
||||
return 0
|
||||
}
|
||||
globalThis.cancelAnimationFrame ??= () => {}
|
||||
|
||||
const modifiers = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
|
||||
|
||||
afterEach(() => {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
|
||||
})
|
||||
|
||||
describe('opening width floor-plan affordances', () => {
|
||||
for (const kind of ['door', 'window'] as const) {
|
||||
test(`${kind} previews through a live override and writes the scene only on commit`, () => {
|
||||
const wall = WallNode.parse({
|
||||
id: `wall_${kind}`,
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
})
|
||||
const opening =
|
||||
kind === 'door'
|
||||
? DoorNode.parse({
|
||||
id: 'door_width-live',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
: WindowNode.parse({
|
||||
id: 'window_width-live',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
const nodes = { [wall.id]: wall, [opening.id]: opening }
|
||||
useScene.setState({ nodes } as never)
|
||||
const affordance = kind === 'door' ? doorWidthAffordance : windowWidthAffordance
|
||||
const session = affordance.start({
|
||||
node: opening as never,
|
||||
payload: { side: 'end' },
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [2.5, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 0], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[opening.id]).toBe(opening)
|
||||
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toMatchObject({
|
||||
width: 1.5,
|
||||
position: [2.25, 1.05, 0],
|
||||
})
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toBeUndefined()
|
||||
expect(useScene.getState().nodes[opening.id]).toMatchObject({
|
||||
width: 1.5,
|
||||
position: [2.25, 1.05, 0],
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('floor-plan affordance preview policy', () => {
|
||||
test('a parametric resize keeps scene data stable until commit', () => {
|
||||
const column = {
|
||||
id: 'column_live-resize',
|
||||
type: 'column',
|
||||
position: [0, 0, 0],
|
||||
width: 1,
|
||||
depth: 1,
|
||||
radius: 0.5,
|
||||
}
|
||||
useScene.setState({ nodes: { [column.id]: column } } as never)
|
||||
const session = columnResizeAffordance.start({
|
||||
node: column as never,
|
||||
payload: { dim: 'width', planAxis: [1, 0] },
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [0.5, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [0.75, 0], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[column.id]).toBe(column)
|
||||
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toMatchObject({
|
||||
width: 1.5,
|
||||
})
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toBeUndefined()
|
||||
expect(useScene.getState().nodes[column.id]).toMatchObject({ width: 1.5 })
|
||||
})
|
||||
|
||||
test('a rotation keeps scene data stable until commit', () => {
|
||||
const spawn = {
|
||||
id: 'spawn_live-rotate',
|
||||
type: 'spawn',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
}
|
||||
useScene.setState({ nodes: { [spawn.id]: spawn } } as never)
|
||||
const session = spawnRotateAffordance.start({
|
||||
node: spawn as never,
|
||||
payload: undefined,
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [0, 1], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[spawn.id]).toBe(spawn)
|
||||
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)?.rotation).toBeCloseTo(
|
||||
-Math.PI / 2,
|
||||
)
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)).toBeUndefined()
|
||||
expect((useScene.getState().nodes[spawn.id] as { rotation: number }).rotation).toBeCloseTo(
|
||||
-Math.PI / 2,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
|
||||
@@ -45,13 +47,15 @@ export const shelfResizeAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
} else {
|
||||
lastPatch = { depth: Math.max(MIN_SHELF_DEPTH, initialDepth + 2 * projDelta) }
|
||||
}
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
useLiveNodeOverrides.getState().set(shelfId, lastPatch)
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
}
|
||||
},
|
||||
@@ -83,21 +87,23 @@ export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotationY = initialRotationY - delta
|
||||
lastRotation = [r[0], newRotationY, r[2]]
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(shelfId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
movingFootprintAnchors,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getFloorStackPreviewPosition,
|
||||
getSegmentGridStep,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
@@ -23,22 +25,8 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
* because shelf is a `position`-field kind (it carries its location in
|
||||
* `node.position`, not in polygon vertices):
|
||||
*
|
||||
* - Each pointermove writes the absolute world-plan position straight
|
||||
* to `useScene` (history is paused by the overlay). This is the single
|
||||
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
|
||||
* `ParametricNodeRenderer` group transform both follow it reactively,
|
||||
* so 2D and 3D can never diverge.
|
||||
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
|
||||
* history, and re-applies the final position as one undoable step.
|
||||
* `canCommit` only validates.
|
||||
*
|
||||
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
|
||||
* `slab` / `ceiling` use. That works for polygon kinds because their commit
|
||||
* rebuilds geometry (the vertices change), which forces the 3D group to
|
||||
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
|
||||
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
|
||||
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
|
||||
* scene directly removes that second source of truth entirely.
|
||||
* - Each pointermove previews through `useLiveNodeOverrides`.
|
||||
* - On commit, the final position is written once as one undoable step.
|
||||
*/
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
@@ -49,6 +37,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastVisualPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
// Alignment candidates — corner/edge/segment anchors of every OTHER node
|
||||
@@ -58,16 +47,13 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snap = (value: number) => {
|
||||
if (modifiers.shiftKey) return value
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
apply({ planPoint }) {
|
||||
const gridSnapActive = isGridSnapActive()
|
||||
const step = gridSnapActive ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value)
|
||||
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||
// Figma-style alignment layered on the grid snap — the shelf footprint
|
||||
// edges snap to neighbours / wall faces and a guide is published. Alt
|
||||
// bypasses alignment; Shift bypasses all snap.
|
||||
// edges snap to neighbours / wall faces and a guide is published.
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
@@ -77,7 +63,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
originalRotationY,
|
||||
),
|
||||
candidates,
|
||||
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
@@ -86,7 +72,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
// and the placement coordinators. Item / slab / wall flows fire
|
||||
// the same cue, so the shelf following along is the expected UX.
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
|
||||
if (gridSnapActive && snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
@@ -96,23 +82,19 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
rotation: node.rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
// Single source of truth — write the absolute position straight to
|
||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||
// the 3D group transform read `node.position` reactively, so they
|
||||
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
|
||||
// into one undoable step on commit.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: visualPosition },
|
||||
},
|
||||
])
|
||||
lastVisualPosition = visualPosition
|
||||
useLiveNodeOverrides.getState().set(shelfId, { position: visualPosition })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
|
||||
if (live?.type !== 'shelf') return false
|
||||
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNodes([{ id: shelfId, data: { position: lastVisualPosition } }])
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type SpawnNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||
@@ -17,20 +19,22 @@ export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [spawnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(spawnId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(spawnId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(spawnId)
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
type SpawnNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor'
|
||||
|
||||
export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node }) => {
|
||||
const spawnId = node.id as AnyNodeId
|
||||
@@ -16,14 +17,15 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [spawnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const step = getSegmentGridStep()
|
||||
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
|
||||
apply({ planPoint }) {
|
||||
const step = isGridSnapActive() ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
|
||||
const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])]
|
||||
|
||||
if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return
|
||||
lastPosition = next
|
||||
useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }])
|
||||
useLiveNodeOverrides.getState().set(spawnId, { position: next })
|
||||
useScene.getState().markDirty(spawnId)
|
||||
},
|
||||
canCommit() {
|
||||
if (!lastPosition) return false
|
||||
@@ -31,6 +33,7 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
|
||||
},
|
||||
commit() {
|
||||
if (!lastPosition) return
|
||||
useLiveNodeOverrides.getState().clear(spawnId)
|
||||
useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }])
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
type FloorplanAffordanceSession,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Minimums + max sweep mirror the 3D handles in
|
||||
@@ -73,12 +75,14 @@ export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const delta = sign * (currentProj - initialProj)
|
||||
const newWidth = Math.max(MIN_SEGMENT_WIDTH, initialWidth + delta)
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(segmentNodeId, { width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(segmentNodeId, { width: newWidth })
|
||||
useScene.getState().markDirty(segmentNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentNodeId)
|
||||
useScene.getState().updateNode(segmentNodeId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -111,12 +115,14 @@ export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const delta = currentProj - initialProj
|
||||
const newLength = Math.max(MIN_SEGMENT_LENGTH, initialLength + delta)
|
||||
lastLength = newLength
|
||||
useScene.getState().updateNode(segmentNodeId, { length: newLength })
|
||||
useLiveNodeOverrides.getState().set(segmentNodeId, { length: newLength })
|
||||
useScene.getState().markDirty(segmentNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentNodeId)
|
||||
useScene.getState().updateNode(segmentNodeId, { length: lastLength })
|
||||
},
|
||||
}
|
||||
@@ -149,12 +155,14 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(stairId, { width: newWidth })
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -200,12 +208,17 @@ export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> =
|
||||
const newWidth = initialOuterRadius - newInner
|
||||
lastInner = newInner
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { innerRadius: newInner, width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(stairId, {
|
||||
innerRadius: newInner,
|
||||
width: newWidth,
|
||||
})
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { innerRadius: lastInner, width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -233,21 +246,23 @@ export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(stairId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
@@ -298,12 +313,17 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
|
||||
const newRotation = initialRotation + rotationShift
|
||||
lastSweep = newSweep
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { sweepAngle: newSweep, rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(stairId, {
|
||||
sweepAngle: newSweep,
|
||||
rotation: newRotation,
|
||||
})
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
movingAlignmentAnchors,
|
||||
type StairNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getSegmentGridStep,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
|
||||
/**
|
||||
@@ -17,13 +23,11 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
* Existing stairs preserve the cursor grab offset, matching the 3D move
|
||||
* tools; fresh catalog placement follows the cursor absolutely.
|
||||
*
|
||||
* Figma alignment is layered on the stair footprint edges; Alt bypasses.
|
||||
* Figma alignment is layered on the stair footprint edges.
|
||||
* Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
|
||||
*
|
||||
* The position is written straight to scene each tick (the stair has a real
|
||||
* `position` field, unlike polygon kinds) and re-applied atomically via
|
||||
* `commit()` so the overlay's deterministic revert → resume → commit path
|
||||
* records a single undo step (same pattern door / window use).
|
||||
* The position previews through the live override store and is written to
|
||||
* scene once via `commit()`.
|
||||
*/
|
||||
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
|
||||
const startY = node.position[1]
|
||||
@@ -37,14 +41,12 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Snap the origin to the editor's current grid step (driven by
|
||||
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
|
||||
const step = getSegmentGridStep()
|
||||
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
|
||||
apply({ planPoint }) {
|
||||
const step = isGridSnapActive() ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
|
||||
const [gx, gz] = resolveCursor(planPoint, { snap })
|
||||
// Figma alignment on the actual stair footprint (Alt bypasses alignment; Shift all snap),
|
||||
// matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
|
||||
// Figma alignment on the actual stair footprint, matching the 3D move
|
||||
// tool. Publishes guides via `useAlignmentGuides`.
|
||||
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
|
||||
const { point: aligned } = applyFloorplanAlignment(
|
||||
[gx, gz],
|
||||
@@ -52,14 +54,15 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
? movingAnchors
|
||||
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const sx = aligned[0]
|
||||
const sz = aligned[1]
|
||||
|
||||
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
|
||||
lastValid = { position: [sx, startY, sz] }
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastValid)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
// No overlap / placement rules for stairs in 2D — any pointer-up
|
||||
@@ -72,6 +75,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
// commit-path (revert → resume → session.commit()). Same pattern
|
||||
// door / window use.
|
||||
if (!lastValid) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const structuralGridDefinition: NodeDefinition<typeof StructuralGridNode>
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
availableModes: ['expert'],
|
||||
preferredView: '2d',
|
||||
} satisfies FloorplanNodeExtension<StructuralGridNode>,
|
||||
},
|
||||
|
||||
@@ -14,34 +14,9 @@ function wall(overrides: Partial<WallNodeType>): WallNodeType {
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [1, 0],
|
||||
thickness: 0.2,
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -88,42 +63,9 @@ function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false)
|
||||
id: 'wall_partition',
|
||||
start: [4, partitionSpansPlan ? -6 : -4],
|
||||
end: [4, 0],
|
||||
thickness: 0.12,
|
||||
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<
|
||||
@@ -172,12 +114,12 @@ describe('automatic wall dimension reference policy', () => {
|
||||
return entries[0]?.end[0]
|
||||
}
|
||||
|
||||
expect(intersection('finish-face')).toBeCloseTo(3.92)
|
||||
expect(intersection('finish-face')).toBeCloseTo(3.94)
|
||||
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', () => {
|
||||
test('keeps centerline distinct while all face modes use the wall face', () => {
|
||||
const { nodes, top, walls } = topFacadeFixture(true)
|
||||
const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes })
|
||||
const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
@@ -191,9 +133,9 @@ describe('automatic wall dimension reference policy', () => {
|
||||
}
|
||||
|
||||
expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([
|
||||
'3.92m',
|
||||
'0.26m',
|
||||
'6.02m',
|
||||
'4.04m',
|
||||
'0.12m',
|
||||
'6.04m',
|
||||
])
|
||||
expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m'])
|
||||
expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([
|
||||
@@ -306,16 +248,7 @@ describe('automatic wall dimension reference policy', () => {
|
||||
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'],
|
||||
},
|
||||
],
|
||||
thickness: 0.12,
|
||||
})
|
||||
const horizontalWalls = [top, right, bottom, left, partition]
|
||||
const horizontalNodes = Object.fromEntries(
|
||||
|
||||
@@ -184,36 +184,12 @@ describe('buildWallConstructionDimensions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('places witness origins on centerline, structural, finish, or assembly faces', () => {
|
||||
const assemblyWall = wall({
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
test('places witness origins on the centerline or wall faces', () => {
|
||||
const plainWall = wall({ thickness: 0.2 })
|
||||
const witnessY = (
|
||||
datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face',
|
||||
) => {
|
||||
const entry = buildWallConstructionDimensions(assemblyWall, context(), {
|
||||
const entry = buildWallConstructionDimensions(plainWall, context(), {
|
||||
unit: 'metric',
|
||||
standard: constructionDimensionStandard({ datumPolicy }),
|
||||
})[0]
|
||||
@@ -221,9 +197,9 @@ describe('buildWallConstructionDimensions', () => {
|
||||
}
|
||||
|
||||
expect(witnessY('centerline')).toBe(0)
|
||||
expect(witnessY('structural-face')).toBeCloseTo(0.05)
|
||||
expect(witnessY('finish-face')).toBeCloseTo(0.08)
|
||||
expect(witnessY('wall-face')).toBeCloseTo(0.08)
|
||||
expect(witnessY('structural-face')).toBeCloseTo(0.1)
|
||||
expect(witnessY('finish-face')).toBeCloseTo(0.1)
|
||||
expect(witnessY('wall-face')).toBeCloseTo(0.1)
|
||||
})
|
||||
|
||||
test('never dimensions a classified interior wall', () => {
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallArcData,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -1512,16 +1511,8 @@ function wallDatumOffsetOnSide(
|
||||
policy: ConstructionDimensionDrawingStandard['datumPolicy'],
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
if (policy === 'wall-face') return side > 0 ? faces.exterior : faces.interior
|
||||
if (policy === 'centerline') return 0
|
||||
|
||||
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 side > 0 ? faces.exterior : faces.interior
|
||||
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
|
||||
return (getWallThickness(wall) / 2) * side
|
||||
}
|
||||
|
||||
function wallDatumDistanceToward(
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatLinearMeasurement, readFloorplanMetricNotationOverride } from '@pascal-app/editor'
|
||||
|
||||
type WallHostedOpening = {
|
||||
position: readonly [number, number, number]
|
||||
width: number
|
||||
}
|
||||
|
||||
type OpeningDimensionOptions = {
|
||||
showClearancesWhileMoving: boolean
|
||||
useExteriorNormal: boolean
|
||||
}
|
||||
|
||||
const WALL_CONNECTION_TOLERANCE = 0.03
|
||||
|
||||
function formatLength(value: number, ctx: GeometryContext): string {
|
||||
return formatLinearMeasurement(
|
||||
value,
|
||||
ctx.viewState?.unit ?? 'metric',
|
||||
readFloorplanMetricNotationOverride(ctx) ?? 'meters',
|
||||
)
|
||||
}
|
||||
|
||||
function selectedStroke(ctx: GeometryContext): string {
|
||||
return ctx.viewState?.palette?.selectedStroke ?? '#2563eb'
|
||||
}
|
||||
|
||||
function contextualWallDimensionNormal(
|
||||
wall: WallNode,
|
||||
frontNormal: FloorplanPoint,
|
||||
siblings: GeometryContext['siblings'],
|
||||
): FloorplanPoint {
|
||||
const front: FloorplanPoint = [cleanZero(frontNormal[0]), cleanZero(frontNormal[1])]
|
||||
const back: FloorplanPoint = [cleanZero(-front[0]), cleanZero(-front[1])]
|
||||
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return front
|
||||
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return back
|
||||
if (wall.frontSide === 'interior' && wall.backSide === 'interior') return front
|
||||
|
||||
const walls = [
|
||||
wall,
|
||||
...siblings.filter((sibling): sibling is WallNode => sibling.type === 'wall'),
|
||||
]
|
||||
let centroidX = 0
|
||||
let centroidY = 0
|
||||
for (const candidate of walls) {
|
||||
centroidX += candidate.start[0] + candidate.end[0]
|
||||
centroidY += candidate.start[1] + candidate.end[1]
|
||||
}
|
||||
const centroid: FloorplanPoint = [centroidX / (walls.length * 2), centroidY / (walls.length * 2)]
|
||||
const midpoint: FloorplanPoint = [
|
||||
(wall.start[0] + wall.end[0]) / 2,
|
||||
(wall.start[1] + wall.end[1]) / 2,
|
||||
]
|
||||
const towardFront =
|
||||
(midpoint[0] - centroid[0]) * front[0] + (midpoint[1] - centroid[1]) * front[1]
|
||||
return towardFront >= 0 ? front : back
|
||||
}
|
||||
|
||||
function cleanZero(value: number): number {
|
||||
return Math.abs(value) <= Number.EPSILON ? 0 : value
|
||||
}
|
||||
|
||||
function cross(left: FloorplanPoint, right: FloorplanPoint): number {
|
||||
return left[0] * right[1] - left[1] * right[0]
|
||||
}
|
||||
|
||||
function pointSegmentDistance(
|
||||
point: FloorplanPoint,
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
): number {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const lengthSquared = dx * dx + dy * dy
|
||||
if (lengthSquared <= 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared),
|
||||
)
|
||||
return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dy * t))
|
||||
}
|
||||
|
||||
function structuralFaceProjections(
|
||||
wallStart: FloorplanPoint,
|
||||
wallTangent: FloorplanPoint,
|
||||
connectedWall: WallNode,
|
||||
): number[] {
|
||||
if (isCurvedWall(connectedWall)) return []
|
||||
const dx = connectedWall.end[0] - connectedWall.start[0]
|
||||
const dy = connectedWall.end[1] - connectedWall.start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length <= 1e-6) return []
|
||||
const direction: FloorplanPoint = [dx / length, dy / length]
|
||||
const denominator = cross(wallTangent, direction)
|
||||
if (Math.abs(denominator) <= 1e-6) return []
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
|
||||
const halfThickness = getWallThickness(connectedWall) / 2
|
||||
return [-halfThickness, halfThickness].map((offset) => {
|
||||
const facePoint: FloorplanPoint = [
|
||||
connectedWall.start[0] + normal[0] * offset,
|
||||
connectedWall.start[1] + normal[1] * offset,
|
||||
]
|
||||
return (
|
||||
cross([facePoint[0] - wallStart[0], facePoint[1] - wallStart[1]], direction) / denominator
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function wallStudFaceSpan(
|
||||
wall: WallNode,
|
||||
siblings: GeometryContext['siblings'],
|
||||
): { end: FloorplanPoint; length: number; start: FloorplanPoint } {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const centerlineLength = Math.hypot(dx, dy)
|
||||
if (centerlineLength <= 1e-6) {
|
||||
return { start: wall.start, end: wall.end, length: centerlineLength }
|
||||
}
|
||||
|
||||
const tangent: FloorplanPoint = [dx / centerlineLength, dy / centerlineLength]
|
||||
let startProjection = 0
|
||||
let endProjection = centerlineLength
|
||||
|
||||
for (const sibling of siblings) {
|
||||
if (sibling.type !== 'wall' || sibling.id === wall.id) continue
|
||||
const projections = structuralFaceProjections(wall.start, tangent, sibling)
|
||||
if (pointSegmentDistance(wall.start, sibling.start, sibling.end) <= WALL_CONNECTION_TOLERANCE) {
|
||||
for (const projection of projections) {
|
||||
if (projection > startProjection && projection < endProjection) {
|
||||
startProjection = projection
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointSegmentDistance(wall.end, sibling.start, sibling.end) <= WALL_CONNECTION_TOLERANCE) {
|
||||
for (const projection of projections) {
|
||||
if (projection > startProjection && projection < endProjection) {
|
||||
endProjection = projection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start: FloorplanPoint = [
|
||||
wall.start[0] + tangent[0] * startProjection,
|
||||
wall.start[1] + tangent[1] * startProjection,
|
||||
]
|
||||
const end: FloorplanPoint = [
|
||||
wall.start[0] + tangent[0] * endProjection,
|
||||
wall.start[1] + tangent[1] * endProjection,
|
||||
]
|
||||
return { start, end, length: Math.max(0, endProjection - startProjection) }
|
||||
}
|
||||
|
||||
export function buildWallContextualDimensions(
|
||||
node: WallNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const length = getWallCurveLength(node)
|
||||
if (!Number.isFinite(length) || length <= 1e-6) return null
|
||||
|
||||
if (isCurvedWall(node)) {
|
||||
const frame = getWallCurveFrameAt(node, 0.5)
|
||||
const offsetNormal = contextualWallDimensionNormal(
|
||||
node,
|
||||
[frame.normal.x, frame.normal.y],
|
||||
ctx.siblings,
|
||||
)
|
||||
return {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: frame.point.x + offsetNormal[0] * 0.38,
|
||||
cy: frame.point.y + offsetNormal[1] * 0.38,
|
||||
text: formatLength(length, ctx),
|
||||
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dy = node.end[1] - node.start[1]
|
||||
const chordLength = Math.hypot(dx, dy)
|
||||
if (chordLength <= 1e-6) return null
|
||||
const offsetNormal = contextualWallDimensionNormal(
|
||||
node,
|
||||
[-dy / chordLength, dx / chordLength],
|
||||
ctx.siblings,
|
||||
)
|
||||
const span =
|
||||
node.frontSide === 'interior' && node.backSide === 'interior'
|
||||
? wallStudFaceSpan(node, ctx.siblings)
|
||||
: { start: node.start, end: node.end, length: chordLength }
|
||||
if (span.length <= 1e-6) return null
|
||||
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(span.length, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWallHostedOpeningContextualDimensions(
|
||||
node: WallHostedOpening,
|
||||
ctx: GeometryContext,
|
||||
options: OpeningDimensionOptions,
|
||||
): FloorplanGeometry | null {
|
||||
const wall = ctx.parent as WallNode | null
|
||||
if (wall?.type !== 'wall' || node.width <= 1e-6) return null
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const wallLength = Math.hypot(dx, dy)
|
||||
if (wallLength <= 1e-6) return null
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirY = dy / wallLength
|
||||
const cx = wall.start[0] + dirX * node.position[0]
|
||||
const cy = wall.start[1] + dirY * node.position[0]
|
||||
const halfWidth = node.width / 2
|
||||
const openingStart: FloorplanPoint = [cx - dirX * halfWidth, cy - dirY * halfWidth]
|
||||
const openingEnd: FloorplanPoint = [cx + dirX * halfWidth, cy + dirY * halfWidth]
|
||||
|
||||
if (!options.useExteriorNormal || isCurvedWall(wall)) {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
offsetNormal: [-dirY, dirX],
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(node.width, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
const offsetNormal = contextualWallDimensionNormal(wall, [-dirY, dirX], ctx.siblings)
|
||||
if (!options.showClearancesWhileMoving || !ctx.viewState?.moving) {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(node.width, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
const span = wallStudFaceSpan(wall, ctx.siblings)
|
||||
const spanStart = (span.start[0] - wall.start[0]) * dirX + (span.start[1] - wall.start[1]) * dirY
|
||||
const spanEnd = (span.end[0] - wall.start[0]) * dirX + (span.end[1] - wall.start[1]) * dirY
|
||||
const openingStartAlong = node.position[0] - halfWidth
|
||||
const openingEndAlong = node.position[0] + halfWidth
|
||||
|
||||
return {
|
||||
kind: 'dimension-string',
|
||||
segments: [
|
||||
{
|
||||
start: span.start,
|
||||
end: openingStart,
|
||||
text: formatLength(Math.max(0, openingStartAlong - spanStart), ctx),
|
||||
},
|
||||
{
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
text: formatLength(node.width, ctx),
|
||||
},
|
||||
{
|
||||
start: openingEnd,
|
||||
end: span.end,
|
||||
text: formatLength(Math.max(0, spanEnd - openingEndAlong), ctx),
|
||||
},
|
||||
],
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { wallDefinition } from './definition'
|
||||
|
||||
test('wallDefinition records the retired assembly field migration', () => {
|
||||
expect(wallDefinition.schemaVersion).toBe(7)
|
||||
})
|
||||
|
||||
describe('wallDefinition floor-plan extension', () => {
|
||||
test('owns curve eligibility for hosted openings', () => {
|
||||
const wall = wallDefinition.schema.parse({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AnyNodeId, NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildWallContextualDimensions } from './contextual-dimensions'
|
||||
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -33,12 +34,13 @@ import { wallSlots } from './slots'
|
||||
export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
kind: 'wall',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
schema: WallNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildWallContextualDimensions,
|
||||
actionMenu: {
|
||||
canCurve: ({ node, nodes }) =>
|
||||
!node.children.some((childId) => {
|
||||
@@ -58,7 +60,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: [0, 0],
|
||||
end: [3, 0],
|
||||
frontSide: 'unknown',
|
||||
|
||||
@@ -31,6 +31,7 @@ function context(
|
||||
selected = false,
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
wallDimensionReference: 'finished-faces' | 'centerline' | 'stud-faces' = 'finished-faces',
|
||||
automaticDimensions = true,
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
@@ -46,6 +47,7 @@ function context(
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({
|
||||
automaticDimensions,
|
||||
metricNotation,
|
||||
purpose,
|
||||
wallDimensionReference,
|
||||
@@ -90,6 +92,53 @@ describe('buildWallFloorplan render purpose', () => {
|
||||
expect(readFloorplanGeometryMetadata(documentPolygon).annotationObstacle).toBe('outline')
|
||||
})
|
||||
|
||||
test('draws crisp diagonal hatch strokes inside a selected wall', () => {
|
||||
const diagonalWall = WallNode.parse({
|
||||
...wall,
|
||||
end: [4, 4],
|
||||
})
|
||||
const selected = buildWallFloorplan(diagonalWall, context('edit', true))
|
||||
const selectedOutline = selected
|
||||
? flatten(selected).find((entry) => entry.kind === 'polygon')
|
||||
: undefined
|
||||
const hatchLines = selected
|
||||
? flatten(selected).filter(
|
||||
(entry) => entry.kind === 'line' && entry.stroke === palette.selectedHatch,
|
||||
)
|
||||
: []
|
||||
|
||||
expect(selectedOutline?.kind).toBe('polygon')
|
||||
expect(hatchLines.length).toBeGreaterThan(8)
|
||||
expect(
|
||||
hatchLines.every(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.strokeWidth === 0.02 &&
|
||||
entry.strokeWidth < (selectedOutline?.strokeWidth ?? 0) &&
|
||||
entry.vectorEffect === undefined &&
|
||||
entry.pointerEvents === 'none' &&
|
||||
readFloorplanGeometryMetadata(entry).renderPass === 'overlay',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('extends selected-wall hatch strokes to both wall faces', () => {
|
||||
const selected = buildWallFloorplan(wall, context('edit', true))
|
||||
const entries = selected ? flatten(selected) : []
|
||||
const outline = entries.find((entry) => entry.kind === 'polygon')
|
||||
const hatch = entries.find(
|
||||
(entry) => entry.kind === 'line' && entry.stroke === palette.selectedHatch,
|
||||
)
|
||||
|
||||
expect(outline?.kind).toBe('polygon')
|
||||
expect(hatch?.kind).toBe('line')
|
||||
if (outline?.kind !== 'polygon' || hatch?.kind !== 'line') return
|
||||
|
||||
const wallFaces = outline.points.map((point) => point[1])
|
||||
expect(Math.min(hatch.y1, hatch.y2)).toBeCloseTo(Math.min(...wallFaces))
|
||||
expect(Math.max(hatch.y1, hatch.y2)).toBeCloseTo(Math.max(...wallFaces))
|
||||
})
|
||||
|
||||
test('uses document metric notation only for document output', () => {
|
||||
const edit = buildWallFloorplan(wall, context('edit'))
|
||||
const document = buildWallFloorplan(wall, context('document'))
|
||||
@@ -115,144 +164,40 @@ describe('buildWallFloorplan render purpose', () => {
|
||||
expect(texts).toContain('4000')
|
||||
})
|
||||
|
||||
test('keeps standalone wall witnesses on the stud face in every intersection mode', () => {
|
||||
const assemblyWall = WallNode.parse({
|
||||
test('does not construct automatic wall dimensions when presentation disables them', () => {
|
||||
const geometry = buildWallFloorplan(
|
||||
wall,
|
||||
context('edit', false, 'meters', 'finished-faces', false),
|
||||
)
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.kind === 'dimension' ||
|
||||
entry.kind === 'dimension-string' ||
|
||||
entry.kind === 'dimension-label',
|
||||
),
|
||||
).toBe(false)
|
||||
expect(entries.some((entry) => entry.kind === 'polygon')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps standalone wall witnesses on the wall face in every intersection mode', () => {
|
||||
const plainWall = 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'],
|
||||
},
|
||||
],
|
||||
thickness: 0.1,
|
||||
})
|
||||
const witnessY = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
const geometry = buildWallFloorplan(assemblyWall, context('edit', false, 'meters', reference))
|
||||
const geometry = buildWallFloorplan(plainWall, 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)
|
||||
expect(witnessY('finished-faces')).toBeCloseTo(0.065)
|
||||
expect(witnessY('centerline')).toBeCloseTo(0.065)
|
||||
expect(witnessY('stud-faces')).toBeCloseTo(0.065)
|
||||
})
|
||||
|
||||
test('shows an orthogonal depth dimension for a curved wall without a radius leader', () => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallAssemblyThickness,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallPlanFootprint,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
type WallAssemblyLayer,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -28,13 +29,15 @@ 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 FLOORPLAN_SELECTION_HATCH_SPACING = 0.12
|
||||
const FLOORPLAN_SELECTED_WALL_STROKE_WIDTH = 0.03
|
||||
const FLOORPLAN_SELECTION_HATCH_STROKE_WIDTH = 0.02
|
||||
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 = getWallAssemblyThickness(wall)
|
||||
const baseThickness = getWallThickness(wall)
|
||||
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
|
||||
return Math.min(
|
||||
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
|
||||
@@ -46,10 +49,6 @@ function exaggerateWallThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: floorplanWallThickness(wall) }
|
||||
}
|
||||
|
||||
function wallWithModeledAssemblyThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: getWallAssemblyThickness(wall) }
|
||||
}
|
||||
|
||||
export type WallFloorplanLevelData = {
|
||||
miters: WallMiterData
|
||||
documentMiters: WallMiterData
|
||||
@@ -64,29 +63,40 @@ export function computeWallFloorplanLevelData({
|
||||
nodes: Record<string, AnyNode>
|
||||
}): WallFloorplanLevelData {
|
||||
const walls = siblings.map(exaggerateWallThickness)
|
||||
const constructionDimensionsByReference = {} as Record<
|
||||
WallDimensionReference,
|
||||
WallConstructionDimensionPlan
|
||||
>
|
||||
for (const reference of WALL_DIMENSION_REFERENCES) {
|
||||
let cached: WallConstructionDimensionPlan | undefined
|
||||
Object.defineProperty(constructionDimensionsByReference, reference, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
if (cached) return cached
|
||||
const datumPolicy =
|
||||
reference === 'finished-faces'
|
||||
? 'wall-face'
|
||||
: reference === 'stud-faces'
|
||||
? 'structural-face'
|
||||
: 'centerline'
|
||||
cached = buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({
|
||||
datumPolicy,
|
||||
...(reference === 'finished-faces'
|
||||
? { intersectionReferencePolicy: 'both-faces' as const }
|
||||
: {}),
|
||||
}),
|
||||
)
|
||||
return cached
|
||||
},
|
||||
})
|
||||
}
|
||||
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' }),
|
||||
),
|
||||
},
|
||||
constructionDimensionsByReference,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +117,10 @@ export function computeWallFloorplanLevelData({
|
||||
* direct builder callers.
|
||||
*/
|
||||
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const { metricNotation, purpose, wallDimensionReference } = readFloorplanContext(ctx)
|
||||
const { automaticDimensions, metricNotation, purpose, wallDimensionReference } =
|
||||
readFloorplanContext(ctx)
|
||||
const documentMode = purpose === 'document'
|
||||
const wallForPurpose = (wall: WallNode) =>
|
||||
documentMode ? wallWithModeledAssemblyThickness(wall) : exaggerateWallThickness(wall)
|
||||
const wallForPurpose = (wall: WallNode) => (documentMode ? 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 —
|
||||
@@ -155,7 +165,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
points,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
strokeWidth: showSelectedChrome ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : 0.02,
|
||||
opacity: 0.92,
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
|
||||
// Once the wall is selected, the body keeps catching the pointer
|
||||
@@ -167,65 +177,60 @@ 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) {
|
||||
if (automaticDimensions) {
|
||||
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(
|
||||
...renderPlannedConstructionDimensions(
|
||||
planned,
|
||||
view?.unit ?? 'metric',
|
||||
dimensionStroke,
|
||||
documentMode ? 'document' : 'editor',
|
||||
dimensionStandard,
|
||||
),
|
||||
)
|
||||
} else if (!levelData) {
|
||||
children.push(
|
||||
...buildWallConstructionDimensions(self, ctx, {
|
||||
...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) {
|
||||
children.push({
|
||||
kind: 'hatch',
|
||||
points,
|
||||
color: palette.selectedHatch,
|
||||
opacity: 1,
|
||||
})
|
||||
children.push(...buildSelectedWallHatchLines(self, palette.selectedHatch))
|
||||
}
|
||||
|
||||
// Hit-line on the centerline. Stroke width is in screen pixels so it
|
||||
@@ -309,6 +314,35 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
function buildSelectedWallHatchLines(wall: WallNode, stroke: string): FloorplanGeometry[] {
|
||||
const length = getWallCurveLength(wall)
|
||||
if (length <= 1e-6) return []
|
||||
|
||||
const halfAcross = getWallThickness(wall) / 2
|
||||
const halfAlong = halfAcross
|
||||
const count = Math.max(1, Math.floor(length / FLOORPLAN_SELECTION_HATCH_SPACING))
|
||||
const spacing = length / count
|
||||
const lines: FloorplanGeometry[] = []
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const along = (index + 0.5) * spacing
|
||||
const frame = getWallCurveFrameAt(wall, along / length)
|
||||
lines.push({
|
||||
kind: 'line',
|
||||
x1: frame.point.x - frame.tangent.x * halfAlong - frame.normal.x * halfAcross,
|
||||
y1: frame.point.y - frame.tangent.y * halfAlong - frame.normal.y * halfAcross,
|
||||
x2: frame.point.x + frame.tangent.x * halfAlong + frame.normal.x * halfAcross,
|
||||
y2: frame.point.y + frame.tangent.y * halfAlong + frame.normal.y * halfAcross,
|
||||
stroke,
|
||||
strokeWidth: FLOORPLAN_SELECTION_HATCH_STROKE_WIDTH,
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function wallDimensionDatumPolicy(reference: WallDimensionReference) {
|
||||
switch (reference) {
|
||||
case 'centerline':
|
||||
@@ -320,424 +354,6 @@ function wallDimensionDatumPolicy(reference: WallDimensionReference) {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Doors, windows, and wall-attached items would tear if the wall bent
|
||||
* around them, so the curve sagitta handle hides when any of those
|
||||
|
||||
@@ -15,9 +15,6 @@ 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'
|
||||
@@ -37,7 +34,7 @@ import {
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Plus, Spline, Trash2 } from 'lucide-react'
|
||||
import { Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling'
|
||||
|
||||
@@ -269,7 +266,10 @@ export default function WallPanel() {
|
||||
min={metersToLinearUnit(0.05, unit)}
|
||||
onChange={(v) =>
|
||||
handleUpdate({
|
||||
thickness: linearControlValueToMeters(v, unit, { maxMeters: 1, minMeters: 0.05 }),
|
||||
thickness: linearControlValueToMeters(v, unit, {
|
||||
maxMeters: 1,
|
||||
minMeters: 0.05,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={3}
|
||||
@@ -301,8 +301,6 @@ export default function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<WallAssemblySection node={node} onUpdate={handleUpdate} unit={unit} unitLabel={unitLabel} />
|
||||
|
||||
<WallFaceBandSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
@@ -357,165 +355,6 @@ 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,20 +8,5 @@
|
||||
* imports a single canonical type.
|
||||
*/
|
||||
|
||||
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'
|
||||
export type { WallNode as WallNodeType } from '@pascal-app/core'
|
||||
export { WallNode } from '@pascal-app/core'
|
||||
|
||||
@@ -153,7 +153,6 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
thickness: DRAFT_WALL_THICKNESS,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { FloorplanGeometry, GeometryContext, WindowNode } from '@pascal-app/core'
|
||||
import { buildWallHostedOpeningContextualDimensions } from '../wall/contextual-dimensions'
|
||||
|
||||
export function buildWindowContextualDimensions(
|
||||
node: WindowNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
return buildWallHostedOpeningContextualDimensions(node, ctx, {
|
||||
showClearancesWhileMoving: true,
|
||||
useExteriorNormal: true,
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
|
||||
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
|
||||
import { buildWindowContextualDimensions } from './contextual-dimensions'
|
||||
import { buildWindowFloorplan } from './floorplan'
|
||||
import { windowWidthAffordance } from './floorplan-affordances'
|
||||
import { windowFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -171,6 +172,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
category: 'structure',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildWindowContextualDimensions,
|
||||
schedule: buildWindowFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<WindowNodeType>,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
@@ -24,11 +25,8 @@ type WindowWidthPayload = { side: 'start' | 'end' }
|
||||
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
|
||||
* edge stays fixed.
|
||||
*
|
||||
* Uses the scene-write preview pattern (writes directly to `useScene`
|
||||
* each tick): the registry layer's `effectiveNode` only merges live
|
||||
* overrides for walls, so an override-based preview wouldn't show on
|
||||
* windows. The dispatcher snapshots / pauses history at start, so
|
||||
* per-tick scene writes still collapse to one undoable entry on commit.
|
||||
* Preview state stays in the live override store so the scene graph is
|
||||
* written only once, when the drag commits.
|
||||
*/
|
||||
export const windowWidthAffordance: FloorplanAffordance<WindowNode> = {
|
||||
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
@@ -76,20 +74,17 @@ export const windowWidthAffordance: FloorplanAffordance<WindowNode> = {
|
||||
const newWindowX = anchorX + growDir * (newWidth / 2)
|
||||
lastWidth = newWidth
|
||||
lastWindowX = newWindowX
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: windowId,
|
||||
data: {
|
||||
width: newWidth,
|
||||
position: [newWindowX, initialWindowY, initialWindowZ],
|
||||
},
|
||||
},
|
||||
])
|
||||
useLiveNodeOverrides.getState().set(windowId, {
|
||||
width: newWidth,
|
||||
position: [newWindowX, initialWindowY, initialWindowZ],
|
||||
})
|
||||
useScene.getState().markDirty(windowId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(windowId)
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: windowId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor'
|
||||
import {
|
||||
buildOpeningMarkAnnotation,
|
||||
type OpeningFloorplanLevelData,
|
||||
@@ -169,7 +169,7 @@ export function buildWindowFloorplan(
|
||||
|
||||
// Placement-measurement dimensions when actively moving — same
|
||||
// contract as door (see `nodes/src/door/floorplan.ts`).
|
||||
if (view?.moving) {
|
||||
if (view?.moving && readFloorplanContext(ctx).automaticDimensions) {
|
||||
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
|
||||
children.push(dim)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
resolveAutoZonePolygon,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatAreaLabel } from '@pascal-app/editor'
|
||||
|
||||
export function buildZoneContextualDimensions(
|
||||
node: ZoneNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = resolveAutoZonePolygon(node, ctx.resolve)
|
||||
if (polygon.length < 3) return null
|
||||
const { area, centroid } = polygonAreaAndCentroid(polygon)
|
||||
if (area <= 1e-6) return null
|
||||
|
||||
return {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: centroid[0],
|
||||
cy: centroid[1],
|
||||
text: formatAreaLabel(area, ctx.viewState?.unit ?? 'metric', 1),
|
||||
angle: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function polygonAreaAndCentroid(points: readonly FloorplanPoint[]): {
|
||||
area: number
|
||||
centroid: FloorplanPoint
|
||||
} {
|
||||
let twiceSignedArea = 0
|
||||
let weightedX = 0
|
||||
let weightedY = 0
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]!
|
||||
const next = points[(index + 1) % points.length]!
|
||||
const cross = current[0] * next[1] - next[0] * current[1]
|
||||
twiceSignedArea += cross
|
||||
weightedX += (current[0] + next[0]) * cross
|
||||
weightedY += (current[1] + next[1]) * cross
|
||||
}
|
||||
const area = Math.abs(twiceSignedArea) / 2
|
||||
if (Math.abs(twiceSignedArea) <= 1e-9) {
|
||||
const sum = points.reduce(
|
||||
(acc, point) => [acc[0] + point[0], acc[1] + point[1]] as FloorplanPoint,
|
||||
[0, 0] as FloorplanPoint,
|
||||
)
|
||||
return {
|
||||
area,
|
||||
centroid: [sum[0] / points.length, sum[1] / points.length],
|
||||
}
|
||||
}
|
||||
return {
|
||||
area,
|
||||
centroid: [weightedX / (3 * twiceSignedArea), weightedY / (3 * twiceSignedArea)],
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildZoneContextualDimensions } from './contextual-dimensions'
|
||||
import { buildZoneFloorplan } from './floorplan'
|
||||
import {
|
||||
zoneAddVertexAffordance,
|
||||
@@ -32,6 +33,7 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
category: 'site',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildZoneContextualDimensions,
|
||||
schedule: buildRoomFloorplanSchedule,
|
||||
} satisfies FloorplanNodeExtension<ZoneNode>,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,9 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
|
||||
stroke,
|
||||
),
|
||||
)
|
||||
children.push(...buildRoomClearDimensions(node, ctx))
|
||||
if (floorplanContext.automaticDimensions) {
|
||||
children.push(...buildRoomClearDimensions(node, ctx))
|
||||
}
|
||||
} else if (name) {
|
||||
children.push({
|
||||
kind: 'text',
|
||||
|
||||
@@ -53,33 +53,10 @@ function enclosure(points: Array<[number, number]>) {
|
||||
return { context, nodes, walls, zone }
|
||||
}
|
||||
|
||||
function withFinishAssembly(wall: WallNode): WallNode {
|
||||
function withFinishedThickness(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'],
|
||||
},
|
||||
],
|
||||
thickness: 0.24,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,23 +124,23 @@ describe('buildRoomClearDimensions', () => {
|
||||
).toEqual(['2.8m', '3.8m'])
|
||||
})
|
||||
|
||||
test('dimensions finish faces when every boundary wall has assembly finish datums', () => {
|
||||
test('dimensions finish faces using each boundary wall thickness', () => {
|
||||
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 finishedWalls = walls.map(withFinishedThickness)
|
||||
const finishedNodes = { ...nodes }
|
||||
for (const wall of finishedWalls) finishedNodes[wall.id] = wall
|
||||
|
||||
const result = dimensions(
|
||||
buildRoomClearDimensions(
|
||||
{ ...zone, clearDimensionPolicy: 'finish-faces' },
|
||||
{
|
||||
...context,
|
||||
resolve: (id) => assembledNodes[id],
|
||||
resolve: (id) => finishedNodes[id],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -181,7 +158,7 @@ describe('buildRoomClearDimensions', () => {
|
||||
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)
|
||||
].map(withFinishedThickness)
|
||||
const zoneA = ZoneNode.parse({
|
||||
id: 'zone_a',
|
||||
parentId: 'level_main',
|
||||
@@ -254,7 +231,7 @@ describe('buildRoomClearDimensions', () => {
|
||||
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', () => {
|
||||
test('suppresses dimensions when the room or requested policy is invalid', () => {
|
||||
const { context, nodes, walls, zone } = enclosure([
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
@@ -264,8 +241,10 @@ describe('buildRoomClearDimensions', () => {
|
||||
|
||||
expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([])
|
||||
expect(
|
||||
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
|
||||
).toEqual([])
|
||||
dimensions(
|
||||
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
|
||||
),
|
||||
).toHaveLength(2)
|
||||
expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([])
|
||||
expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([])
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallAssemblyFaceOffsets,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
getWallThickness,
|
||||
type SpaceBoundaryFace,
|
||||
type WallNode,
|
||||
type ZoneNode,
|
||||
@@ -31,11 +30,6 @@ type FaceLine = {
|
||||
|
||||
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
|
||||
type ClearDimensionPolicy = Extract<
|
||||
ZoneNode['clearDimensionPolicy'],
|
||||
'inside-faces' | 'finish-faces'
|
||||
>
|
||||
|
||||
export function buildRoomClearDimensions(
|
||||
node: ZoneNode,
|
||||
ctx: GeometryContext,
|
||||
@@ -72,7 +66,7 @@ export function buildRoomClearDimensions(
|
||||
if (!space) return []
|
||||
|
||||
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
|
||||
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy)
|
||||
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById)
|
||||
if (!faceLines) return []
|
||||
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
@@ -104,13 +98,12 @@ export function buildRoomClearDimensions(
|
||||
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)
|
||||
const line = offsetBoundaryFace(boundary, wall)
|
||||
if (!line) return null
|
||||
faceLines.push(line)
|
||||
}
|
||||
@@ -228,11 +221,7 @@ function buildRectilinearClearDimensions(
|
||||
return dimensions
|
||||
}
|
||||
|
||||
function offsetBoundaryFace(
|
||||
boundary: SpaceBoundaryFace,
|
||||
wall: WallNode,
|
||||
policy: ClearDimensionPolicy,
|
||||
): FaceLine | null {
|
||||
function offsetBoundaryFace(boundary: SpaceBoundaryFace, wall: WallNode): FaceLine | null {
|
||||
const first = boundary.points[0]
|
||||
const last = boundary.points[boundary.points.length - 1]
|
||||
if (!(first && last)) return null
|
||||
@@ -241,32 +230,13 @@ function offsetBoundaryFace(
|
||||
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
|
||||
const offset = (getWallThickness(wall) / 2) * side
|
||||
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]!
|
||||
@@ -421,8 +391,8 @@ function buildRoomToRoomClearDimensions(
|
||||
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')
|
||||
const currentLine = offsetBoundaryFace(currentBoundary, wall)
|
||||
const neighborLine = offsetBoundaryFace(neighborBoundary, wall)
|
||||
if (!(currentLine && neighborLine)) continue
|
||||
const dimension = dimensionAcrossSharedRoomWall(
|
||||
currentLine,
|
||||
|
||||
Reference in New Issue
Block a user