From 77442861d9a7e44de64bb1e5fa4c690d8a3865b5 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Wed, 22 Jul 2026 23:32:17 +0530 Subject: [PATCH] editor: complete floorplan construction documentation (#531) * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * feat(floorplan): add construction dimension strings * feat(floorplan): coordinate opening dimensions * feat(floorplan): add opening documentation * feat(floorplan): add construction dimensions and notes * feat(floorplan): add interior dimensions and curved note leaders * feat(floorplan): improve construction dimensions and document plan * feat(floorplan): harden construction document output * feat(floorplan): add annotation collision diagnostics * feat(floorplan): size export annotations in paper space * feat(floorplan): automatically separate overlapping labels * fix(floorplan): resolve dense label overlaps * fix(floorplan): remove stale collision warning overlays * fix(floorplan): treat mark pills as collision obstacles * feat(floorplan): place short dimension values outside * fix(floorplan): preserve dimension string order * fix(floorplan): avoid architectural geometry in label layout * feat(floorplan): add dimension side fallback leaders * docs(floorplan): update chapter 17 implementation status * fix(floorplan): dimension subdivided interior walls * feat: add associative floor plan dimensions * feat: add continuous construction dimension strings * feat: add structural floor plan grids * feat: coordinate columns with structural grids Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer. * feat: add architectural room documentation Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior. * feat: generate architectural room schedules Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings. * feat: add reliable room clear dimensions Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums. * feat: add architectural stair documentation Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility. * feat: add typed specialty construction notes Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls. * feat: add curved and circular dimensions Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls. * feat: coordinate floor plan drawing types Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output. * feat: add associative curved wall dimensions Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes. * fix: render automatic curved wall dimensions The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility. * fix: use radius callout for curved walls Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth. * Implement construction dimension string editing * Add construction dimension standards controls * Apply drawing standards to automatic dimensions * Add floorplan overhead and reference visibility controls * Add view-specific dimension segment suppression * Add persistent drawing sheet model * Plot floorplan exports at fixed scale * Apply paper-space annotation profiles * Compose floorplan PDF sheets * Support sheet paper sizes and preflight * Persist pinned annotation layout overrides * Expand annotation collision obstacles * Add floorplan annotation preflight surface * Add reusable drawing sheet general notes * Add drawing sheet keyed note instances * Add drawing sheet document markers * Expand construction note leader terminators * Add wall assembly layer model * Resolve wall assembly datum references * Add wall assembly floorplan graphics * Add opening documentation dimension policies * Add finish-face room clear dimensions * Extend room clear dimensions to rectilinear rooms * Add construction module advisories * Add clearance advisory profiles * Add dimension completeness audit * Expand dimension completeness audit * Include preflight issues in completeness audit * feat: complete floorplan construction documentation * refactor: remove construction note node * feat: refine floorplan documentation and unit display * fix(editor): improve floorplan PDF dimensions * fix(floorplan): refresh annotation collision layout * fix(floorplan): keep annotations clear and restore registry boundaries * feat(floorplan): refine construction dimension references * fix(floorplan): align documentation tools with architecture --------- Co-authored-by: Claude Opus 4.6 --- apps/editor/app/page.tsx | 2 + apps/editor/components/build-tab.tsx | 74 +- .../floorplan-construction-preflight.tsx | 70 + apps/editor/components/viewer-toolbar.tsx | 218 ++- apps/editor/public/icons/structural-grid.webp | Bin 0 -> 72538 bytes apps/ifc-converter/next-env.d.ts | 2 +- bun.lock | 132 +- design-qa.md | 64 + packages/core/src/events/bus.ts | 9 + packages/core/src/index.ts | 6 + .../core/src/lib/measurement-geometry.test.ts | 33 +- packages/core/src/lib/measurement-geometry.ts | 50 +- packages/core/src/lib/zone-quantities.ts | 2 +- packages/core/src/registry/index.ts | 3 + packages/core/src/registry/subtree.test.ts | 111 +- packages/core/src/registry/subtree.ts | 14 +- packages/core/src/registry/types.ts | 63 +- packages/core/src/schema/index.ts | 66 +- packages/core/src/schema/nodes/building.ts | 7 +- .../nodes/construction-dimension.test.ts | 176 ++ .../schema/nodes/construction-dimension.ts | 205 +++ packages/core/src/schema/nodes/door.ts | 23 + .../src/schema/nodes/drawing-sheet.test.ts | 222 +++ .../core/src/schema/nodes/drawing-sheet.ts | 263 +++ packages/core/src/schema/nodes/level.ts | 4 + .../src/schema/nodes/structural-grid.test.ts | 33 + .../core/src/schema/nodes/structural-grid.ts | 22 + packages/core/src/schema/nodes/wall.test.ts | 222 ++- packages/core/src/schema/nodes/wall.ts | 260 +++ packages/core/src/schema/nodes/window.ts | 22 + packages/core/src/schema/nodes/zone.test.ts | 53 + packages/core/src/schema/nodes/zone.ts | 15 + packages/core/src/schema/types.ts | 6 + ...e-construction-dimension-migration.test.ts | 70 + packages/core/src/store/use-scene.ts | 25 + packages/core/src/systems/wall/wall-curve.ts | 2 +- .../src/systems/wall/wall-mitering.test.ts | 1 + .../core/src/utils/clone-scene-graph.test.ts | 113 ++ packages/core/src/utils/clone-scene-graph.ts | 22 +- packages/editor/package.json | 6 +- .../floorplan-alignment-guide-layer.tsx | 3 +- .../floorplan-measurement-tool-layer.tsx | 26 +- .../floorplan-registered-tool-layer.tsx | 61 + .../floorplan-registry-action-menu.tsx | 39 +- .../floorplan-annotation-layout.test.ts | 408 +++++ .../renderers/floorplan-annotation-layout.ts | 688 +++++++ .../floorplan-dimension-renderer.test.tsx | 236 +++ .../floorplan-dimension-renderer.tsx | 608 ++++++ .../floorplan-geometry-renderer.test.tsx | 369 ++++ .../renderers/floorplan-geometry-renderer.tsx | 457 ++++- .../floorplan-registry-layer.test.ts | 122 ++ .../renderers/floorplan-registry-layer.tsx | 425 +++-- .../renderers/floorplan-stair-layer.tsx | 30 +- .../src/components/editor/floorplan-panel.tsx | 25 +- .../components/editor/measurement-pill.tsx | 7 +- .../editor/quick-measurement-card.tsx | 12 +- .../editor/quick-measurement-hud.tsx | 8 +- .../ui/action-menu/measurement-control.tsx | 123 +- .../sidebar/panels/settings-panel/index.tsx | 11 +- packages/editor/src/index.tsx | 35 + .../floorplan/annotation-visibility.test.ts | 249 +++ .../lib/floorplan/annotation-visibility.ts | 110 ++ .../floorplan/drawing-coordination.test.ts | 46 + .../src/lib/floorplan/drawing-coordination.ts | 13 + .../lib/floorplan/floorplan-export.test.ts | 591 ++++++ .../src/lib/floorplan/floorplan-export.tsx | 1593 ++++++++++++++-- .../lib/floorplan/floorplan-extension.test.ts | 37 + .../src/lib/floorplan/floorplan-extension.ts | 177 ++ .../floorplan/floorplan-pdfkit-document.ts | 194 ++ .../floorplan-pdfkit-renderer.test.ts | 107 ++ .../floorplan/floorplan-pdfkit-renderer.ts | 680 +++++++ packages/editor/src/lib/floorplan/geometry.ts | 5 +- packages/editor/src/lib/measurements.test.ts | 5 + packages/editor/src/lib/measurements.ts | 13 +- .../editor/src/store/use-drawing-view.test.ts | 31 + packages/editor/src/store/use-drawing-view.ts | 130 ++ packages/editor/src/store/use-editor.tsx | 6 +- .../use-floorplan-annotation-visibility.ts | 60 + .../src/store/use-floorplan-preflight.ts | 54 + .../nodes/src/building/definition.test.ts | 17 + packages/nodes/src/building/definition.ts | 2 +- packages/nodes/src/column/definition.ts | 27 +- packages/nodes/src/column/floorplan-move.ts | 13 +- packages/nodes/src/column/floorplan.test.ts | 96 + packages/nodes/src/column/floorplan.ts | 88 +- packages/nodes/src/column/tool.tsx | 31 +- .../construction-dimension/definition.test.ts | 34 + .../src/construction-dimension/definition.ts | 91 + .../drawing-coordination.test.ts | 83 + .../drawing-coordination.ts | 76 + .../floorplan-affordances.test.ts | 126 ++ .../floorplan-affordances.ts | 154 ++ .../floorplan-tool.test.ts | 180 ++ .../construction-dimension/floorplan-tool.tsx | 726 ++++++++ .../construction-dimension/floorplan.test.ts | 518 ++++++ .../src/construction-dimension/floorplan.ts | 660 +++++++ .../src/construction-dimension/geometry.ts | 154 ++ .../nodes/src/construction-dimension/index.ts | 6 + .../src/construction-dimension/panel.tsx | 416 +++++ .../src/construction-dimension/parametrics.ts | 6 + .../src/construction-dimension/schema.ts | 6 + packages/nodes/src/door/definition.ts | 13 +- packages/nodes/src/door/floorplan.ts | 38 +- packages/nodes/src/door/panel.tsx | 17 + packages/nodes/src/door/schema.ts | 6 +- .../src/drawing-sheet/definition.test.ts | 52 + .../nodes/src/drawing-sheet/definition.ts | 51 + packages/nodes/src/drawing-sheet/index.ts | 1 + packages/nodes/src/drawing-sheet/schema.ts | 18 + packages/nodes/src/fence/tool.tsx | 1 + packages/nodes/src/index.ts | 38 + .../nodes/src/measurement/floorplan.test.ts | 30 +- packages/nodes/src/measurement/floorplan.ts | 195 +- packages/nodes/src/measurement/index.ts | 1 + packages/nodes/src/measurement/resolve.ts | 4 +- .../src/shared/clearance-advisories.test.ts | 209 +++ .../nodes/src/shared/clearance-advisories.ts | 513 ++++++ .../construction-dimension-standards.ts | 43 + .../src/shared/construction-length.test.ts | 29 + .../nodes/src/shared/construction-length.ts | 77 + .../construction-module-advisories.test.ts | 181 ++ .../shared/construction-module-advisories.ts | 326 ++++ .../dimension-completeness-audit.test.ts | 340 ++++ .../shared/dimension-completeness-audit.ts | 495 +++++ .../nodes/src/shared/dimension-string.test.ts | 61 + packages/nodes/src/shared/dimension-string.ts | 47 + .../shared/opening-documentation-fields.tsx | 199 ++ .../src/shared/opening-documentation.test.ts | 205 +++ .../nodes/src/shared/opening-documentation.ts | 413 +++++ .../opening-placement-dimensions.test.ts | 65 + .../shared/opening-placement-dimensions.ts | 11 +- .../src/slab/placement-ownership.test.ts | 19 + .../nodes/src/slab/placement-ownership.ts | 8 + packages/nodes/src/slab/tool.tsx | 19 +- packages/nodes/src/stair/definition.ts | 8 + .../nodes/src/stair/documentation.test.ts | 141 ++ packages/nodes/src/stair/documentation.ts | 349 ++++ packages/nodes/src/stair/floorplan.test.ts | 62 + packages/nodes/src/stair/floorplan.ts | 52 +- .../src/structural-grid/coordination.test.ts | 66 + .../nodes/src/structural-grid/coordination.ts | 149 ++ .../src/structural-grid/definition.test.ts | 14 + .../nodes/src/structural-grid/definition.ts | 59 + .../structural-grid/floorplan-tool.test.ts | 59 + .../src/structural-grid/floorplan-tool.tsx | 313 ++++ .../src/structural-grid/floorplan.test.ts | 55 + .../nodes/src/structural-grid/floorplan.ts | 76 + packages/nodes/src/structural-grid/index.ts | 1 + packages/nodes/src/structural-grid/schema.ts | 1 + ...ruction-dimension-reference-policy.test.ts | 335 ++++ .../src/wall/construction-dimensions.test.ts | 1326 ++++++++++++++ .../nodes/src/wall/construction-dimensions.ts | 1622 +++++++++++++++++ packages/nodes/src/wall/definition.test.ts | 30 + packages/nodes/src/wall/definition.ts | 20 +- .../src/wall/floorplan-overrides.test.ts | 48 + .../nodes/src/wall/floorplan-overrides.ts | 36 +- packages/nodes/src/wall/floorplan.test.ts | 284 +++ packages/nodes/src/wall/floorplan.ts | 611 ++++++- packages/nodes/src/wall/measurement.test.ts | 32 +- packages/nodes/src/wall/measurement.ts | 13 + packages/nodes/src/wall/panel.tsx | 166 +- packages/nodes/src/wall/schema.ts | 19 +- packages/nodes/src/wall/tool.tsx | 1 + packages/nodes/src/window/definition.ts | 13 +- packages/nodes/src/window/floorplan.ts | 14 + packages/nodes/src/window/panel.tsx | 18 + packages/nodes/src/window/schema.ts | 6 +- packages/nodes/src/zone/definition.ts | 9 +- packages/nodes/src/zone/floorplan.test.ts | 67 + packages/nodes/src/zone/floorplan.ts | 82 +- packages/nodes/src/zone/quantities-panel.tsx | 252 ++- .../src/zone/room-clear-dimensions.test.ts | 293 +++ .../nodes/src/zone/room-clear-dimensions.ts | 609 +++++++ .../nodes/src/zone/room-documentation.test.ts | 144 ++ packages/nodes/src/zone/room-documentation.ts | 125 ++ packages/viewer/src/index.ts | 6 +- packages/viewer/src/store/use-viewer.test.ts | 12 + packages/viewer/src/store/use-viewer.ts | 10 + wiki/architecture/measurements.md | 2 +- wiki/floorplan-chapter-17-assessment.md | 203 +++ wiki/floorplan-pdf-export-library-research.md | 269 +++ 181 files changed, 25002 insertions(+), 800 deletions(-) create mode 100644 apps/editor/components/floorplan-construction-preflight.tsx create mode 100644 apps/editor/public/icons/structural-grid.webp create mode 100644 design-qa.md create mode 100644 packages/core/src/schema/nodes/construction-dimension.test.ts create mode 100644 packages/core/src/schema/nodes/construction-dimension.ts create mode 100644 packages/core/src/schema/nodes/drawing-sheet.test.ts create mode 100644 packages/core/src/schema/nodes/drawing-sheet.ts create mode 100644 packages/core/src/schema/nodes/structural-grid.test.ts create mode 100644 packages/core/src/schema/nodes/structural-grid.ts create mode 100644 packages/core/src/schema/nodes/zone.test.ts create mode 100644 packages/core/src/store/use-scene-construction-dimension-migration.test.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx create mode 100644 packages/editor/src/lib/floorplan/annotation-visibility.test.ts create mode 100644 packages/editor/src/lib/floorplan/annotation-visibility.ts create mode 100644 packages/editor/src/lib/floorplan/drawing-coordination.test.ts create mode 100644 packages/editor/src/lib/floorplan/drawing-coordination.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-export.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-extension.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-extension.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts create mode 100644 packages/editor/src/store/use-drawing-view.test.ts create mode 100644 packages/editor/src/store/use-drawing-view.ts create mode 100644 packages/editor/src/store/use-floorplan-annotation-visibility.ts create mode 100644 packages/editor/src/store/use-floorplan-preflight.ts create mode 100644 packages/nodes/src/building/definition.test.ts create mode 100644 packages/nodes/src/column/floorplan.test.ts create mode 100644 packages/nodes/src/construction-dimension/definition.test.ts create mode 100644 packages/nodes/src/construction-dimension/definition.ts create mode 100644 packages/nodes/src/construction-dimension/drawing-coordination.test.ts create mode 100644 packages/nodes/src/construction-dimension/drawing-coordination.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-affordances.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-affordances.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-tool.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan-tool.tsx create mode 100644 packages/nodes/src/construction-dimension/floorplan.test.ts create mode 100644 packages/nodes/src/construction-dimension/floorplan.ts create mode 100644 packages/nodes/src/construction-dimension/geometry.ts create mode 100644 packages/nodes/src/construction-dimension/index.ts create mode 100644 packages/nodes/src/construction-dimension/panel.tsx create mode 100644 packages/nodes/src/construction-dimension/parametrics.ts create mode 100644 packages/nodes/src/construction-dimension/schema.ts create mode 100644 packages/nodes/src/drawing-sheet/definition.test.ts create mode 100644 packages/nodes/src/drawing-sheet/definition.ts create mode 100644 packages/nodes/src/drawing-sheet/index.ts create mode 100644 packages/nodes/src/drawing-sheet/schema.ts create mode 100644 packages/nodes/src/shared/clearance-advisories.test.ts create mode 100644 packages/nodes/src/shared/clearance-advisories.ts create mode 100644 packages/nodes/src/shared/construction-dimension-standards.ts create mode 100644 packages/nodes/src/shared/construction-length.test.ts create mode 100644 packages/nodes/src/shared/construction-length.ts create mode 100644 packages/nodes/src/shared/construction-module-advisories.test.ts create mode 100644 packages/nodes/src/shared/construction-module-advisories.ts create mode 100644 packages/nodes/src/shared/dimension-completeness-audit.test.ts create mode 100644 packages/nodes/src/shared/dimension-completeness-audit.ts create mode 100644 packages/nodes/src/shared/dimension-string.test.ts create mode 100644 packages/nodes/src/shared/dimension-string.ts create mode 100644 packages/nodes/src/shared/opening-documentation-fields.tsx create mode 100644 packages/nodes/src/shared/opening-documentation.test.ts create mode 100644 packages/nodes/src/shared/opening-documentation.ts create mode 100644 packages/nodes/src/shared/opening-placement-dimensions.test.ts create mode 100644 packages/nodes/src/slab/placement-ownership.test.ts create mode 100644 packages/nodes/src/slab/placement-ownership.ts create mode 100644 packages/nodes/src/stair/documentation.test.ts create mode 100644 packages/nodes/src/stair/documentation.ts create mode 100644 packages/nodes/src/stair/floorplan.test.ts create mode 100644 packages/nodes/src/structural-grid/coordination.test.ts create mode 100644 packages/nodes/src/structural-grid/coordination.ts create mode 100644 packages/nodes/src/structural-grid/definition.test.ts create mode 100644 packages/nodes/src/structural-grid/definition.ts create mode 100644 packages/nodes/src/structural-grid/floorplan-tool.test.ts create mode 100644 packages/nodes/src/structural-grid/floorplan-tool.tsx create mode 100644 packages/nodes/src/structural-grid/floorplan.test.ts create mode 100644 packages/nodes/src/structural-grid/floorplan.ts create mode 100644 packages/nodes/src/structural-grid/index.ts create mode 100644 packages/nodes/src/structural-grid/schema.ts create mode 100644 packages/nodes/src/wall/construction-dimension-reference-policy.test.ts create mode 100644 packages/nodes/src/wall/construction-dimensions.test.ts create mode 100644 packages/nodes/src/wall/construction-dimensions.ts create mode 100644 packages/nodes/src/wall/definition.test.ts create mode 100644 packages/nodes/src/wall/floorplan-overrides.test.ts create mode 100644 packages/nodes/src/wall/floorplan.test.ts create mode 100644 packages/nodes/src/zone/floorplan.test.ts create mode 100644 packages/nodes/src/zone/room-clear-dimensions.test.ts create mode 100644 packages/nodes/src/zone/room-clear-dimensions.ts create mode 100644 packages/nodes/src/zone/room-documentation.test.ts create mode 100644 packages/nodes/src/zone/room-documentation.ts create mode 100644 wiki/floorplan-chapter-17-assessment.md create mode 100644 wiki/floorplan-pdf-export-library-research.md diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e4ccf7ed..7119d312 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { BuildTab } from '@/components/build-tab' +import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight, @@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor' export default function Home() { return (
+ {PROJECT_ID === 'local-editor' && (
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index bb476542..1ec86d04 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,7 +1,12 @@ 'use client' import { nodeRegistry } from '@pascal-app/core' -import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' +import { + getFloorplanNodeExtension, + MaterialPaintPanel, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -13,24 +18,6 @@ import { } from '@/components/toolbar-tooltip' import { cn } from '@/lib/utils' -/** - * Raw structure-tool kinds the Build tab can activate. These map 1:1 to the - * editor's `StructureTool` ids. - */ -type BuildToolKind = - | 'wall' - | 'fence' - | 'slab' - | 'ceiling' - | 'roof' - | 'stair' - | 'elevator' - | 'door' - | 'window' - | 'column' - | 'shelf' - | 'spawn' - /** * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" * group tile — its own sub-grid, like Roof's "Features". @@ -53,7 +40,8 @@ type BuildType = { /** Raster asset tile (legacy Build sidebar artwork). */ iconSrc: string /** Present for structure-tool types (absent for paint mode and the MEP group). */ - kind?: BuildToolKind + kind?: string + paletteOrder?: number /** Non-placement special mode. */ mode?: 'material-paint' } @@ -67,7 +55,7 @@ type MepItem = { } // Same icons + ordering as the community Build sidebar, minus presets. -const BUILD_TYPES: BuildType[] = [ +const BASE_BUILD_TYPES: BuildType[] = [ { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, @@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [ { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, ] +function collectBuildTypes(): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + !extension?.tool || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) { + continue + } + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP // tools had in the community Build sidebar. const MEP_ITEMS: MepItem[] = [ @@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [ * Activate a raw structure draw/cursor tool. Mirrors the editor's own * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). */ -function activateBuildTool(kind: BuildToolKind | MepToolKind): void { +function activateBuildTool(kind: string): void { const ed = useEditor.getState() + const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView + if (preferredView) ed.setViewMode(preferredView) ed.setPhase('structure') ed.setStructureLayer('elements') ed.setCatalogCategory(null) @@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void { ed.setStructureLayer('elements') ed.setCatalogCategory(null) ed.setMode('build') - ed.setTool(kind as Parameters[0]) + ed.setTool(kind) } /** @@ -165,6 +186,7 @@ export function BuildTab() { const mode = useEditor((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) + const buildTypes = useMemo(collectBuildTypes, []) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays @@ -245,9 +267,9 @@ export function BuildTab() { didInitRef.current = true const ed = useEditor.getState() if (ed.mode === 'build' && ed.tool) return - const firstType = BUILD_TYPES.find((t) => t.kind) + const firstType = buildTypes.find((t) => t.kind) if (firstType) handleTypeClick(firstType) - }, [handleTypeClick]) + }, [buildTypes, handleTypeClick]) return (
@@ -256,7 +278,7 @@ export function BuildTab() { className="grid gap-1.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} > - {BUILD_TYPES.map((type) => { + {buildTypes.map((type) => { const active = isTypeActive(type) return ( diff --git a/apps/editor/components/floorplan-construction-preflight.tsx b/apps/editor/components/floorplan-construction-preflight.tsx new file mode 100644 index 00000000..19d30b85 --- /dev/null +++ b/apps/editor/components/floorplan-construction-preflight.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useFloorplanPreflight } from '@pascal-app/editor' +import { + buildClearanceAdvisories, + buildConstructionModuleAdvisories, + buildDimensionCompletenessAudit, +} from '@pascal-app/nodes' +import { useEffect, useMemo, useState } from 'react' + +export function FloorplanConstructionPreflight() { + const nodes = useDebouncedSceneNodes() + const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled) + const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled) + const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues) + + const issues = useMemo(() => { + const completeness = buildDimensionCompletenessAudit(nodes, { + includeAutomaticDimensions: true, + }).map((issue) => ({ + id: issue.id, + kind: 'dimension-completeness' as const, + severity: issue.severity, + message: issue.message, + })) + const clearance = clearanceChecksEnabled + ? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'clearance-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + const modules = moduleChecksEnabled + ? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'module-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + return [...completeness, ...clearance, ...modules] + }, [clearanceChecksEnabled, moduleChecksEnabled, nodes]) + + useEffect(() => { + setAuditIssues(issues) + return () => setAuditIssues([]) + }, [issues, setAuditIssues]) + + return null +} + +function useDebouncedSceneNodes() { + const [nodes, setNodes] = useState(() => useScene.getState().nodes) + + useEffect(() => { + let pending: ReturnType | undefined + const unsubscribe = useScene.subscribe((state) => { + if (pending) clearTimeout(pending) + pending = setTimeout(() => setNodes(state.nodes), 100) + }) + return () => { + if (pending) clearTimeout(pending) + unsubscribe() + } + }, []) + + return nodes +} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index d4443a32..77809e10 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -2,6 +2,7 @@ import { Icon as IconifyIcon } from '@iconify/react' import { + DRAWING_TYPE_OPTIONS, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -10,7 +11,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + useDrawingView, useEditor, + useFloorplanAnnotationVisibility, useSidebarStore, type ViewMode, } from '@pascal-app/editor' @@ -32,12 +35,16 @@ import { EyeOff, Footprints, Grid2X2, + Layers3, Magnet, PenLine, Ruler, + ScanLine, SlidersHorizontal, Sparkles, + SquareUserRound, SwatchBook, + Tag, } from 'lucide-react' import Image from 'next/image' import { type ReactNode, useCallback } from 'react' @@ -133,6 +140,22 @@ const SHADING_OPTIONS = [ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, ] as const +const FLOORPLAN_ANNOTATION_OPTIONS = [ + { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler }, + { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler }, + { id: 'measurements', name: 'Measurements', icon: ScanLine }, + { id: 'openingMarks', name: 'Door/window marks', icon: Tag }, + { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 }, + { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound }, + { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, +] as const + +const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ + { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' }, + { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' }, + { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' }, +] as const + function ViewModeControl() { const viewMode = useEditor((state) => state.viewMode) const setViewMode = useEditor((state) => state.setViewMode) @@ -165,6 +188,49 @@ function ViewModeControl() { ) } +function DrawingTypeControl() { + const viewMode = useEditor((state) => state.viewMode) + const drawingType = useDrawingView((state) => state.drawingType) + const setDrawingType = useDrawingView((state) => state.setDrawingType) + if (viewMode === '3d') return null + + const active = + DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0] + + return ( +
+ + + + + + + + {DRAWING_TYPE_OPTIONS.map((option) => ( + setDrawingType(option.id)}> + + {option.label} + {drawingType === option.id ? : null} + + ))} + + +
+ ) +} + function CollapseSidebarButton() { const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -278,12 +344,15 @@ const EDGE_OPTIONS = [ const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const viewMode = useEditor((state) => state.viewMode) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) const showMeasurements = useViewer((state) => state.showMeasurements) const setShowMeasurements = useViewer((state) => state.setShowMeasurements) const unit = useViewer((state) => state.unit) const setUnit = useViewer((state) => state.setUnit) + const metricNotation = useViewer((state) => state.metricNotation) + const setMetricNotation = useViewer((state) => state.setMetricNotation) const cameraMode = useViewer((state) => state.cameraMode) const setCameraMode = useViewer((state) => state.setCameraMode) const shading = useViewer((state) => state.shading) @@ -296,6 +365,14 @@ function DisplayMenu() { const setShadows = useViewer((state) => state.setShadows) const magneticSnap = useEditor((state) => state.magneticSnap) const setMagneticSnap = useEditor((state) => state.setMagneticSnap) + const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) + const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory) + const wallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.wallDimensionReference, + ) + const setWallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.setWallDimensionReference, + ) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -337,17 +414,82 @@ function DisplayMenu() { )} - keepOpen(e, () => setShowMeasurements(!showMeasurements))} - > - - Measurements - {showMeasurements ? ( - - ) : ( - - )} - + {viewMode !== '2d' ? ( + keepOpen(e, () => setShowMeasurements(!showMeasurements))} + > + + {viewMode === 'split' ? '3D measurements' : 'Measurements'} + {showMeasurements ? ( + + ) : ( + + )} + + ) : null} + {viewMode !== '3d' ? ( + <> + + + + Floor plan annotations + + + {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { + const OptionIcon = option.icon + const visible = annotationVisibility[option.id] + return ( + + keepOpen(e, () => setAnnotationCategory(option.id, !visible)) + } + > + + {option.name} + {visible ? ( + + ) : ( + + )} + + ) + })} + + + + + + Wall dimensions + + { + FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( + (option) => option.id === wallDimensionReference, + )?.name + } + + + + {FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => ( + + keepOpen(event, () => setWallDimensionReference(option.id)) + } + > +
+ {option.name} + {option.detail} +
+ {wallDimensionReference === option.id ? ( + + ) : null} +
+ ))} +
+
+ + ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> Magnetic snap @@ -377,17 +519,48 @@ function DisplayMenu() { {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} - keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} - > - - {unit === 'metric' ? 'm' : 'ft'} - - Units - - {unit === 'metric' ? 'Metric' : 'Imperial'} - - + + + + {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} + + Units + + {unit === 'imperial' + ? 'Feet & inches' + : metricNotation === 'millimeters' + ? 'Millimeters' + : 'Meters'} + + + + setMetricNotation('meters')}> + + m + + Meters + {unit === 'metric' && metricNotation === 'meters' ? ( + + ) : null} + + setMetricNotation('millimeters')}> + + mm + + Millimeters + {unit === 'metric' && metricNotation === 'millimeters' ? ( + + ) : null} + + setUnit('imperial')}> + + ft + + Feet & inches + {unit === 'imperial' ? : null} + + + @@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() { <> + ) } diff --git a/apps/editor/public/icons/structural-grid.webp b/apps/editor/public/icons/structural-grid.webp new file mode 100644 index 0000000000000000000000000000000000000000..bc5ec5053ef9a1c14cf35bc8568c0db851af41b8 GIT binary patch literal 72538 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 literal 0 HcmV?d00001 diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/bun.lock b/bun.lock index 2a57c1e6..5cbd6c7a 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -124,7 +124,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -145,35 +145,37 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", + "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", - "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", - "svg2pdf.js": "^2.7.0", + "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", + "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", + "@types/pdfkit": "^0.17.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -201,7 +203,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -215,7 +217,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.1", + "version": "0.3.2", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -225,22 +227,22 @@ "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", @@ -249,9 +251,9 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", @@ -283,7 +285,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -297,7 +299,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -538,6 +540,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -852,6 +858,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], @@ -868,9 +876,7 @@ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], - "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], - - "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -882,8 +888,6 @@ "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -978,8 +982,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], @@ -988,12 +990,20 @@ "bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="], + + "blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -1014,8 +1024,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], - "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1030,6 +1038,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1056,18 +1066,12 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -1100,9 +1104,9 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], - "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -1202,8 +1206,6 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1232,7 +1234,7 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1302,8 +1304,6 @@ "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], - "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1330,8 +1330,6 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1404,6 +1402,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -1422,8 +1422,6 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1460,6 +1458,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1580,7 +1580,7 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1598,7 +1598,7 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1606,6 +1606,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1632,8 +1634,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1660,8 +1660,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1674,9 +1672,9 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], - "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1726,10 +1724,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="], - - "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], - "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1768,12 +1762,6 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], - "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], - - "svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="], - - "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1782,8 +1770,6 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -1792,6 +1778,8 @@ "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1844,6 +1832,10 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], @@ -1860,8 +1852,6 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1962,6 +1952,8 @@ "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1978,6 +1970,8 @@ "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..8ed33c4f --- /dev/null +++ b/design-qa.md @@ -0,0 +1,64 @@ +**Comparison Target** + +- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`. +- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`. +- Viewport: 1280 × 720. +- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator. +- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions. + +**Full-view Comparison Evidence** + +- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text. +- The larger left perimeter door has no room-side width dimension in the source state. +- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading. + +**Focused-region Comparison Evidence** + +- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall. +- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`. +- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door. +- A same-state rendered focused comparison is blocked by the local loading state. + +**Findings** + +- [P0] Browser-rendered implementation evidence unavailable. + Location: local editor preview. + Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected. + Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted. + Fix: restore a working local scene preview and recapture the reported room in 2D. + +**Required Fidelity Surfaces** + +- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection. +- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection. +- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection. +- Image quality and asset fidelity: no image assets are involved. +- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included. + +**Comparison History** + +- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls. +- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls. +- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked. + +**Implementation Evidence** + +- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed. +- Nodes package build: passed. +- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`. +- Biome check: passed. +- Git diff whitespace check: passed. +- Browser console warnings/errors: none reported. + +**Implementation Checklist** + +- Restore the local editor preview. +- Reopen the reported room in 2D. +- Confirm each internal string has a visible parallel baseline, witness lines, and ticks. +- Confirm the large left-side door displays its room-side width dimension. + +**Follow-up Polish** + +- Reassess internal line contrast only after the corrected geometry can be seen in the target scene. + +final result: blocked diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 78bc2619..25aed8e7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -9,10 +9,12 @@ import type { CeilingNode, ChimneyNode, ColumnNode, + ConstructionDimensionNode, CupolaNode, DoorNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctFittingNode, DuctSegmentNode, DuctTerminalNode, @@ -42,6 +44,7 @@ import type { SpawnNode, StairNode, StairSegmentNode, + StructuralGridNode, TurbineVentNode, WallNode, WindowNode, @@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type ColumnEvent = NodeEvent +export type ConstructionDimensionEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent export type StairSegmentEvent = NodeEvent +export type StructuralGridEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent @@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent export type DownspoutEvent = NodeEvent +export type DrawingSheetEvent = NodeEvent export type DuctSegmentEvent = NodeEvent export type DuctFittingEvent = NodeEvent export type DuctTerminalEvent = NodeEvent @@ -295,10 +301,12 @@ type EditorEvents = GridEvents & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & + NodeEvents<'construction-dimension', ConstructionDimensionEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & NodeEvents<'stair-segment', StairSegmentEvent> & + NodeEvents<'structural-grid', StructuralGridEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & NodeEvents<'scan', ScanEvent> & @@ -314,6 +322,7 @@ type EditorEvents = GridEvents & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & NodeEvents<'downspout', DownspoutEvent> & + NodeEvents<'drawing-sheet', DrawingSheetEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1b8b3a47..b76a9c69 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,10 @@ export type { CeilingEvent, ChimneyEvent, ColumnEvent, + ConstructionDimensionEvent, DoorEvent, DormerEvent, + DrawingSheetEvent, ElevatorEvent, EventSuffix, FenceEvent, @@ -34,6 +36,7 @@ export type { SpawnEvent, StairEvent, StairSegmentEvent, + StructuralGridEvent, WallEvent, WindowEvent, ZoneEvent, @@ -88,6 +91,7 @@ export { closestMeasurementFeatureBinding, MEASUREMENT_PLANAR_TOLERANCE, measurementAnchorFallback, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -98,6 +102,7 @@ export { measurementPerimeter, measurementPrismVolume, measurementReferenceNodeIds, + remapMeasurementAnchors, remapMeasurementReferences, } from './lib/measurement-geometry' export { @@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise' export { getClampedWallCurveOffset, getMaxWallCurveOffset, + getWallArcData, getWallChordFrame, getWallCurveFrameAt, getWallCurveLength, diff --git a/packages/core/src/lib/measurement-geometry.test.ts b/packages/core/src/lib/measurement-geometry.test.ts index 842f184d..48a8264b 100644 --- a/packages/core/src/lib/measurement-geometry.test.ts +++ b/packages/core/src/lib/measurement-geometry.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from 'bun:test' import type { MeasurementFeature } from '../registry/types' -import type { MeasurementPoint } from '../schema/nodes/measurement' +import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement' import { areMeasurementPointsCoplanar, closestMeasurementFeatureBinding, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -12,6 +13,7 @@ import { measurementNormal, measurementPerimeter, measurementPrismVolume, + remapMeasurementAnchors, } from './measurement-geometry' const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => { @@ -134,4 +136,33 @@ describe('measurement geometry', () => { expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24) }) + + test('remaps and collects references for arbitrary anchor strings', () => { + const anchors: MeasurementAnchor[] = [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [2, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_b', featureId: 'wall:end' }, + fallback: [4, 0, 0], + }, + ] + + expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b']) + const remapped = remapMeasurementAnchors( + anchors, + new Map([ + ['wall_a', 'wall_a_copy'], + ['wall_b', 'wall_b_copy'], + ]), + ) + const first = remapped[0]! + const last = remapped[2]! + expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy') + expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy') + }) }) diff --git a/packages/core/src/lib/measurement-geometry.ts b/packages/core/src/lib/measurement-geometry.ts index f2184ad1..9dd7b7f9 100644 --- a/packages/core/src/lib/measurement-geometry.ts +++ b/packages/core/src/lib/measurement-geometry.ts @@ -1,4 +1,5 @@ import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types' +import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension' import type { MeasurementAnchor, MeasurementPayload, @@ -176,11 +177,8 @@ export function remapMeasurementReferences( measurement: MeasurementPayload, idMap: ReadonlyMap, ): MeasurementPayload { - const remap = (anchor: MeasurementAnchor): MeasurementAnchor => { - if (Array.isArray(anchor)) return anchor - const nodeId = idMap.get(anchor.reference.nodeId) - return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor - } + const remap = (anchor: MeasurementAnchor): MeasurementAnchor => + remapMeasurementAnchors([anchor], idMap)[0]! switch (measurement.kind) { case 'distance': @@ -205,11 +203,35 @@ export function remapMeasurementReferences( } } -export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { - const anchors = - measurement.kind === 'distance' || measurement.kind === 'angle' - ? measurement.points - : measurement.base +export function remapMeasurementAnchors( + anchors: readonly MeasurementAnchor[], + idMap: ReadonlyMap, +): MeasurementAnchor[] { + return anchors.map((anchor) => { + if (Array.isArray(anchor)) return anchor + const nodeId = idMap.get(anchor.reference.nodeId) + return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor + }) +} + +export function remapConstructionDimensionReferences( + dimension: ConstructionDimensionNode, + idMap: ReadonlyMap, +): ConstructionDimensionNode { + const controllingDimensionId = dimension.controllingDimensionId + ? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ?? + dimension.controllingDimensionId) + : null + return { + ...dimension, + anchors: remapMeasurementAnchors(dimension.anchors, idMap), + controllingDimensionId, + } +} + +export function measurementAnchorReferenceNodeIds( + anchors: readonly MeasurementAnchor[], +): AnyNodeId[] { const ids = new Set() for (const anchor of anchors) { if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId) @@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An return [...ids] as AnyNodeId[] } +export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { + const anchors = + measurement.kind === 'distance' || measurement.kind === 'angle' + ? measurement.points + : measurement.base + return measurementAnchorReferenceNodeIds(anchors) +} + export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint { if (points.length < 3) return [0, 0, 0] diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index a2ad983c..b1830696 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue { export function deriveZoneQuantityReport( zone: ZoneNode, - sceneNodes: Record, + sceneNodes: Readonly>, ): ZoneQuantityReport { const levelId = zone.parentId const levelNodes = levelId diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 437ee091..08d459be 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -65,6 +65,8 @@ export type { Capabilities, CapabilityCtx, CuttableConfig, + DimensionTerminator, + DimensionTextPosition, DistributionRole, DragAction, DuplicableConfig, @@ -88,6 +90,7 @@ export type { FloorplanPoint, FloorplanStyle, GeometryContext, + GroupMoveSnapArgs, HostableConfig, IconRef, Issue, diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts index ae7b8605..4f14671f 100644 --- a/packages/core/src/registry/subtree.test.ts +++ b/packages/core/src/registry/subtree.test.ts @@ -143,12 +143,121 @@ describe('cloneNodesInto', () => { ) { const anchor = clonedMeasurement.measurement.points[0] expect(Array.isArray(anchor)).toBe(false) - if (!Array.isArray(anchor)) { + if (anchor && !Array.isArray(anchor)) { expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) } } }) + test('remaps associative construction-dimension anchors inside the cloned subtree', () => { + const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' }) + const dimension = makeNode('construction-dimension_1', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [1, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:end' }, + fallback: [2, 0, 0], + }, + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'continuous', + }) + const result = cloneNodesInto([wall, dimension], { + rootId: 'wall_1' as AnyNodeId, + }) + const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension') + + expect(clonedDimension?.type).toBe('construction-dimension') + if (clonedDimension?.type === 'construction-dimension') { + const anchor = clonedDimension.anchors[0] + expect(Array.isArray(anchor)).toBe(false) + if (anchor && !Array.isArray(anchor)) { + expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + const lastAnchor = clonedDimension.anchors[2] + expect(Array.isArray(lastAnchor)).toBe(false) + if (lastAnchor && !Array.isArray(lastAnchor)) { + expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + } + }) + + test('remaps a construction dimension foundation controller when both are cloned', () => { + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + drawingType: 'foundation-plan', + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [1, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + controllingDimensionId: controller.id, + }) + + const result = cloneNodesInto([controller, dependent], { + rootId: controller.id as AnyNodeId, + }) + const clonedDependent = result.nodes.find( + (node) => node.id === result.idMap.get(dependent.id as AnyNodeId), + ) + + expect(clonedDependent?.type).toBe('construction-dimension') + if (clonedDependent?.type === 'construction-dimension') { + expect(clonedDependent.controllingDimensionId).toBe( + result.idMap.get( + controller.id as AnyNodeId, + ) as typeof clonedDependent.controllingDimensionId, + ) + } + }) + + test('regenerates drawing-sheet identities while preserving external level references', () => { + const original = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + + const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId }) + const cloned = nodes[0] + + expect(cloned?.type).toBe('drawing-sheet') + if (cloned?.type === 'drawing-sheet') { + expect(cloned.placedViews[0]?.levelId).toBe('level_existing') + expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id) + expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id) + } + }) + test('parents the cloned root under opts.parentId when supplied', () => { const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' }) const { nodes } = cloneNodesInto([orig], { diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index 894669c7..f3e3f10d 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -1,5 +1,9 @@ -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import { generateId } from '../schema/base' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' import type { AnyNode, AnyNodeId } from '../schema/types' // Generic, opinion-free primitives the host app composes to implement @@ -141,7 +145,7 @@ export function cloneNodesInto( const out: AnyNode[] = [] let root: AnyNode | null = null for (const original of nodes) { - const cloned = JSON.parse(JSON.stringify(original)) as AnyNode + let cloned = JSON.parse(JSON.stringify(original)) as AnyNode const freshId = idMap.get(original.id)! ;(cloned as { id: AnyNodeId }).id = freshId // parentId: root's parentId becomes opts.parentId (or preserved @@ -169,6 +173,12 @@ export function cloneNodesInto( if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } if (original.id === opts.rootId) { if (opts.position) { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index de9e4089..22150bcb 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -51,6 +51,8 @@ export type GeometryContext = { * `scene:` refs. */ materials?: Record + /** Opaque host/plugin context. Core never interprets extension values. */ + extensions?: Readonly> /** * Optional view state — only populated for `def.floorplan` builders. The * 2D floor-plan layer surfaces selection / hover here so kinds can vary @@ -212,12 +214,18 @@ export type FloorplanPalette = { export type FloorplanPoint = readonly [x: number, y: number] +export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot' + +export type DimensionTextPosition = 'above' | 'centered' + export type FloorplanStyle = { stroke?: string fill?: string strokeWidth?: number strokeDasharray?: string opacity?: number + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> /** * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` * as a constant screen-pixel width regardless of viewport zoom. Maps @@ -398,6 +406,8 @@ export type FloorplanGeometry = * of the floor-plan's scene rotation (default 90°). */ upright?: boolean + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Bitmap overlay — captured top-down asset thumbnail, AI-generated @@ -426,6 +436,8 @@ export type FloorplanGeometry = children: FloorplanGeometry[] /** Optional transform applied to all children. Rotation in radians. */ transform?: { translate?: FloorplanPoint; rotate?: number } + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Hatched fill overlay — same polygon shape as the kind's main fill but @@ -629,16 +641,64 @@ export type FloorplanGeometry = kind: 'dimension' start: FloorplanPoint end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint /** Outward-pointing unit normal — the dimension line offsets along this. */ offsetNormal: FloorplanPoint /** Distance (plan units) from the edge to the dimension line. */ offsetDistance: number /** How far past the offset point the extension line continues. */ extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position relative to the baseline. Defaults above the line. */ + textPosition?: DimensionTextPosition text: string /** Optional override for the line/text colour. Defaults to the palette accent. */ stroke?: string } + | { + kind: 'dimension-string' + segments: readonly { + start: FloorplanPoint + end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint + text: string + }[] + /** Outward-pointing unit normal shared by every segment in the string. */ + offsetNormal: FloorplanPoint + /** Distance (plan units) from each measured origin to its dimension line. */ + offsetDistance: number + /** How far past each offset point the extension line continues. */ + extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position shared by every segment. Defaults above the line. */ + textPosition?: DimensionTextPosition + /** Optional override for the line/text colour. Defaults to the palette accent. */ + stroke?: string + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> + } // ─── FloorplanAffordance ───────────────────────────────────────────── // @@ -853,6 +913,8 @@ export type NodeDefinition> = { schemaVersion: number schema: S category: NodeCategory + /** Opaque host/plugin contributions. Core stores but never interprets them. */ + extensions?: Readonly> surfaceRole?: SurfaceRole /** * Show a floor direction-triangle while placing/moving — the kind has a @@ -889,7 +951,6 @@ export type NodeDefinition> = { portConnectivityFollow?: boolean defaults: () => Omit, 'id' | 'type'> - migrate?: Record unknown> capabilities: Capabilities relations?: Relations diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e58db315..fd0e8f7f 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -51,8 +51,33 @@ export { ColumnStyle, ColumnSupportStyle, } from './nodes/column' +export { + CONSTRUCTION_DRAWING_TYPES, + ConstructionDimensionBaseline, + ConstructionDimensionChainMode, + ConstructionDimensionDatumPolicy, + ConstructionDimensionDrawingOverride, + ConstructionDimensionDrawingPresentation, + ConstructionDimensionImperialPrecision, + ConstructionDimensionMetricNotation, + ConstructionDimensionMode, + ConstructionDimensionNode, + ConstructionDimensionTerminator, + ConstructionDimensionTextPosition, + ConstructionDrawingType, + constructionDimensionRequiredAnchorCount, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' -export { DoorNode, DoorSegment } from './nodes/door' +export { + DoorNode, + DoorSegment, + OpeningConstructionType, + OpeningDimensionReference, +} from './nodes/door' export { DormerNode, type DormerSurfaceMaterialRole, @@ -60,6 +85,25 @@ export { getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' export { DownspoutNode } from './nodes/downspout' +export { + DrawingSheetAnnotationProfile, + DrawingSheetDocumentMarker, + DrawingSheetDocumentMarkerKind, + DrawingSheetGeneralNote, + DrawingSheetGeneralNoteSet, + DrawingSheetKeyedNote, + DrawingSheetKeyedNoteDefinition, + DrawingSheetKeyedNoteInstance, + DrawingSheetNode, + DrawingSheetOrientation, + DrawingSheetPaperSize, + DrawingSheetPlacedView, + DrawingSheetRect, + DrawingSheetScale, + DrawingSheetSchedulePlacement, + DrawingSheetTitleBlock, + remapDrawingSheetReferences, +} from './nodes/drawing-sheet' export { DuctFittingNode } from './nodes/duct-fitting' export { DuctSegmentNode } from './nodes/duct-segment' export { DuctTerminalNode } from './nodes/duct-terminal' @@ -200,9 +244,13 @@ export { StairType, } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' +export { StructuralGridNode } from './nodes/structural-grid' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { TurbineVentNode } from './nodes/turbine-vent' export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, WallBandSurfaceSlotId, WallFaceBand, WallFaceBandConfig, @@ -215,11 +263,18 @@ export { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, getEffectiveWallSurfaceMaterial, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyLayers, + getWallAssemblyThickness, getWallBandSlotId, + getWallDatumEligibleLayers, getWallFaceBandConfig, getWallFaceBandForHeight, getWallSurfaceMaterialSignature, getWallSurfaceSideFromBandSlot, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -230,11 +285,18 @@ export { WALL_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WALL_TRIM_DEFAULTS, + WallAssemblyLayerRole, + WallDimensionDatum, WallNode, WallTreatmentSide, WallTrimProfile, } from './nodes/wall' -export { WindowNode, WindowType } from './nodes/window' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, + WindowType, +} from './nodes/window' export { ZoneNode } from './nodes/zone' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index e4a4e470..c1acb785 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,13 +1,16 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { DrawingSheetNode } from './drawing-sheet' import { ElevatorNode } from './elevator' import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), + children: z + .array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id])) + .default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( @@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({ Building node - used to represent a building - position: position in site coordinate system - rotation: rotation in site coordinate system - - children: array of level nodes and building-level systems such as elevators + - children: array of level nodes, building-level systems such as elevators, and drawing sheets `, ) diff --git a/packages/core/src/schema/nodes/construction-dimension.test.ts b/packages/core/src/schema/nodes/construction-dimension.test.ts new file mode 100644 index 00000000..33cc7e56 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + ConstructionDimensionNode, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './construction-dimension' + +describe('ConstructionDimensionNode', () => { + test('creates valid free-anchor defaults', () => { + const node = ConstructionDimensionNode.parse({}) + + expect(node.type).toBe('construction-dimension') + expect(node.id).toMatch(/^construction-dimension_/) + expect(node.anchors).toEqual([ + [0, 0, 0], + [1, 0, 0], + ]) + expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] }) + expect(node.chainMode).toBe('point-to-point') + expect(node).toMatchObject({ + mode: 'linear', + featureCount: 1, + showCenterMark: true, + prefix: '', + suffix: '', + textOverride: null, + datumPolicy: 'centerline', + terminator: 'architectural-tick', + textPosition: 'above', + imperialPrecision: '1/16', + metricNotation: 'meters', + extensionStartGap: 0.075, + extensionOvershoot: 0.12, + drawingType: 'floor-plan', + drawingOverrides: [], + controllingDimensionId: null, + }) + }) + + test('accepts semantic anchors and rejects a collapsed baseline direction', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } }, + fallback: [1, 0, 0], + }, + [3, 0, 0], + ], + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ + baseline: { origin: [0, 0], direction: [0, 0] }, + }).success, + ).toBe(false) + }) + + test('accepts continuous strings with three or more anchors', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + ], + chainMode: 'continuous', + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' }) + .success, + ).toBe(false) + }) + + test('accepts curved and circular notation settings', () => { + expect( + ConstructionDimensionNode.safeParse({ + mode: 'diameter', + featureCount: 6, + prefix: 'TYP · ', + suffix: ' CLR', + }).success, + ).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false) + }) + + test('accepts dimension-standard overrides and rejects invalid drafting distances', () => { + const node = ConstructionDimensionNode.parse({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + + expect(node).toMatchObject({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false) + }) + + test('coordinates one associative dimension across persistent drawing types', () => { + const node = ConstructionDimensionNode.parse({ + drawingType: 'foundation-plan', + drawingOverrides: [ + { drawingType: 'floor-plan', presentation: 'controlled' }, + { drawingType: 'roof-plan', presentation: 'shown' }, + ], + controllingDimensionId: 'construction-dimension_foundation', + }) + + expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled') + expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit') + }) + + test('stores only drawing presentations that differ from the primary defaults', () => { + const node = ConstructionDimensionNode.parse({}) + const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown') + expect(shown).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] }, + ]) + expect( + setConstructionDimensionDrawingPresentation( + { ...node, drawingOverrides: shown }, + 'roof-plan', + 'omit', + ), + ).toEqual([]) + }) + + test('stores view-specific suppressed segment indexes without changing default presentation', () => { + const node = ConstructionDimensionNode.parse({}) + const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments( + node, + 'floor-plan', + [3, 1, 1, -1], + ) + + expect(drawingOverrides).toEqual([ + { + drawingType: 'floor-plan', + presentation: 'shown', + suppressedSegmentIndexes: [1, 3], + }, + ]) + expect( + resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan') + ?.suppressedSegmentIndexes, + ).toEqual([1, 3]) + expect( + setConstructionDimensionDrawingSuppressedSegments( + { ...node, drawingOverrides }, + 'floor-plan', + [], + ), + ).toEqual([]) + }) +}) diff --git a/packages/core/src/schema/nodes/construction-dimension.ts b/packages/core/src/schema/nodes/construction-dimension.ts new file mode 100644 index 00000000..c831b116 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.ts @@ -0,0 +1,205 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MeasurementAnchor } from './measurement' + +const FiniteCoordinate = z.number().finite() + +export const ConstructionDimensionBaseline = z + .object({ + origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]), + direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]), + }) + .superRefine((baseline, ctx) => { + if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) { + ctx.addIssue({ + code: 'custom', + path: ['direction'], + message: 'Construction dimension baseline direction must be non-zero', + }) + } + }) + +export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous']) +export const ConstructionDimensionMode = z.enum([ + 'linear', + 'radius', + 'diameter', + 'center-mark', + 'chord', + 'arc-length', + 'angular', + 'coordinate', +]) +export const ConstructionDrawingType = z.enum([ + 'floor-plan', + 'foundation-plan', + 'reflected-ceiling-plan', + 'roof-plan', + 'site-plan', +]) +export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled']) +export const ConstructionDimensionDrawingOverride = z.object({ + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, + suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]), +}) +export const ConstructionDimensionDatumPolicy = z.enum([ + 'centerline', + 'wall-face', + 'structural-face', + 'finish-face', +]) +export const ConstructionDimensionTerminator = z.enum([ + 'architectural-tick', + 'filled-arrow', + 'open-arrow', + 'dot', +]) +export const ConstructionDimensionTextPosition = z.enum(['above', 'centered']) +export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16']) +export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters']) + +export const ConstructionDimensionNode = BaseNode.extend({ + id: objectId('construction-dimension'), + type: nodeType('construction-dimension'), + anchors: z + .array(MeasurementAnchor) + .min(2) + .default([ + [0, 0, 0], + [1, 0, 0], + ]), + baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }), + chainMode: ConstructionDimensionChainMode.default('point-to-point'), + mode: ConstructionDimensionMode.default('linear'), + featureCount: z.number().int().min(1).max(999).default(1), + showCenterMark: z.boolean().default(true), + prefix: z.string().max(40).default(''), + suffix: z.string().max(40).default(''), + textOverride: z.string().trim().min(1).max(120).nullable().default(null), + datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'), + terminator: ConstructionDimensionTerminator.default('architectural-tick'), + textPosition: ConstructionDimensionTextPosition.default('above'), + imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'), + metricNotation: ConstructionDimensionMetricNotation.default('meters'), + extensionStartGap: z.number().finite().min(0).max(1).default(0.075), + extensionOvershoot: z.number().finite().min(0).max(1).default(0.12), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]), + controllingDimensionId: objectId('construction-dimension').nullable().default(null), +}).describe( + dedent` + Construction dimension node - an associative floor-plan construction dimension + - anchors: two or more free or semantic feature anchors that supply the witness origins + - baseline.origin: a point on the independently placed dimension line + - baseline.direction: the fixed plan direction used to project the witness origins + - chainMode: point-to-point for one segment or continuous for adjacent dimension strings + - mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate + - featureCount: repeated-feature multiplier used by diameter/radius and other notation + - showCenterMark: displays the resolved circle/angle center where applicable + - prefix/suffix/textOverride: document notation overrides without changing geometry + - datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides + - drawingType: the primary persistent drawing that owns the dimension + - drawingOverrides: omit, show, or foundation-control presentation per drawing type + - controllingDimensionId: foundation dimension whose associative geometry controls this dimension + `, +) + +export type ConstructionDimensionBaseline = z.infer +export type ConstructionDimensionChainMode = z.infer +export type ConstructionDimensionMode = z.infer +export type ConstructionDrawingType = z.infer +export type ConstructionDimensionDrawingPresentation = z.infer< + typeof ConstructionDimensionDrawingPresentation +> +export type ConstructionDimensionDrawingOverride = z.infer< + typeof ConstructionDimensionDrawingOverride +> +export type ConstructionDimensionDatumPolicy = z.infer +export type ConstructionDimensionTerminator = z.infer +export type ConstructionDimensionTextPosition = z.infer +export type ConstructionDimensionImperialPrecision = z.infer< + typeof ConstructionDimensionImperialPrecision +> +export type ConstructionDimensionMetricNotation = z.infer< + typeof ConstructionDimensionMetricNotation +> +export type ConstructionDimensionNode = z.infer + +export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options + +export function resolveConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingPresentation { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit') +} + +export function resolveConstructionDimensionDrawingOverride( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingOverride | null { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override ?? null +} + +export function setConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { + drawingType, + presentation, + suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [], + } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function setConstructionDimensionDrawingSuppressedSegments( + node: Pick, + drawingType: ConstructionDrawingType, + suppressedSegmentIndexes: readonly number[], +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const presentation = existing?.presentation ?? defaultPresentation + const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number { + return mode === 'arc-length' || mode === 'angular' ? 3 : 2 +} + +function isDefaultConstructionDimensionDrawingOverride( + override: ConstructionDimensionDrawingOverride, + defaultPresentation: ConstructionDimensionDrawingPresentation, +): boolean { + return ( + override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0 + ) +} + +function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] { + return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort( + (left, right) => left - right, + ) +} diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 26b4170e..ab0aaab9 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -19,6 +19,13 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer export const DoorCategory = z.enum(['interior', 'garage']) +export const OpeningConstructionType = z.enum(['framed', 'masonry']) +export const OpeningDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) export const DoorType = z.enum([ 'hinged', 'double', @@ -34,6 +41,8 @@ export const DoorType = z.enum([ export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) export type DoorCategory = z.infer +export type OpeningConstructionType = z.infer +export type OpeningDimensionReference = z.infer export type DoorType = z.infer export type DoorTrackStyle = z.infer @@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Construction-document identity. `mark` overrides the deterministic + // level fallback (101, 102, ...). Rough-opening dimensions stay optional + // because they are manufacturer/framing inputs, not safe derivations from + // the nominal modeled size. + mark: z.string().trim().max(16).optional(), + constructionType: OpeningConstructionType.default('framed'), + dimensionReference: OpeningDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Door family doorCategory: DoorCategory.default('interior'), doorType: DoorType.default('hinged'), diff --git a/packages/core/src/schema/nodes/drawing-sheet.test.ts b/packages/core/src/schema/nodes/drawing-sheet.test.ts new file mode 100644 index 00000000..216295e1 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode } from './building' +import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet' + +describe('DrawingSheetNode', () => { + test('creates persistent sheet defaults', () => { + const sheet = DrawingSheetNode.parse({}) + + expect(sheet.type).toBe('drawing-sheet') + expect(sheet.id).toMatch(/^drawing-sheet_/) + expect(sheet).toMatchObject({ + sheetNumber: 'A1.0', + sheetTitle: 'Floor Plan', + paperSize: 'arch-b', + orientation: 'landscape', + customPaperWidth: null, + customPaperHeight: null, + annotationProfile: 'architectural-default', + placedViews: [], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [], + keyedNoteInstances: [], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + titleBlock: { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', + }, + }) + }) + + test('stores placed views, notes, schedules, and title-block metadata', () => { + const sheet = DrawingSheetNode.parse({ + sheetNumber: 'A2.1', + sheetTitle: 'Enlarged Plans', + paperSize: 'custom', + customPaperWidth: 24, + customPaperHeight: 36, + placedViews: [ + { + id: 'drawing-view_main', + drawingType: 'floor-plan', + drawingNumber: '2', + title: 'Main Floor Plan', + levelId: 'level_main', + scale: '1/4"=1\'-0"', + viewport: { x: 1, y: 1, width: 12, height: 8 }, + }, + ], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }], + }, + ], + generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }], + keyedNoteDefinitions: [ + { id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' }, + ], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_patch-slab-1', + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }, + { + id: 'keyed-note-instance_patch-slab-2', + definitionId: 'keyed-note_patch-slab', + position: [5, 4], + }, + ], + keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }], + documentMarkers: [ + { + id: 'sheet-marker_wall-a', + kind: 'wall-tag', + label: 'W1', + placedViewId: 'drawing-view_main', + position: [2, 3], + }, + { + id: 'sheet-marker_revision-a', + kind: 'revision-cloud', + label: '1', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }, + ], + schedules: [ + { + id: 'sheet-schedule_room', + scheduleType: 'room', + title: 'Room Schedule', + region: { x: 15, y: 1, width: 6, height: 5 }, + }, + ], + titleBlock: { + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + }, + }) + + expect(sheet.placedViews[0]).toMatchObject({ + drawingType: 'floor-plan', + levelId: 'level_main', + annotationProfile: 'architectural-default', + showNorthArrow: true, + showGraphicScale: true, + }) + expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.') + expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project']) + expect(sheet.generalNoteSets[0]).toMatchObject({ + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ text: 'COORDINATE WITH OWNER.' }], + }) + expect(sheet.keyedNoteLegend[0]).toEqual({ + key: 'A', + text: 'ALIGN WITH EXISTING WALL.', + }) + expect(sheet.keyedNoteDefinitions[0]).toEqual({ + id: 'keyed-note_patch-slab', + key: 'A', + text: 'PATCH EXISTING SLAB.', + }) + expect(sheet.keyedNoteInstances).toHaveLength(2) + expect(sheet.keyedNoteInstances[0]).toMatchObject({ + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }) + expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull() + expect(sheet.documentMarkers).toHaveLength(2) + expect(sheet.documentMarkers[0]).toMatchObject({ + kind: 'wall-tag', + label: 'W1', + position: [2, 3], + }) + expect(sheet.documentMarkers[1]).toMatchObject({ + kind: 'revision-cloud', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }) + expect(sheet.schedules[0]?.title).toBe('Room Schedule') + expect(sheet.titleBlock).toMatchObject({ + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + drawnBy: '', + }) + }) + + test('can live under a building instead of a level', () => { + const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' }) + + expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([ + 'level_main', + sheet.id, + ]) + }) + + test('remaps sheet-local identities and their references together', () => { + const sheet = DrawingSheetNode.parse({ + placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }], + }, + ], + generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a1', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + }, + ], + documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }], + schedules: [{ id: 'sheet-schedule_a' }], + }) + const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']])) + + expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id) + expect(remapped.placedViews[0]?.levelId).toBe('level_cloned') + expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id) + expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe( + sheet.generalNoteSets[0]?.notes[0]?.id, + ) + expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id) + expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id) + expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id) + expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id) + expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id) + }) +}) diff --git a/packages/core/src/schema/nodes/drawing-sheet.ts b/packages/core/src/schema/nodes/drawing-sheet.ts new file mode 100644 index 00000000..372addf0 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.ts @@ -0,0 +1,263 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, generateId, nodeType, objectId } from '../base' +import { ConstructionDrawingType } from './construction-dimension' + +const PositiveFinite = z.number().finite().positive() +const SheetCoordinate = z.number().finite().min(0) + +export const DrawingSheetPaperSize = z.enum([ + 'letter', + 'tabloid', + 'arch-a', + 'arch-b', + 'arch-c', + 'a4', + 'a3', + 'custom', +]) +export const DrawingSheetOrientation = z.enum(['portrait', 'landscape']) +export const DrawingSheetScale = z.enum([ + '1:20', + '1:25', + '1:50', + '1:75', + '1:100', + '1/8"=1\'-0"', + '1/4"=1\'-0"', + '1/2"=1\'-0"', + '1"=1\'-0"', +]) +export const DrawingSheetAnnotationProfile = z.enum([ + 'architectural-default', + 'presentation', + 'permit', +]) + +export const DrawingSheetRect = z.object({ + x: SheetCoordinate.default(0), + y: SheetCoordinate.default(0), + width: PositiveFinite.default(1), + height: PositiveFinite.default(1), +}) + +export const DrawingSheetPlacedView = z.object({ + id: objectId('drawing-view'), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingNumber: z.string().trim().min(1).max(24).default('1'), + title: z.string().trim().min(1).max(80).default('Floor Plan'), + levelId: objectId('level').nullable().default(null), + scale: DrawingSheetScale.default('1/4"=1\'-0"'), + viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + showNorthArrow: z.boolean().default(true), + showGraphicScale: z.boolean().default(true), +}) + +export const DrawingSheetGeneralNote = z.object({ + id: objectId('sheet-note'), + number: z.number().int().positive().default(1), + text: z.string().trim().min(1).max(500).default('GENERAL NOTE'), +}) + +export const DrawingSheetGeneralNoteSet = z.object({ + id: objectId('sheet-note-set'), + name: z.string().trim().min(1).max(80).default('General Notes'), + notes: z.array(DrawingSheetGeneralNote).max(200).default([]), +}) + +export const DrawingSheetKeyedNote = z.object({ + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteDefinition = z.object({ + id: objectId('keyed-note'), + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteInstance = z.object({ + id: objectId('keyed-note-instance'), + definitionId: DrawingSheetKeyedNoteDefinition.shape.id, + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), +}) + +export const DrawingSheetDocumentMarkerKind = z.enum([ + 'wall-tag', + 'glazing-tag', + 'assembly-tag', + 'section-callout', + 'elevation-callout', + 'detail-reference', + 'delta-marker', + 'revision-cloud', +]) + +export const DrawingSheetDocumentMarker = z.object({ + id: objectId('sheet-marker'), + kind: DrawingSheetDocumentMarkerKind.default('detail-reference'), + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + label: z.string().trim().min(1).max(32).default('1'), + title: z.string().trim().max(120).default(''), + sheetReference: z.string().trim().max(24).default(''), + drawingReference: z.string().trim().max(24).default(''), + revisionId: z.string().trim().max(16).default(''), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), + endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null), + points: z + .array(z.tuple([SheetCoordinate, SheetCoordinate])) + .max(64) + .default([]), +}) + +export const DrawingSheetSchedulePlacement = z.object({ + id: objectId('sheet-schedule'), + scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'), + title: z.string().trim().min(1).max(80).default('Room Schedule'), + region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }), +}) + +export const DrawingSheetTitleBlock = z.object({ + projectName: z.string().trim().max(120).default(''), + projectNumber: z.string().trim().max(40).default(''), + clientName: z.string().trim().max(120).default(''), + drawnBy: z.string().trim().max(40).default(''), + checkedBy: z.string().trim().max(40).default(''), + issueDate: z.string().trim().max(40).default(''), + revision: z.string().trim().max(20).default(''), +}) + +const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', +} + +export const DrawingSheetNode = BaseNode.extend({ + id: objectId('drawing-sheet'), + type: nodeType('drawing-sheet'), + sheetNumber: z.string().trim().min(1).max(24).default('A1.0'), + sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'), + paperSize: DrawingSheetPaperSize.default('arch-b'), + orientation: DrawingSheetOrientation.default('landscape'), + customPaperWidth: PositiveFinite.nullable().default(null), + customPaperHeight: PositiveFinite.nullable().default(null), + placedViews: z.array(DrawingSheetPlacedView).max(32).default([]), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]), + generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]), + generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]), + keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]), + keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]), + keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]), + documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]), + schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]), + titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK), +}).describe( + dedent` + Drawing sheet node - persistent construction-document sheet metadata + - sheetNumber/sheetTitle: sheet identity in the drawing set + - paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition + - placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles + - generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes + - keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries + - documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds + - schedules/titleBlock: sheet-level documentation content and title-block metadata + `, +) + +export type DrawingSheetPaperSize = z.infer +export type DrawingSheetOrientation = z.infer +export type DrawingSheetScale = z.infer +export type DrawingSheetAnnotationProfile = z.infer +export type DrawingSheetRect = z.infer +export type DrawingSheetPlacedView = z.infer +export type DrawingSheetGeneralNote = z.infer +export type DrawingSheetGeneralNoteSet = z.infer +export type DrawingSheetKeyedNote = z.infer +export type DrawingSheetKeyedNoteDefinition = z.infer +export type DrawingSheetKeyedNoteInstance = z.infer +export type DrawingSheetDocumentMarker = z.infer +export type DrawingSheetDocumentMarkerKind = z.infer +export type DrawingSheetSchedulePlacement = z.infer +export type DrawingSheetTitleBlock = z.infer +export type DrawingSheetNode = z.infer + +/** + * Rewrites every scene and sheet-local identity carried by a drawing sheet. + * External scene references are preserved when they are not present in + * `sceneIdMap`, which keeps a duplicated sheet attached to its existing level. + */ +export function remapDrawingSheetReferences( + sheet: DrawingSheetNode, + sceneIdMap: ReadonlyMap, +): DrawingSheetNode { + const placedViewIds = new Map( + sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const), + ) + const noteSetIds = new Map( + sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const), + ) + const noteIds = new Map( + [...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map( + (note) => [note.id, generateId('sheet-note')] as const, + ), + ) + const keyedDefinitionIds = new Map( + sheet.keyedNoteDefinitions.map( + (definition) => [definition.id, generateId('keyed-note')] as const, + ), + ) + + return { + ...sheet, + placedViews: sheet.placedViews.map((view) => ({ + ...view, + id: placedViewIds.get(view.id)!, + levelId: view.levelId + ? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId) + : null, + })), + generalNoteSetIds: sheet.generalNoteSetIds.map( + (id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number], + ), + generalNoteSets: sheet.generalNoteSets.map((set) => ({ + ...set, + id: noteSetIds.get(set.id)!, + notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + })), + generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({ + ...definition, + id: keyedDefinitionIds.get(definition.id)!, + })), + keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({ + ...instance, + id: generateId('keyed-note-instance'), + definitionId: (keyedDefinitionIds.get(instance.definitionId) ?? + instance.definitionId) as typeof instance.definitionId, + placedViewId: instance.placedViewId + ? ((placedViewIds.get(instance.placedViewId) ?? + instance.placedViewId) as typeof instance.placedViewId) + : null, + })), + documentMarkers: sheet.documentMarkers.map((marker) => ({ + ...marker, + id: generateId('sheet-marker'), + placedViewId: marker.placedViewId + ? ((placedViewIds.get(marker.placedViewId) ?? + marker.placedViewId) as typeof marker.placedViewId) + : null, + })), + schedules: sheet.schedules.map((schedule) => ({ + ...schedule, + id: generateId('sheet-schedule'), + })), + } +} diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index a566f593..114c9514 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' import { ColumnNode } from './column' +import { ConstructionDimensionNode } from './construction-dimension' import { DuctFittingNode } from './duct-fitting' import { DuctSegmentNode } from './duct-segment' import { DuctTerminalNode } from './duct-terminal' @@ -22,6 +23,7 @@ import { ShelfNode } from './shelf' import { SlabNode } from './slab' import { SpawnNode } from './spawn' import { StairNode } from './stair' +import { StructuralGridNode } from './structural-grid' import { WallNode } from './wall' import { ZoneNode } from './zone' @@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({ WallNode.shape.id, FenceNode.shape.id, ColumnNode.shape.id, + ConstructionDimensionNode.shape.id, + StructuralGridNode.shape.id, ItemNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, diff --git a/packages/core/src/schema/nodes/structural-grid.test.ts b/packages/core/src/schema/nodes/structural-grid.test.ts new file mode 100644 index 00000000..1b6afcfc --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { LevelNode } from './level' +import { StructuralGridNode } from './structural-grid' + +describe('StructuralGridNode', () => { + test('fills stable construction-document defaults', () => { + const grid = StructuralGridNode.parse({}) + + expect(grid.id).toStartWith('structural-grid_') + expect(grid).toMatchObject({ + type: 'structural-grid', + start: [0, 0], + end: [0, 5], + label: '1', + showStartBubble: true, + showEndBubble: true, + }) + }) + + test('is accepted as a level child', () => { + expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([ + 'structural-grid_axis-1', + ]) + }) + + test('rejects empty labels and zero-length concerns stay in authoring', () => { + expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow() + expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({ + start: [1, 1], + end: [1, 1], + }) + }) +}) diff --git a/packages/core/src/schema/nodes/structural-grid.ts b/packages/core/src/schema/nodes/structural-grid.ts new file mode 100644 index 00000000..09f071a6 --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.ts @@ -0,0 +1,22 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const StructuralGridNode = BaseNode.extend({ + id: objectId('structural-grid'), + type: nodeType('structural-grid'), + start: z.tuple([z.number(), z.number()]).default([0, 0]), + end: z.tuple([z.number(), z.number()]).default([0, 5]), + label: z.string().trim().min(1).max(12).default('1'), + showStartBubble: z.boolean().default(true), + showEndBubble: z.boolean().default(true), +}).describe( + dedent` + Structural grid node - a persistent floor-plan datum axis with identification bubbles + - start/end: level-local plan coordinates defining the grid axis extent + - label: axis identifier, commonly numeric in one direction and alphabetic in the other + - showStartBubble/showEndBubble: independently control the two endpoint identifiers + `, +) + +export type StructuralGridNode = z.infer diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 5364e083..164a5c37 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test' import { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyThickness, + getWallDatumEligibleLayers, getWallFaceBandConfig, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -13,7 +19,8 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, - type WallNode, + WallNode, + type WallNode as WallNodeType, WallTrimConfig, } from './wall' @@ -99,7 +106,7 @@ describe('wall face bands', () => { lowerInterior: 'library:stale-lower', middleExterior: 'library:stale-middle', }, - } as Pick) + } as Pick) expect(patch.faceBands).toEqual({ enabled: true, @@ -135,7 +142,7 @@ describe('wall face bands', () => { exterior: 'scene:exterior-finish', topInterior: 'library:stale-top', }, - } as Pick, + } as Pick, 3, ) @@ -159,7 +166,7 @@ describe('wall face bands', () => { middleInterior: 'library:stale-middle', upperExterior: 'library:stale-upper', }, - } as Pick) + } as Pick) expect(patch.slots).toEqual({ lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, @@ -187,7 +194,7 @@ describe('wall face bands', () => { lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }, - } as Pick, + } as Pick, 3, ) @@ -219,7 +226,7 @@ describe('wall face bands', () => { middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, upperExterior: 'library:painted-top-exterior', }, - } as Pick, + } as Pick, 4, ) @@ -260,3 +267,206 @@ describe('wall trim profiles', () => { expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) }) }) + +describe('wall assembly layers', () => { + test('defaults to legacy thickness when no assembly layers are modeled', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(wall.assemblyLayers).toEqual([]) + expect(getWallAssemblyThickness(wall)).toBe(0.14) + }) + + test('stores role, side, thickness, material reference, and datum eligibility', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face', 'finish-face'], + }, + ], + }) + + expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196) + expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([ + 'interior-gwb', + 'brick-veneer', + ]) + expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([ + { id: 'stud-core', role: 'structure', side: 'core' }, + ]) + expect(getWallAssemblyFaceOffsets(wall)).toEqual({ + interior: -0.061, + exterior: 0.135, + }) + }) + + test('resolves stable datum references for legacy single-thickness walls', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(resolveWallAssemblyDatumReferences(wall)).toEqual([ + { id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 }, + { + id: 'wall:structural-face:interior', + datum: 'structural-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:structural-face:exterior', + datum: 'structural-face', + side: 'exterior', + offset: 0.07, + }, + { + id: 'wall:finish-face:interior', + datum: 'finish-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:finish-face:exterior', + datum: 'finish-face', + side: 'exterior', + offset: 0.07, + }, + ]) + }) + + test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-sheathing', + role: 'exterior-sheathing', + side: 'exterior', + thickness: 0.012, + materialRef: 'library:sheathing', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face'], + }, + ], + }) + + const references = resolveWallAssemblyDatumReferences(wall) + + expect(references).toContainEqual({ + id: 'wall:centerline:center', + datum: 'centerline', + side: 'center', + offset: 0, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:interior:stud-core', + datum: 'structural-face', + side: 'interior', + layerId: 'stud-core', + offset: -0.045, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:exterior:stud-core', + datum: 'structural-face', + side: 'exterior', + layerId: 'stud-core', + offset: 0.045, + }) + expect(references).toContainEqual({ + id: 'wall:finish-face:interior:interior-gwb', + datum: 'finish-face', + side: 'interior', + layerId: 'interior-gwb', + offset: -0.061, + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + ), + ).toMatchObject({ + datum: 'finish-face', + side: 'exterior', + layerId: 'exterior-sheathing', + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + )?.offset, + ).toBeCloseTo(0.057) + + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + }) + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer') + ?.offset, + ).toBeCloseTo(0.147) + expect( + resolveWallAssemblyDatumReference( + wall, + getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'), + ), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + offset: 0.147, + }) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a01a98ad..82a81429 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = { export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS +export const WallAssemblyLayerRole = z.enum([ + 'structure', + 'interior-finish', + 'exterior-sheathing', + 'exterior-finish', + 'masonry-veneer', + 'air-space', + 'concrete-block', + 'structural-masonry', + 'solid-concrete', + 'furring', +]) +export type WallAssemblyLayerRole = z.infer + +export const WallDimensionDatum = z.enum([ + 'centerline', + 'structural-face', + 'finish-face', + 'veneer-face', +]) +export type WallDimensionDatum = z.infer + +export const WallAssemblyLayer = z.object({ + id: z.string().trim().min(1).max(80).default('structure'), + role: WallAssemblyLayerRole.default('structure'), + side: z.enum(['core', 'interior', 'exterior']).default('core'), + thickness: z.number().finite().positive().default(0.1), + materialRef: z.string().trim().max(120).default(''), + datumEligible: z.array(WallDimensionDatum).max(8).default([]), +}) +export type WallAssemblyLayer = z.infer + +export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior' + +export type WallAssemblyDatumReference = { + id: string + datum: WallDimensionDatum + side: WallAssemblyDatumSide + layerId?: string + offset: number +} + export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), @@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({ // in a follow-up once migrated scenes are the norm. slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), + assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]), height: z.number().optional(), curveOffset: z.number().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. @@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({ dedent` Wall node - used to represent a wall in the building - thickness: thickness in meters + - assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility - height: height in meters - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system @@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId = | 'upperExterior' | 'topExterior' +export function getWallAssemblyLayers(wall: Pick): WallAssemblyLayer[] { + return wall.assemblyLayers ?? [] +} + +export function getWallAssemblyThickness( + wall: Pick, +): number { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return wall.thickness ?? 0.1 + return layers.reduce((sum, layer) => sum + layer.thickness, 0) +} + +export function getWallAssemblyFaceOffsets(wall: Pick): { + interior: number + exterior: number +} { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return { interior: -halfThickness, exterior: halfThickness } + } + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const interiorFinishThickness = layers + .filter((layer) => layer.side === 'interior') + .reduce((sum, layer) => sum + layer.thickness, 0) + const exteriorFinishThickness = layers + .filter((layer) => layer.side === 'exterior') + .reduce((sum, layer) => sum + layer.thickness, 0) + + return { + interior: -coreThickness / 2 - interiorFinishThickness, + exterior: coreThickness / 2 + exteriorFinishThickness, + } +} + +export function getWallDatumEligibleLayers( + wall: Pick, + datum: WallDimensionDatum, +): WallAssemblyLayer[] { + return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum)) +} + +export function getWallAssemblyDatumReferenceId( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + layerId?: string, +): string { + return ['wall', datum, side, layerId].filter(Boolean).join(':') +} + +type WallAssemblyLayerSpan = { + layer: WallAssemblyLayer + interiorOffset: number + exteriorOffset: number +} + +function getWallAssemblyLayerSpans( + wall: Pick, +): WallAssemblyLayerSpan[] { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return [] + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const coreInteriorFace = -coreThickness / 2 + const coreExteriorFace = coreThickness / 2 + const spans: WallAssemblyLayerSpan[] = [] + + let coreOffset = coreInteriorFace + for (const layer of coreLayers) { + const interiorOffset = coreOffset + const exteriorOffset = coreOffset + layer.thickness + spans.push({ layer, interiorOffset, exteriorOffset }) + coreOffset = exteriorOffset + } + + let interiorOffset = coreInteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'interior')) { + const exteriorOffset = interiorOffset + const nextInteriorOffset = exteriorOffset - layer.thickness + spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset }) + interiorOffset = nextInteriorOffset + } + + let exteriorOffset = coreExteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) { + const interiorFaceOffset = exteriorOffset + const nextExteriorOffset = interiorFaceOffset + layer.thickness + spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset }) + exteriorOffset = nextExteriorOffset + } + + return spans +} + +function createWallAssemblyDatumReference( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + offset: number, + layerId?: string, +): WallAssemblyDatumReference { + return { + id: getWallAssemblyDatumReferenceId(datum, side, layerId), + datum, + side, + ...(layerId ? { layerId } : {}), + offset, + } +} + +export function resolveWallAssemblyDatumReferences( + wall: Pick, +): WallAssemblyDatumReference[] { + const layers = wall.assemblyLayers ?? [] + const references: WallAssemblyDatumReference[] = [ + createWallAssemblyDatumReference('centerline', 'center', 0), + ] + + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return [ + ...references, + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ] + } + + const spans = getWallAssemblyLayerSpans(wall) + + for (const span of spans) { + if (span.layer.datumEligible.includes('structural-face')) { + if (span.layer.side === 'core') { + references.push( + createWallAssemblyDatumReference( + 'structural-face', + 'interior', + span.interiorOffset, + span.layer.id, + ), + createWallAssemblyDatumReference( + 'structural-face', + 'exterior', + span.exteriorOffset, + span.layer.id, + ), + ) + } else { + const side = span.layer.side + references.push( + createWallAssemblyDatumReference( + 'structural-face', + side, + side === 'interior' ? span.interiorOffset : span.exteriorOffset, + span.layer.id, + ), + ) + } + } + + if (span.layer.datumEligible.includes('finish-face')) { + const side = span.layer.side === 'core' ? 'center' : span.layer.side + const offset = + span.layer.side === 'interior' + ? span.interiorOffset + : span.layer.side === 'exterior' + ? span.exteriorOffset + : (span.interiorOffset + span.exteriorOffset) / 2 + references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id)) + } + + if (span.layer.datumEligible.includes('veneer-face')) { + const side = span.layer.side === 'interior' ? 'interior' : 'exterior' + const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset + references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id)) + } + } + + if (!references.some((reference) => reference.datum === 'structural-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + ) + } + + if (!references.some((reference) => reference.datum === 'finish-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ) + } + + return references +} + +export function resolveWallAssemblyDatumReference( + wall: Pick, + referenceId: string, +): WallAssemblyDatumReference | null { + return ( + resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ?? + null + ) +} + // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // slot declaration (nodes) and the material resolver (viewer) share one value. diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 08c5ce4e..53b7fec5 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -17,6 +17,16 @@ export const WindowType = z.enum([ ]) export type WindowType = z.infer +export const WindowConstructionType = z.enum(['framed', 'masonry']) +export const WindowDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) +export type WindowConstructionType = z.infer +export type WindowDimensionReference = z.infer + export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), @@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({ width: z.number().default(1.5), height: z.number().default(1.5), + // Construction-document identity and optional manufacturer rough opening. + // Legacy scenes omit these fields and continue to parse unchanged. + mark: z.string().trim().max(16).optional(), + constructionType: WindowConstructionType.default('framed'), + dimensionReference: WindowDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Opening mode - when set to "opening", the window is only a shaped cutout openingKind: z.enum(['window', 'opening']).default('window'), diff --git a/packages/core/src/schema/nodes/zone.test.ts b/packages/core/src/schema/nodes/zone.test.ts new file mode 100644 index 00000000..95a80512 --- /dev/null +++ b/packages/core/src/schema/nodes/zone.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { ZoneNode } from './zone' + +describe('ZoneNode architectural room data', () => { + test('keeps legacy zones generic while supplying room-safe defaults', () => { + const zone = ZoneNode.parse({ + id: 'zone_legacy', + name: 'Landscape area', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + }) + + expect(zone).toMatchObject({ + spaceRole: 'generic', + roomNumber: '', + enclosureStatus: 'auto', + floorFinish: '', + wallFinish: '', + ceilingFinish: '', + ceilingHeight: 2.7, + occupancy: '', + clearDimensionPolicy: 'none', + }) + }) + + test('persists a complete architectural room profile', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + spaceRole: 'room', + roomNumber: '101', + enclosureStatus: 'enclosed', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 3, + occupancy: 'Business', + clearDimensionPolicy: 'inside-faces', + }) + + expect(room.spaceRole).toBe('room') + expect(room.roomNumber).toBe('101') + expect(room.clearDimensionPolicy).toBe('inside-faces') + }) +}) diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts index 55a17957..227f5a4d 100644 --- a/packages/core/src/schema/nodes/zone.ts +++ b/packages/core/src/schema/nodes/zone.ts @@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({ // stored polygon remains a fallback for missing or temporarily open walls. autoFromWalls: z.boolean().default(false), boundaryWallIds: z.array(objectId('wall')).default([]), + // Generic zones remain available for sites and analysis. Architectural + // room documentation is opt-in so legacy zone behavior is unchanged. + spaceRole: z.enum(['generic', 'room']).default('generic'), + roomNumber: z.string().trim().max(32).default(''), + enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'), + floorFinish: z.string().trim().max(120).default(''), + wallFinish: z.string().trim().max(120).default(''), + ceilingFinish: z.string().trim().max(120).default(''), + ceilingHeight: z.number().min(0.1).default(2.7), + occupancy: z.string().trim().max(80).default(''), + clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'), // Visual styling color: z.string().default('#3b82f6'), // Default blue metadata: z.json().optional().default({}), @@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({ - polygon: array of [x, z] points defining the zone boundary - autoFromWalls: whether the boundary follows an enclosed wall loop - boundaryWallIds: wall ids that prove the procedural enclosure + - spaceRole: generic site/analysis zone or architectural room + - roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata + - enclosureStatus: auto-detected, explicitly enclosed, or open + - clearDimensionPolicy: optional room clear-dimension datum preference - color: hex color for visual styling - metadata: zone metadata (optional) `, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index da37c6f2..2c980bfc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' +import { ConstructionDimensionNode } from './nodes/construction-dimension' import { CupolaNode } from './nodes/cupola' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' +import { DrawingSheetNode } from './nodes/drawing-sheet' import { DuctFittingNode } from './nodes/duct-fitting' import { DuctSegmentNode } from './nodes/duct-segment' import { DuctTerminalNode } from './nodes/duct-terminal' @@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel' import { SpawnNode } from './nodes/spawn' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' +import { StructuralGridNode } from './nodes/structural-grid' import { TurbineVentNode } from './nodes/turbine-vent' import { WallNode } from './nodes/wall' import { WindowNode } from './nodes/window' @@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ElevatorNode, LevelNode, ColumnNode, + ConstructionDimensionNode, + StructuralGridNode, WallNode, FenceNode, CabinetNode, @@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SkylightNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctSegmentNode, DuctFittingNode, DuctTerminalNode, diff --git a/packages/core/src/store/use-scene-construction-dimension-migration.test.ts b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts new file mode 100644 index 00000000..6becb6d1 --- /dev/null +++ b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene construction-dimension migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('normalizes the legacy reference presentation before parsing', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['construction-dimension_test'], + level: 0, + }, + 'construction-dimension_test': { + object: 'node', + id: 'construction-dimension_test', + type: 'construction-dimension', + parentId: 'level_test', + visible: true, + metadata: {}, + reference: true, + referenceStyle: 'suffix', + drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }], + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode & + Record + expect(dimension.reference).toBeUndefined() + expect(dimension.referenceStyle).toBeUndefined() + expect(dimension.drawingOverrides).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown' }, + ]) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index be23e8e5..6036dff8 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +function migrateConstructionDimension(node: Record) { + const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : [] + const hasLegacyDrawingOverride = drawingOverrides.some( + (entry) => + entry && + typeof entry === 'object' && + !Array.isArray(entry) && + entry.presentation === 'reference', + ) + if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node + + const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node + return { + ...dimension, + drawingOverrides: drawingOverrides.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry + return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry + }), + } +} + // Walls whose top lands within this of the storey plane become plane-bound; // ceilings whose stored height lands within this of their clamp bound become // follows-mode (step 3f) — same census-backed threshold for both. @@ -687,6 +708,10 @@ function migrateNodes(nodes: Record): { } } + if (node.type === 'construction-dimension') { + patchedNodes[id] = migrateConstructionDimension(node) + } + if (node.type === 'stair') { const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 743f14ca..5107922c 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) { } } -function getWallArcData(wall: WallCurveLike) { +export function getWallArcData(wall: WallCurveLike) { const chord = getWallChordFrame(wall) const sagitta = getClampedWallCurveOffset(wall) diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index 637b1840..d73bd8d4 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN visible: true, parentId: 'level_test', children: [], + assemblyLayers: [], start, end, thickness: 0.1, diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 93fd3c69..02aea9c5 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -77,6 +77,119 @@ describe('forkSceneGraph', () => { }) }) +describe('construction-dimension clone references', () => { + function sceneWithControlledDimensions(): SceneGraph { + const site = makeNode('site_1', 'site', { children: ['level_1'] }) + const level = makeNode('level_1', 'level', { + parentId: 'site_1', + children: ['construction-dimension_foundation', 'construction-dimension_floor'], + }) + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + name: 'Foundation controller', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: null, + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + name: 'Floor dependent', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: controller.id, + }) + return { + nodes: { + [site.id]: site, + [level.id]: level, + [controller.id]: controller, + [dependent.id]: dependent, + }, + rootNodeIds: [site.id], + } + } + + test('remaps controller IDs in whole-scene clones', () => { + const cloned = cloneSceneGraph(sceneWithControlledDimensions()) + const dimensions = Object.values(cloned.nodes).filter( + (node) => node.type === 'construction-dimension', + ) + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) + + test('remaps controller IDs in level-subtree clones', () => { + const scene = sceneWithControlledDimensions() + const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId) + const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension') + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) +}) + +describe('drawing-sheet clone references', () => { + test('remaps placed levels and nested sheet identities in whole-scene clones', () => { + const level = makeNode('level_main', 'level') + const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: level.id }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + const cloned = cloneSceneGraph({ + nodes: { [level.id]: level, [sheet.id]: sheet }, + rootNodeIds: [level.id, sheet.id] as AnyNodeId[], + }) + const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level') + const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet') + + expect(clonedLevel).toBeDefined() + expect(clonedSheet?.type).toBe('drawing-sheet') + if (clonedLevel && clonedSheet?.type === 'drawing-sheet') { + expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id) + expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe( + clonedSheet.keyedNoteDefinitions[0]?.id, + ) + expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id) + } + }) +}) + describe('supportSlabId remap', () => { test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 8235c012..5c6ec8bc 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,8 +1,12 @@ import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' import type { Collection, CollectionId } from '../schema/collections' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' export type SceneGraph = { nodes: Record @@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { for (const [oldId, node] of Object.entries(nodes)) { const newId = idMap.get(oldId)! as AnyNodeId - const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode + let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode // Remap parentId if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { @@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } + if (clonedNode.type === 'construction-dimension') { + clonedNode = remapConstructionDimensionReferences(clonedNode, idMap) + } + if (clonedNode.type === 'drawing-sheet') { + clonedNode = remapDrawingSheetReferences(clonedNode, idMap) + } clonedNodes[newId] = clonedNode } @@ -221,7 +231,7 @@ export function cloneLevelSubtree( const newId = idMap.get(oldId)! as AnyNodeId // JSON roundtrip: safely strips functions, Object3D, circular refs, etc. - const cloned = JSON.parse(JSON.stringify(node)) as AnyNode + let cloned = JSON.parse(JSON.stringify(node)) as AnyNode ;(cloned as Record).id = newId // Remap parentId — but only for descendants, not the level node itself @@ -274,6 +284,12 @@ export function cloneLevelSubtree( if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } clonedNodes.push(cloned) } diff --git a/packages/editor/package.json b/packages/editor/package.json index bec9357d..73d5899f 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -40,16 +40,16 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", + "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", - "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", - "svg2pdf.js": "^2.7.0", + "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", @@ -59,8 +59,10 @@ "@pascal-app/core": "^0.9.2", "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", + "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", + "@types/pdfkit": "^0.17.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx index b98d2394..8478747b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context' export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { const guides = useAlignmentGuides((s) => s.guides) const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const ctx = useFloorplanRender() if (guides.length === 0) return null @@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid // offset along X. const pillX = axis === 'x' ? midX + pillOffset : midX const pillZ = axis === 'z' ? midZ + pillOffset : midZ - const distLabel = formatMeasurement(distMeters, unit) + const distLabel = formatMeasurement(distMeters, unit, metricNotation) const charWidth = pillFontSize * 0.55 const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillHeight = pillFontSize + pillPadY * 2 diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx index 7e690b82..70840020 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() { const draftLevelId = useMeasurementDraft((state) => state.levelId) const activeLevelId = useViewer((state) => state.selection.levelId) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) useEffect(() => { if (active) { @@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() { angle: Math.atan2(end[2] - start[2], end[0] - start[0]), point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], screenUpright: false, - text: formatLinearMeasurement(measurementDistance(start, end), unit), + text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation), } } else if (kind === 'angle' && livePoints.length >= 3) { const anglePoints = livePoints.slice(0, 3) as [ @@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() { text: kind === 'area' ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}` - : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`, + : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`, } } } else if (kind === 'volume' && center && baseNormal) { @@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() { (segmentStart[1] + segmentEnd[1]) / 2, (segmentStart[2] + segmentEnd[2]) / 2, ], - text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit), + text: formatLinearMeasurement( + measurementDistance(segmentStart, segmentEnd), + unit, + metricNotation, + ), } } } @@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() { polygonPoints, segmentLabel, } - }, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag]) + }, [ + axisGuide, + baseNormal, + extrusionHeight, + hover, + kind, + metricNotation, + points, + stage, + unit, + vertexDrag, + ]) if (smartActive) return if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null @@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() { text={`${hover.semantic.label}${ hover.semantic.length === null ? '' - : ` · ${formatLinearMeasurement(hover.semantic.length, unit)}` + : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}` }`} textColor={labelText} unitsPerPixel={unitsPerPixel} diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx new file mode 100644 index 00000000..54b240cc --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -0,0 +1,61 @@ +'use client' + +import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react' +import { + type FloorplanToolContext, + getFloorplanNodeExtension, +} from '../../lib/floorplan/floorplan-extension' +import useEditor from '../../store/use-editor' + +const lazyToolCache = new WeakMap<() => Promise, ComponentType>() + +function registeredFloorplanTool(tool: string | null): ComponentType | null { + if (!tool) return null + const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool + if (!loader) return null + const cached = lazyToolCache.get(loader) + if (cached) return cached + const component = lazy(loader) + lazyToolCache.set(loader, component) + return component +} + +export function FloorplanRegisteredToolLayer() { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const gridSnapStep = useEditor((state) => state.gridSnapStep) + const toolDefaults = useEditor((state) => + state.tool ? (state.toolDefaults[state.tool] ?? null) : null, + ) + const activeLevelId = useViewer((state) => state.selection.levelId) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + const sceneApi = useMemo(() => createSceneApi(useScene), []) + const selectNode = useCallback( + (id: Parameters[0]) => + useViewer.getState().setSelection({ selectedIds: [id] }), + [], + ) + const finishTool = useCallback(() => { + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + }, []) + if (mode !== 'build') return null + const Tool = registeredFloorplanTool(tool) + return Tool ? ( + + + + ) : null +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index bef46dcd..315834d1 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -20,16 +20,21 @@ import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { useReducedMotion } from '../../hooks/use-reduced-motion' import { resolveMoveActionNode } from '../../lib/direct-manipulation' +import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, } from '../../lib/fresh-planar-placement' +import { curveReshapeScope } from '../../lib/interaction/scope' import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' -import { useMovingNode } from '../../store/use-interaction-scope' +import useInteractionScope, { + useIsCurveReshape, + useMovingNode, +} from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' import { IconRefGlyph } from '../ui/icon-ref' @@ -106,6 +111,8 @@ function collectQuickActionNodes( * `` / dispatcher picks the right path. * Walls are excluded — their move is reached via the side-arrow * handles emitted from `def.floorplan`, not via a menu button. + * - Curve (wall only): enters curve reshape mode. The selected wall's + * midpoint curve handle remains visible so it can be dragged in plan. * - Add hole (slab + ceiling only): inserts a small default-square * hole at the polygon centroid via `updateNode`. Mirrors the legacy * `handleAddHole` in `floating-action-menu.tsx`. @@ -114,7 +121,7 @@ function collectQuickActionNodes( * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's * `relations.cascadeDelete` if declared on the def. * - * Hidden while in a move state (so we don't show buttons over a ghost). + * Hidden while moving or curving so the menu does not compete with the active affordance. */ export function FloorplanRegistryActionMenu() { const reducedMotion = useReducedMotion() @@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() { s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined, ) as AnyNodeId | undefined const movingNode = useMovingNode() + const isCurveReshape = useIsCurveReshape() const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Gate on floorplan hover so this 2D menu never coexists with the 3D @@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() { // Only show for registered kinds (skip legacy kinds — they have their // own FloorplanActionMenuLayer entries). const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) + const canCurve = useScene((s) => { + if (!selectedId) return false + const selectedNode = s.nodes[selectedId] + if (!selectedNode) return false + const definition = nodeRegistry.get(selectedNode.type) + const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve + return ( + !!definition?.floorplanAffordances?.curve && + !!canCurveNode?.({ + node: selectedNode as never, + nodes: s.nodes, + }) + ) + }) const def = selectedKind ? nodeRegistry.get(selectedKind) : null const isRegistryKind = !!def const isVisible = - isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered + isRegistryKind && + def?.presentation?.actionMenu !== false && + !movingNode && + !isCurveReshape && + isFloorplanHovered const isWall = selectedKind === 'wall' const quickActionNodes = useScene( useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), @@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() { ) } + const handleCurve = () => { + if (!canCurve) return + sfxEmitter.emit('sfx:item-pick') + useInteractionScope.getState().begin(curveReshapeScope(node.id)) + } + const handleDuplicate = () => { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') @@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() { > { + test('treats fixed annotation categories as layout obstacles', () => { + expect( + floorplanAnnotationObstacleMode({ + kind: 'text', + x: 0, + y: 0, + text: 'BEDROOM', + fontSize: 0.18, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + }), + ).toBe('bounds') + expect( + floorplanAnnotationObstacleMode({ + kind: 'line', + x1: 0, + y1: 0, + x2: 1, + y2: 0, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + }), + ).toBe('bounds') + expect( + floorplanAnnotationObstacleMode({ + kind: 'polyline', + points: [ + [0, 0], + [1, 0], + ], + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + }), + ).toBe('outline') + }) +}) + +describe('collectAnnotationLayoutPreflightIssues', () => { + test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => { + const issues = collectAnnotationLayoutPreflightIssues( + [ + { + id: 'short', + x: 0, + y: 0, + width: 40, + height: 10, + priority: 10, + text: '1"', + labelPlacement: 'outside-end', + }, + { + id: 'blocked', + x: 100, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'Blocked', + }, + { + id: 'overlap-a', + x: 200, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'A', + }, + { + id: 'overlap-b', + x: 205, + y: 0, + width: 40, + height: 10, + priority: 10, + text: 'B', + }, + ], + [ + { id: 'short', dx: 0, dy: 0, resolved: true }, + { id: 'blocked', dx: 0, dy: 0, resolved: true }, + { id: 'overlap-a', dx: 0, dy: 0, resolved: false }, + { id: 'overlap-b', dx: 0, dy: 0, resolved: true }, + ], + [{ x: 96, y: -2, width: 48, height: 14 }], + ) + + expect(issues.map((issue) => issue.kind)).toEqual([ + 'short-unreadable-segment', + 'plan-geometry-conflict', + 'unresolved-collision', + ]) + expect(issues.every((issue) => issue.severity === 'warning')).toBe(true) + }) +}) + +describe('resolveAnnotationLabelRectangles', () => { + test('keeps the higher-priority label and moves the conflicting label', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 }, + { id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 }, + ]) + + expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + }) + + test('keeps pinned labels at their drawing-view override and routes other labels around them', () => { + const shifts = resolveAnnotationLabelRectangles([ + { + id: 'pinned', + x: 0, + y: 0, + width: 80, + height: 12, + priority: 1, + pinnedShift: { dx: 30, dy: 0 }, + }, + { id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 }, + ]) + + expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({ + id: 'pinned', + dx: 30, + dy: 0, + resolved: true, + }) + expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({ + dx: 0, + dy: 0, + }) + }) + + test('does not move labels that are already clear', () => { + expect( + resolveAnnotationLabelRectangles([ + { id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 }, + { id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 }, + ]), + ).toEqual([ + { id: 'left', dx: 0, dy: 0, resolved: true }, + { id: 'right', dx: 0, dy: 0, resolved: true }, + ]) + }) + + test('preserves drawing order for labels on the same dimension string', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 }, + { id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 }, + ]) + + expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 }) + expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 }) + }) + + test('slides a colliding label along its dimension string before crossing tiers', () => { + const shifts = resolveAnnotationLabelRectangles([ + { id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 }, + { + id: 'adjacent', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + tangentX: 1, + tangentY: 0, + }, + ]) + + expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true }) + expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0) + }) + + test('tries a short dimension alternative before generic relocation', () => { + const shifts = resolveAnnotationLabelRectangles( + [ + { + id: 'short-dimension', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + preferredShifts: [{ dx: 100, dy: 0 }], + }, + ], + [{ x: 0, y: 0, width: 40, height: 12 }], + ) + + expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }]) + }) + + test('falls back to a third position when both short-dimension sides are blocked', () => { + const shifts = resolveAnnotationLabelRectangles( + [ + { + id: 'short-dimension', + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10, + preferredShifts: [{ dx: 100, dy: 0 }], + }, + ], + [ + { x: 0, y: 0, width: 40, height: 12 }, + { x: 100, y: 0, width: 40, height: 12 }, + ], + ) + + expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true }) + expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 }) + expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 }) + }) + + test('approximates diagonal outlines without blocking their full bounding box', () => { + expect( + polylineObstacleRectangles([ + { x: 0, y: 0 }, + { x: 4, y: 4 }, + { x: 8, y: 8 }, + ]), + ).toEqual([ + { x: -1, y: -1, width: 6, height: 6 }, + { x: 3, y: 3, width: 6, height: 6 }, + ]) + }) + + test('moves a dimension value clear of a fixed door-mark pill', () => { + const shifts = resolveAnnotationLabelRectangles( + [{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }], + [{ x: 100, y: 82, width: 48, height: 32 }], + ) + + expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })]) + const shift = shifts[0] + expect(shift).not.toMatchObject({ dx: 0, dy: 0 }) + expect( + 94 + (shift?.dx ?? 0) + 42 + 6 <= 100 || + 148 + 6 <= 94 + (shift?.dx ?? 0) || + 88 + (shift?.dy ?? 0) + 16 + 6 <= 82 || + 114 + 6 <= 88 + (shift?.dy ?? 0), + ).toBe(true) + }) + + test('finds separate nearby positions for a dense label cluster', () => { + const shifts = resolveAnnotationLabelRectangles( + Array.from({ length: 4 }, (_, index) => ({ + id: `label-${index}`, + x: 0, + y: 0, + width: 40, + height: 12, + priority: 10 - index, + })), + ) + + expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + }) + + test('keeps a large dense label cluster readable', () => { + const rectangles = Array.from({ length: 12 }, (_, index) => ({ + id: `label-${index}`, + x: 100 + (index % 3) * 4, + y: 100 + (index % 2) * 3, + width: 48 + (index % 4) * 8, + height: 14, + priority: 20 - index, + })) + const shifts = resolveAnnotationLabelRectangles(rectangles) + const placed = rectangles.map((rectangle) => { + const shift = shifts.find(({ id }) => id === rectangle.id) + return { + ...rectangle, + x: rectangle.x + (shift?.dx ?? 0), + y: rectangle.y + (shift?.dy ?? 0), + } + }) + + expect(shifts.every((entry) => entry.resolved)).toBe(true) + for (let index = 0; index < placed.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) { + const left = placed[index] + const right = placed[otherIndex] + if (!left || !right) continue + const overlaps = !( + left.x + left.width + 6 <= right.x || + right.x + right.width + 6 <= left.x || + left.y + left.height + 6 <= right.y || + right.y + right.height + 6 <= left.y + ) + expect(overlaps).toBe(false) + } + } + }) + + test('resolves a construction-plan label set without blocking the view transition', () => { + const labels = Array.from({ length: 25 }, (_, index) => ({ + id: `label-${index}`, + x: index % 5, + y: index % 7, + width: 80, + height: 20, + priority: 25 - index, + })) + const obstacles = Array.from({ length: 100 }, (_, index) => ({ + x: (index % 10) * 2, + y: (index % 13) * 2, + width: 100, + height: 40, + })) + + const startedAt = performance.now() + const shifts = resolveAnnotationLabelRectangles(labels, obstacles) + const elapsedMs = performance.now() - startedAt + + expect(shifts).toHaveLength(labels.length) + expect(shifts.every((entry) => entry.resolved)).toBe(true) + expect(elapsedMs).toBeLessThan(500) + }) +}) + +describe('observeSvgAnnotationLayoutChanges', () => { + test('requests a fresh collision pass when floor-plan geometry changes after mount', () => { + const OriginalMutationObserver = globalThis.MutationObserver + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + let notify: MutationCallback | undefined + let animationFrame: FrameRequestCallback | undefined + let disconnected = false + let observedOptions: MutationObserverInit | undefined + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(_target: Node, options?: MutationObserverInit): void { + observedOptions = options + } + + disconnect(): void { + disconnected = true + } + + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrame = callback + return 1 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + + expect(layoutPasses).toBe(0) + animationFrame?.(0) + expect(layoutPasses).toBe(1) + expect(observedOptions).toMatchObject({ + attributes: true, + childList: true, + subtree: true, + attributeFilter: expect.any(Array), + }) + + notify?.( + [ + { + attributeName: 'style', + target: { closest: () => ({}) }, + type: 'attributes', + } as unknown as MutationRecord, + ], + {} as MutationObserver, + ) + expect(layoutPasses).toBe(1) + + stop() + expect(disconnected).toBe(true) + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts new file mode 100644 index 00000000..934a806e --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -0,0 +1,688 @@ +import type { FloorplanGeometry } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' + +export type AnnotationLabelRectangle = { + id: string + x: number + y: number + width: number + height: number + priority: number + text?: string + labelPlacement?: 'inside' | 'outside-end' + pinnedShift?: { dx: number; dy: number } + tangentX?: number + tangentY?: number + preferredShifts?: readonly { dx: number; dy: number }[] +} + +export type AnnotationObstacleRectangle = Pick< + AnnotationLabelRectangle, + 'x' | 'y' | 'width' | 'height' +> + +export type AnnotationLabelShift = { + id: string + dx: number + dy: number + resolved: boolean +} + +const LABEL_GAP_PX = 6 +const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5 +const OUTLINE_SAMPLE_SPACING_PX = 6 +const OUTLINE_OBSTACLE_PADDING_PX = 1 +const MAX_LABEL_SHIFT_CANDIDATES = 512 +const PREFERRED_SHIFT_COST_STEP = 1_000_000 +const COLLISION_GRID_CELL_SIZE_PX = 64 + +class AnnotationObstacleIndex { + private readonly cells = new Map>() + + add(rectangle: AnnotationObstacleRectangle): void { + this.forEachCell(rectangle, 0, (key) => { + let cell = this.cells.get(key) + if (!cell) { + cell = new Set() + this.cells.set(key, cell) + } + cell.add(rectangle) + }) + } + + findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] { + const candidates = new Set() + this.forEachCell(rectangle, LABEL_GAP_PX, (key) => { + for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate) + }) + return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate)) + } + + private forEachCell( + rectangle: AnnotationObstacleRectangle, + padding: number, + visit: (key: string) => void, + ): void { + const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX) + const maxCellX = Math.floor( + (rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX, + ) + const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX) + const maxCellY = Math.floor( + (rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX, + ) + for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { + for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`) + } + } +} + +export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean } +export type AnnotationLayoutOverrides = Readonly> +export type AnnotationPreflightIssueKind = + | 'unresolved-collision' + | 'short-unreadable-segment' + | 'plan-geometry-conflict' +export type AnnotationPreflightIssue = { + id: string + kind: AnnotationPreflightIssueKind + severity: 'warning' + message: string +} + +export function resolveAnnotationLabelRectangles( + rectangles: readonly AnnotationLabelRectangle[], + obstacles: readonly AnnotationObstacleRectangle[] = [], +): AnnotationLabelShift[] { + const occupied = new AnnotationObstacleIndex() + for (const obstacle of obstacles) occupied.add(obstacle) + const shifts = new Map() + const ordered = rectangles + .map((rectangle, order) => ({ order, rectangle })) + .sort( + (left, right) => + Number(Boolean(right.rectangle.pinnedShift)) - + Number(Boolean(left.rectangle.pinnedShift)) || + right.rectangle.priority - left.rectangle.priority || + left.order - right.order, + ) + + for (const { rectangle } of ordered) { + const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied) + const shift = selected ?? { dx: 0, dy: 0 } + const resolved = rectangle.pinnedShift !== undefined || selected !== undefined + occupied.add({ + x: rectangle.x + shift.dx, + y: rectangle.y + shift.dy, + width: rectangle.width, + height: rectangle.height, + }) + shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved }) + } + + return rectangles.map( + (rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false }, + ) +} + +export function resolveSvgAnnotationCollisions( + svg: SVGSVGElement, + options: { layoutOverrides?: AnnotationLayoutOverrides } = {}, +): AnnotationPreflightIssue[] { + const labels = Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]')) + if (labels.length === 0) return [] + + for (const label of labels) { + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform + if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform) + label.removeAttribute('data-floorplan-layout-unresolved') + delete label.dataset.floorplanAnnotationLayoutDx + delete label.dataset.floorplanAnnotationLayoutDy + } + resetDimensionConnectors(svg) + + const pinnedLocalById = new Map() + const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => { + const bounds = label.getBoundingClientRect() + const matrix = label.getScreenCTM() + const id = svgAnnotationLabelId(label, index) + label.dataset.floorplanAnnotationId = id + const override = options.layoutOverrides?.[id] + const pinnedLocal = + override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy) + ? { dx: override.dx, dy: override.dy } + : undefined + if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy }) + const pinnedShift = + pinnedLocal && matrix + ? { + dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy, + dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy, + } + : undefined + const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0 + const outsideStartLocalX = Number( + label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN, + ) + const outsideStartLocalY = Number( + label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN, + ) + const preferredShifts = + matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY) + ? [ + { + dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY, + dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY, + }, + ] + : undefined + return { + id, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + priority: Number(label.dataset.floorplanAnnotationPriority ?? 0), + text: label.textContent?.trim() ?? '', + labelPlacement: + label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside', + pinnedShift, + tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined, + tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined, + preferredShifts, + } + }) + const obstacles = Array.from( + svg.querySelectorAll('[data-floorplan-annotation-obstacle]'), + ).flatMap(svgAnnotationObstacleRectangles) + const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles) + const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles) + + labels.forEach((label, index) => { + const rectangle = rectangles[index] + const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id) + if (!shift || (shift.dx === 0 && shift.dy === 0)) { + label.dataset.floorplanAnnotationLayoutDx = '0' + label.dataset.floorplanAnnotationLayoutDy = '0' + if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true' + return + } + const matrix = label.getScreenCTM() + if (!matrix) return + const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy) + label.dataset.floorplanAnnotationLayoutDx = String(local.x) + label.dataset.floorplanAnnotationLayoutDy = String(local.y) + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim()) + const preferredShift = rectangle.preferredShifts?.[0] + const usedOutsideStart = + preferredShift !== undefined && + Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5 + if (usedOutsideStart) applyOutsideStartDimensionLine(label) + else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') { + showDimensionLeader(label, matrix, shift.dx, shift.dy) + } + if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true' + }) + return preflightIssues +} + +export function observeSvgAnnotationLayoutChanges( + svg: SVGSVGElement, + onChange: () => void, +): () => void { + let scheduledFrame: number | null = null + const schedule = () => { + if (scheduledFrame !== null) return + const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) + scheduledFrame = requestFrame(() => { + scheduledFrame = null + onChange() + }) + } + const observer = new MutationObserver((mutations) => { + if (mutations.some(isAnnotationLayoutMutation)) schedule() + }) + observer.observe(svg, { + attributes: true, + attributeFilter: [ + 'cx', + 'cy', + 'd', + 'dominant-baseline', + 'font-family', + 'font-size', + 'font-weight', + 'height', + 'points', + 'r', + 'rx', + 'ry', + 'stroke-width', + 'text-anchor', + 'transform', + 'visibility', + 'width', + 'x', + 'x1', + 'x2', + 'y', + 'y1', + 'y2', + ], + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame === null) return + if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame) + else clearTimeout(scheduledFrame) + } +} + +function isAnnotationLayoutMutation(mutation: MutationRecord): boolean { + if (mutation.type !== 'attributes') return true + const attribute = mutation.attributeName ?? '' + const target = mutation.target as Element + const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null + + if ( + attribute === 'data-floorplan-annotation-id' || + attribute === 'data-floorplan-annotation-layout-dx' || + attribute === 'data-floorplan-annotation-layout-dy' || + attribute === 'data-floorplan-layout-unresolved' + ) { + return false + } + if ( + closest?.('[data-floorplan-annotation-label]') && + (attribute === 'style' || attribute === 'transform') + ) { + return false + } + if ( + closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') && + (attribute === 'x1' || + attribute === 'x2' || + attribute === 'y1' || + attribute === 'y2' || + attribute === 'visibility') + ) { + return false + } + return true +} + +export function collectAnnotationLayoutPreflightIssues( + rectangles: readonly AnnotationLabelRectangle[], + shifts: readonly AnnotationLabelShift[], + obstacles: readonly AnnotationObstacleRectangle[] = [], +): AnnotationPreflightIssue[] { + const shiftsById = new Map(shifts.map((shift) => [shift.id, shift])) + const finalRectangles = rectangles.map((rectangle) => { + const shift = shiftsById.get(rectangle.id) ?? { + id: rectangle.id, + dx: 0, + dy: 0, + resolved: false, + } + return { + source: rectangle, + shift, + bounds: { + x: rectangle.x + shift.dx, + y: rectangle.y + shift.dy, + width: rectangle.width, + height: rectangle.height, + }, + } + }) + const issues: AnnotationPreflightIssue[] = [] + const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => { + if (issues.some((issue) => issue.id === id && issue.kind === kind)) return + issues.push({ id, kind, severity: 'warning', message }) + } + + for (const entry of finalRectangles) { + const label = preflightLabel(entry.source) + if (entry.source.labelPlacement === 'outside-end') { + addIssue( + entry.source.id, + 'short-unreadable-segment', + `${label} is too short for inline text and uses an outside label or leader.`, + ) + } + if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) { + addIssue( + entry.source.id, + 'plan-geometry-conflict', + `${label} still conflicts with fixed plan geometry after automatic layout.`, + ) + } + if (!entry.shift.resolved) { + const collidesWithLabel = finalRectangles.some( + (candidate) => + candidate.source.id !== entry.source.id && + rectanglesOverlap(entry.bounds, candidate.bounds), + ) + if (collidesWithLabel) { + addIssue( + entry.source.id, + 'unresolved-collision', + `${label} still overlaps another annotation after automatic layout.`, + ) + } + } + } + return issues +} + +function preflightLabel(rectangle: AnnotationLabelRectangle): string { + const text = rectangle.text?.trim() + return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}` +} + +export function svgAnnotationLabelId(label: SVGGElement, index: number): string { + const explicit = label.dataset.floorplanAnnotationId?.trim() + if (explicit) return explicit + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + const text = label.textContent?.trim() ?? '' + return `annotation:${index}:${text}:${defaultTransform}` +} + +function resetDimensionConnectors(svg: SVGSVGElement): void { + for (const line of svg.querySelectorAll('[data-floorplan-dimension-line]')) { + line.setAttribute( + 'x1', + line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0', + ) + line.setAttribute( + 'y1', + line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0', + ) + line.setAttribute( + 'x2', + line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0', + ) + line.setAttribute( + 'y2', + line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0', + ) + } + for (const leader of svg.querySelectorAll('[data-floorplan-dimension-leader]')) { + leader.setAttribute('visibility', 'hidden') + } +} + +function applyOutsideStartDimensionLine(label: SVGGElement): void { + const dimension = label.closest('[data-floorplan-dimension]') + const line = dimension?.querySelector('[data-floorplan-dimension-line]') + if (!line) return + const { dataset } = line + if ( + dataset.floorplanDimensionOutsideStartX1 === undefined || + dataset.floorplanDimensionOutsideStartY1 === undefined || + dataset.floorplanDimensionOutsideStartX2 === undefined || + dataset.floorplanDimensionOutsideStartY2 === undefined + ) { + return + } + line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1) + line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1) + line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2) + line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2) +} + +function showDimensionLeader( + label: SVGGElement, + labelMatrix: DOMMatrix, + dx: number, + dy: number, +): void { + const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null + const leader = dimension?.querySelector('[data-floorplan-dimension-leader]') + const dimensionLine = dimension?.querySelector('[data-floorplan-dimension-line]') + const dimensionMatrix = dimension?.getScreenCTM() + if (!leader || !dimensionLine || !dimensionMatrix) return + + const start = dimensionEndpoint(label, 'start') + const end = dimensionEndpoint(label, 'end') + if (!start || !end) return + dimensionLine.setAttribute('x1', String(start.x)) + dimensionLine.setAttribute('y1', String(start.y)) + dimensionLine.setAttribute('x2', String(end.x)) + dimensionLine.setAttribute('y2', String(end.y)) + const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy } + const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y) + const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y) + const anchor = + Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) < + Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y) + ? start + : end + const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y) + + leader.setAttribute('x1', String(anchor.x)) + leader.setAttribute('y1', String(anchor.y)) + leader.setAttribute('x2', String(labelPoint.x)) + leader.setAttribute('y2', String(labelPoint.y)) + leader.setAttribute('visibility', 'visible') +} + +function dimensionEndpoint( + label: SVGGElement, + endpoint: 'start' | 'end', +): { x: number; y: number } | null { + const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`]) + const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`]) + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null +} + +function localPointToScreen(matrix: DOMMatrix, x: number, y: number) { + return { + x: matrix.a * x + matrix.c * y + matrix.e, + y: matrix.b * x + matrix.d * y + matrix.f, + } +} + +function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) { + const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f) + return { x: local.x, y: local.y } +} + +function svgAnnotationObstacleRectangles( + obstacle: SVGGraphicsElement, +): AnnotationObstacleRectangle[] { + const bounds = obstacle.getBoundingClientRect() + const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }] + if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback + + const geometry = obstacle as SVGGeometryElement + const matrix = geometry.getScreenCTM() + if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback + + try { + const length = geometry.getTotalLength() + const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d)) + const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX)) + const points = Array.from({ length: sampleCount + 1 }, (_, index) => { + const point = geometry.getPointAtLength((length * index) / sampleCount) + return { + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f, + } + }) + return polylineObstacleRectangles(points) + } catch { + return fallback + } +} + +export function polylineObstacleRectangles( + points: readonly { x: number; y: number }[], +): AnnotationObstacleRectangle[] { + if (points.length === 1) { + const point = points[0]! + return [ + { + x: point.x - OUTLINE_OBSTACLE_PADDING_PX, + y: point.y - OUTLINE_OBSTACLE_PADDING_PX, + width: OUTLINE_OBSTACLE_PADDING_PX * 2, + height: OUTLINE_OBSTACLE_PADDING_PX * 2, + }, + ] + } + + const rectangles: AnnotationObstacleRectangle[] = [] + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1]! + const end = points[index]! + rectangles.push({ + x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX, + y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX, + width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2, + height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2, + }) + } + return rectangles +} + +function resolveLabelShift( + rectangle: AnnotationLabelRectangle, + occupied: AnnotationObstacleIndex, +): { dx: number; dy: number } | undefined { + const candidates = new Map() + const visited = new Set() + const addCandidate = (dx: number, dy: number, preference = 0) => { + if (!Number.isFinite(dx) || !Number.isFinite(dy)) return + const key = `${dx}:${dy}` + if (visited.has(key)) return + const existing = candidates.get(key) + if (!existing || preference < existing.preference) { + candidates.set(key, { + dx, + dy, + preference, + cost: candidateCost({ dx, dy, preference }, rectangle), + }) + } + } + addCandidate(0, 0, -2) + for (const preferred of rectangle.preferredShifts ?? []) { + addCandidate(preferred.dx, preferred.dy, -1) + } + + while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) { + let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined + for (const queued of candidates.values()) { + if (!candidate || queued.cost < candidate.cost) candidate = queued + } + if (!candidate) return undefined + const key = `${candidate.dx}:${candidate.dy}` + candidates.delete(key) + visited.add(key) + + const shifted = { + ...rectangle, + x: rectangle.x + candidate.dx, + y: rectangle.y + candidate.dy, + } + const blockers = occupied.findOverlaps(shifted) + if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy } + + const blockerBounds = blockers.reduce( + (bounds, blocker) => ({ + minX: Math.min(bounds.minX, blocker.x), + minY: Math.min(bounds.minY, blocker.y), + maxX: Math.max(bounds.maxX, blocker.x + blocker.width), + maxY: Math.max(bounds.maxY, blocker.y + blocker.height), + }), + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ) + const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x + const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x + const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y + const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y + + addCandidate(candidate.dx, above) + addCandidate(candidate.dx, below) + addCandidate(left, candidate.dy) + addCandidate(right, candidate.dy) + addCandidate(left, above) + addCandidate(right, above) + addCandidate(left, below) + addCandidate(right, below) + } + return undefined +} + +function candidateCost( + candidate: { dx: number; dy: number; preference?: number }, + rectangle: AnnotationLabelRectangle, +): number { + const distance = Math.hypot(candidate.dx, candidate.dy) + const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP + if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) { + return preference + distance + } + + const perpendicularMovement = Math.abs( + candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX, + ) + return preference + distance + perpendicularMovement * 4 +} + +export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean { + if (floorplanAnnotationObstacleMode(geometry)) return true + if (geometry.kind !== 'group') return false + const hasPlate = geometry.children.some( + (child) => child.kind === 'rect' || child.kind === 'circle', + ) + const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright) + return hasPlate && hasUprightText +} + +export function floorplanAnnotationObstacleMode( + geometry: FloorplanGeometry, +): 'bounds' | 'outline' | '' | undefined { + const metadata = readFloorplanGeometryMetadata(geometry) + if (metadata.annotationObstacle) return metadata.annotationObstacle + switch (metadata.annotationRole) { + case 'room-label': + return geometry.kind === 'text' ? 'bounds' : undefined + case 'column-center': + return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined + case 'stair-annotation': + return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds' + default: + return undefined + } +} + +function rectanglesOverlap( + left: Pick, + right: Pick, +): boolean { + return !( + left.x + left.width + LABEL_GAP_PX <= right.x || + right.x + right.width + LABEL_GAP_PX <= left.x || + left.y + left.height + LABEL_GAP_PX <= right.y || + right.y + right.height + LABEL_GAP_PX <= left.y + ) +} + +function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) { + const determinant = matrix.a * matrix.d - matrix.b * matrix.c + if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 } + return { + x: (matrix.d * dx - matrix.c * dy) / determinant, + y: (-matrix.b * dx + matrix.a * dy) / determinant, + } +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx new file mode 100644 index 00000000..c48ef972 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import { renderToStaticMarkup } from 'react-dom/server' +import { + computeArchitecturalDimensionLayout, + FloorplanDimensionRenderer, + FloorplanDimensionStringRenderer, + floorplanDimensionAnnotationPriority, +} from './floorplan-dimension-renderer' + +const dimension = { + kind: 'dimension', + start: [0, 0], + end: [4, 0], + offsetNormal: [0, 1], + offsetDistance: 0.45, + extensionOvershoot: 0.12, + text: `13'-1 1/2"`, +} satisfies Extract + +describe('architectural floor-plan dimensions', () => { + test('leaves a paper-space gap before solid extension lines', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0) + + expect(layout).not.toBeNull() + expect(layout?.extensionStart).toEqual([0, 0.075]) + expect(layout?.extensionEnd).toEqual([4, 0.075]) + expect(layout?.extensionStartTip[0]).toBe(0) + expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57) + expect(layout?.extensionEndTip[0]).toBe(4) + expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57) + expect(layout?.dimensionStart).toEqual([0, 0.45]) + expect(layout?.dimensionEnd).toEqual([4, 0.45]) + expect(layout?.dimensionLineEnd).toEqual([4, 0.45]) + expect(layout?.labelPlacement).toBe('inside') + }) + + test('builds consistent 45-degree architectural slash terminators', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0) + + expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5) + expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5) + }) + + test('honors dimension standard overrides for gaps, terminators, and text placement', () => { + const customDimension = { + ...dimension, + extensionStartGap: 0.2, + terminator: 'dot', + textPosition: 'centered', + } satisfies Extract + const layout = computeArchitecturalDimensionLayout(customDimension, 0) + const markup = renderToStaticMarkup( + + + , + ) + + expect(layout?.extensionStart).toEqual([0, 0.2]) + expect(markup).toContain(' { + const shortDimension = { + ...dimension, + end: [0.23, 0] as [number, number], + text: '0.23m', + } + const layout = computeArchitecturalDimensionLayout(shortDimension, 0) + + expect(layout?.labelPlacement).toBe('outside-end') + expect(layout?.labelPoint[0]).toBeGreaterThan(0.23) + expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0) + expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan( + layout?.outsideStartLabelPoint?.[0] ?? 0, + ) + expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0) + expect(layout?.dimensionEnd).toEqual([0.23, 0.45]) + + const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01) + expect(documentLayout?.labelPlacement).toBe('outside-end') + expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0) + + const markup = renderToStaticMarkup( + + + , + ) + expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=') + expect(markup).not.toContain('data-floorplan-dimension-leader=""') + + const documentMarkup = renderToStaticMarkup( + + + , + ) + expect(documentMarkup).toContain('data-floorplan-dimension-leader=""') + expect(documentMarkup).toContain('visibility="hidden"') + }) + + test('aligns stepped feature origins to an explicit exterior baseline', () => { + const layout = computeArchitecturalDimensionLayout( + { + ...dimension, + start: [0, 0], + end: [4, 1], + dimensionStart: [0, 2], + dimensionEnd: [4, 2], + offsetDistance: 2, + }, + 0, + ) + + expect(layout?.dimensionStart).toEqual([0, 2]) + expect(layout?.dimensionEnd).toEqual([4, 2]) + expect(layout?.extensionStart).toEqual([0, 0.075]) + expect(layout?.extensionEnd).toEqual([4, 1.075]) + expect(layout?.extensionStartTip).toEqual([0, 2.12]) + expect(layout?.extensionEndTip).toEqual([4, 2.12]) + }) + + test('renders one uninterrupted line with the label above it', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup.match(/ { + expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan( + floorplanDimensionAnnotationPriority(0.55), + ) + }) + + test('keeps labels readable after the scene rotates', () => { + expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180) + }) + + test('resolves document annotation sizes from paper points', () => { + const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01) + const markup = renderToStaticMarkup( + + + , + ) + + expect(layout?.extensionStart[1]).toBeCloseTo(0.03) + expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49) + expect(markup).toContain('font-size="0.08"') + expect(markup).toContain('y="-0.05"') + }) + + test('uses PDF-safe label plates and hairline measurement strokes for export', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-label-plate=""') + expect(markup).toContain('stroke-width="0.5"') + expect(markup).not.toContain('paint-order="stroke"') + expect(markup).not.toContain('stroke="#ffffff"') + }) + + test('renders a dimension string with shared witness extension lines and ticks', () => { + const stringGeometry = { + kind: 'dimension-string', + segments: [ + { + start: [0, 0], + end: [2, 0], + dimensionStart: [0, 1], + dimensionEnd: [2, 1], + text: '2m', + }, + { + start: [2, 0], + end: [5, 0], + dimensionStart: [2, 1], + dimensionEnd: [5, 1], + text: '3m', + }, + ], + offsetNormal: [0, 1], + offsetDistance: 1, + extensionOvershoot: 0.12, + textPosition: 'above', + } satisfies Extract + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-string=""') + expect(markup.match(/ { + const automaticString = { + kind: 'dimension-string', + segments: [{ start: [0, 0], end: [2, 0], text: '2m' }], + offsetNormal: [0, 1], + offsetDistance: 0.55, + extensionOvershoot: 0.12, + textPosition: 'above', + } satisfies Extract + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"') + expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"') + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx new file mode 100644 index 00000000..e917a273 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -0,0 +1,608 @@ +import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core' +import { resolveFloorplanLabelAngle } from './floorplan-label-angle' + +const EXTENSION_START_GAP = 0.075 +const TICK_HALF_LENGTH = 0.09 +const LABEL_FONT_SIZE = 0.15 +const LABEL_BASELINE_OFFSET = 0.12 +const LABEL_CHARACTER_WIDTH_RATIO = 0.62 +const LABEL_END_GAP = 0.075 +const DOCUMENT_EXTENSION_START_GAP_PT = 3 +const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4 +const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5 +const DOCUMENT_LABEL_FONT_SIZE_PT = 8 +const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5 +const DOCUMENT_LABEL_END_GAP_PT = 3 +const LINE_STROKE_WIDTH_PX = 0.9 +const TICK_STROKE_WIDTH_PX = 1.35 +const PDF_LINE_STROKE_WIDTH_PT = 0.5 +const PDF_TICK_STROKE_WIDTH_PT = 0.75 +const SQRT_ONE_HALF = Math.SQRT1_2 + +type FloorplanDimensionRenderMode = 'screen' | 'pdf' + +type DimensionGeometry = Extract +type DimensionStringGeometry = Extract +type DimensionTerminator = NonNullable + +export type ArchitecturalDimensionLayout = { + dimensionStart: FloorplanPoint + dimensionEnd: FloorplanPoint + dimensionLineStart: FloorplanPoint + dimensionLineEnd: FloorplanPoint + extensionStart: FloorplanPoint + extensionEnd: FloorplanPoint + extensionStartTip: FloorplanPoint + extensionEndTip: FloorplanPoint + tickHalfVector: FloorplanPoint + labelPoint: FloorplanPoint + labelAngleDeg: number + labelPlacement: 'inside' | 'outside-end' + outsideStartLabelPoint?: FloorplanPoint + outsideStartDimensionLineStart?: FloorplanPoint +} + +export function floorplanDimensionAnnotationPriority(offsetDistance: number): number { + return 100 + Math.round(Math.abs(offsetDistance) * 100) +} + +export function computeArchitecturalDimensionLayout( + geometry: DimensionGeometry, + sceneRotationDeg: number, + annotationUnitsPerPoint?: number, +): ArchitecturalDimensionLayout | null { + const extensionStartGap = annotationUnitsPerPoint + ? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint + : (geometry.extensionStartGap ?? EXTENSION_START_GAP) + const extensionOvershoot = annotationUnitsPerPoint + ? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint + : geometry.extensionOvershoot + const tickHalfLength = annotationUnitsPerPoint + ? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint + : TICK_HALF_LENGTH + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelEndGap = annotationUnitsPerPoint + ? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint + : LABEL_END_GAP + const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance + const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance + const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [ + geometry.start[0] + offsetX, + geometry.start[1] + offsetY, + ] + const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [ + geometry.end[0] + offsetX, + geometry.end[1] + offsetY, + ] + const dx = dimensionEnd[0] - dimensionStart[0] + const dy = dimensionEnd[1] - dimensionStart[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return null + + const dirX = dx / length + const dirY = dy / length + const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal) + const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal) + const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01)) + const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01)) + + // A 45-degree architectural slash. Every terminator within one string uses + // this same vector instead of rotating independently around its endpoint. + const tickHalfVector: FloorplanPoint = [ + (dirX + dirY) * tickHalfLength * SQRT_ONE_HALF, + (dirY - dirX) * tickHalfLength * SQRT_ONE_HALF, + ] + const labelWidth = Math.max( + labelFontSize, + geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO, + ) + const labelPlacement = + length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const) + const direction: FloorplanPoint = [dirX, dirY] + const labelPoint = + labelPlacement === 'inside' + ? ([ + (dimensionStart[0] + dimensionEnd[0]) / 2, + (dimensionStart[1] + dimensionEnd[1]) / 2, + ] as FloorplanPoint) + : addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2) + const dimensionLineEnd = + labelPlacement === 'inside' + ? dimensionEnd + : addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth) + const outsideStartLabelPoint = + labelPlacement === 'outside-end' + ? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2)) + : undefined + const outsideStartDimensionLineStart = + labelPlacement === 'outside-end' + ? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth)) + : undefined + + return { + dimensionStart, + dimensionEnd, + dimensionLineStart: dimensionStart, + dimensionLineEnd, + extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap), + extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap), + extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot), + extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot), + tickHalfVector, + labelPoint, + labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg), + labelPlacement, + outsideStartLabelPoint, + outsideStartDimensionLineStart, + } +} + +function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint { + return [left[0] - right[0], left[1] - right[1]] +} + +function dot(left: FloorplanPoint, right: FloorplanPoint): number { + return left[0] * right[0] + left[1] * right[1] +} + +function addScaled( + point: FloorplanPoint, + direction: FloorplanPoint, + distance: number, +): FloorplanPoint { + return [point[0] + direction[0] * distance, point[1] + direction[1] * distance] +} + +export function FloorplanDimensionRenderer({ + geometry, + sceneRotationDeg = 0, + stroke = geometry.stroke ?? '#334155', + annotationUnitsPerPoint, + renderMode = 'screen', +}: { + geometry: DimensionGeometry + sceneRotationDeg?: number + stroke?: string + annotationUnitsPerPoint?: number + renderMode?: FloorplanDimensionRenderMode +}): React.ReactElement | null { + const layout = computeArchitecturalDimensionLayout( + geometry, + sceneRotationDeg, + annotationUnitsPerPoint, + ) + if (!layout) return null + + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelBaselineOffset = annotationUnitsPerPoint + ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint + : LABEL_BASELINE_OFFSET + const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset + + const lineProps = { + stroke, + strokeLinecap: 'butt' as const, + strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX, + vectorEffect: 'non-scaling-stroke' as const, + } + const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX + const terminator = geometry.terminator ?? 'architectural-tick' + const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})` + const outsideStartLocalShift = layout.outsideStartLabelPoint + ? rotateVector( + subtract(layout.outsideStartLabelPoint, layout.labelPoint), + (-layout.labelAngleDeg * Math.PI) / 180, + ) + : undefined + + return ( + + + + + {renderTerminator( + terminator, + layout.dimensionStart, + layout.dimensionEnd, + layout, + lineProps, + tickStrokeWidth, + )} + {renderTerminator( + terminator, + layout.dimensionEnd, + layout.dimensionStart, + layout, + lineProps, + tickStrokeWidth, + )} + {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? ( + + ) : null} + + + + + ) +} + +export function FloorplanDimensionStringRenderer({ + geometry, + sceneRotationDeg = 0, + stroke = geometry.stroke ?? '#334155', + annotationUnitsPerPoint, + renderMode = 'screen', +}: { + geometry: DimensionStringGeometry + sceneRotationDeg?: number + stroke?: string + annotationUnitsPerPoint?: number + renderMode?: FloorplanDimensionRenderMode +}): React.ReactElement | null { + const segmentLayouts = geometry.segments.flatMap((segment, index) => { + const segmentGeometry: DimensionGeometry = { + kind: 'dimension', + start: segment.start, + end: segment.end, + dimensionStart: segment.dimensionStart, + dimensionEnd: segment.dimensionEnd, + offsetNormal: geometry.offsetNormal, + offsetDistance: geometry.offsetDistance, + extensionOvershoot: geometry.extensionOvershoot, + extensionStartGap: geometry.extensionStartGap, + terminator: geometry.terminator, + textPosition: geometry.textPosition, + text: segment.text, + stroke: geometry.stroke, + } + const layout = computeArchitecturalDimensionLayout( + segmentGeometry, + sceneRotationDeg, + annotationUnitsPerPoint, + ) + return layout ? [{ index, layout, segment: segmentGeometry }] : [] + }) + if (segmentLayouts.length === 0) return null + + const labelFontSize = annotationUnitsPerPoint + ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint + : LABEL_FONT_SIZE + const labelBaselineOffset = annotationUnitsPerPoint + ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint + : LABEL_BASELINE_OFFSET + const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset + const lineProps = { + stroke, + strokeLinecap: 'butt' as const, + strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX, + vectorEffect: 'non-scaling-stroke' as const, + } + const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX + + const extensionLines = new Map() + const ticks = new Map< + string, + { point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint } + >() + for (const { layout } of segmentLayouts) { + extensionLines.set(pointKey(layout.dimensionStart), { + start: layout.extensionStart, + tip: layout.extensionStartTip, + }) + extensionLines.set(pointKey(layout.dimensionEnd), { + start: layout.extensionEnd, + tip: layout.extensionEndTip, + }) + ticks.set(pointKey(layout.dimensionStart), { + point: layout.dimensionStart, + toward: layout.dimensionEnd, + tickHalfVector: layout.tickHalfVector, + }) + ticks.set(pointKey(layout.dimensionEnd), { + point: layout.dimensionEnd, + toward: layout.dimensionStart, + tickHalfVector: layout.tickHalfVector, + }) + } + const terminator = geometry.terminator ?? 'architectural-tick' + + return ( + + {[...extensionLines.values()].map((line, index) => ( + + ))} + {segmentLayouts.map(({ index, layout }) => ( + + ))} + {[...ticks.values()].map(({ point, toward, tickHalfVector }, index) => + renderTerminator( + terminator, + point, + toward, + { tickHalfVector }, + lineProps, + tickStrokeWidth, + `tick-${index}`, + ), + )} + {segmentLayouts.map(({ index, layout, segment }) => { + const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})` + const outsideStartLocalShift = layout.outsideStartLabelPoint + ? rotateVector( + subtract(layout.outsideStartLabelPoint, layout.labelPoint), + (-layout.labelAngleDeg * Math.PI) / 180, + ) + : undefined + return ( + + {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? ( + + ) : null} + + + + + ) + })} + + ) +} + +function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint { + const cosine = Math.cos(radians) + const sine = Math.sin(radians) + return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine] +} + +function DimensionLabel({ + text, + y, + fontSize, + stroke, + renderMode, +}: { + text: string + y: number + fontSize: number + stroke: string + renderMode: FloorplanDimensionRenderMode +}) { + const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO) + const plateWidth = width + fontSize * 0.5 + const plateHeight = fontSize * 1.2 + const plateY = y - fontSize * 0.82 + + return ( + <> + {renderMode === 'pdf' ? ( + + ) : null} + + {text} + + + ) +} + +function renderTerminator( + terminator: DimensionTerminator, + point: FloorplanPoint, + toward: FloorplanPoint, + layout: Pick, + lineProps: { + stroke: string + strokeLinecap: 'butt' + strokeWidth: number + vectorEffect: 'non-scaling-stroke' + }, + tickStrokeWidth: number, + key?: string, +): React.ReactElement | null { + const direction = normalized(point, toward) + if (!direction) return null + const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1]) + if (terminator === 'dot') { + return ( + + ) + } + if (terminator === 'filled-arrow' || terminator === 'open-arrow') { + const base = addScaled(point, direction, tickHalfLength * 1.7) + const normal: FloorplanPoint = [-direction[1], direction[0]] + const wing = tickHalfLength * 0.65 + const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing] + const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing] + if (terminator === 'filled-arrow') { + return ( + + ) + } + return ( + + + + + ) + } + const [tickX, tickY] = layout.tickHalfVector + return ( + + ) +} + +function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const magnitude = Math.hypot(dx, dy) + return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude] +} + +function pointKey(point: FloorplanPoint): string { + return `${point[0].toFixed(6)},${point[1].toFixed(6)}` +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx new file mode 100644 index 00000000..10672fd5 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx @@ -0,0 +1,369 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import { renderToStaticMarkup } from 'react-dom/server' +import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' +import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' + +describe('FloorplanGeometryRenderer static labels', () => { + test('renders a measurement value together with its geometry', () => { + const geometry = { + kind: 'group', + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 0, + text: '2.00m', + angle: 0, + offsetPx: 14, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain(' { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: 'A 6.0m²', + angle: Math.PI / 3, + screenUpright: true, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('A 6.0m²') + expect(markup).toContain('rotate(-90)') + }) + + test('uses paper-point sizing when document scale is provided', () => { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.08"') + }) + + test('uses live screen sizing without enabling document annotation styles', () => { + const geometry = { + kind: 'group', + children: [ + { + kind: 'text', + x: 0, + y: 0, + text: 'LIVE TEXT', + fontSize: 0.16, + upright: true, + }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + offsetPx: 14, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.16"') + expect(markup).toContain('font-size="0.24"') + expect(markup).toContain('translate(0 -0.28)') + }) + + test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => { + const geometry = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 2, + text: '2.00m', + angle: 0, + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-dimension-label-plate=""') + expect(markup).toContain('fill="#111827"') + expect(markup).not.toContain('paint-order="stroke"') + expect(markup).not.toContain('fill="#ffffff" font-family=') + }) + + test('caps PDF annotation linework without changing live stroke widths', () => { + const geometry = { + kind: 'line', + x1: 0, + y1: 0, + x2: 2, + y2: 0, + stroke: '#334155', + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke', + } satisfies FloorplanGeometry + + const liveMarkup = renderToStaticMarkup( + + + , + ) + const pdfMarkup = renderToStaticMarkup( + + + , + ) + + expect(liveMarkup).toContain('stroke-width="2"') + expect(pdfMarkup).toContain('stroke-width="0.5"') + }) + + test('removes unsupported paint-order outlines from generic PDF text', () => { + const geometry = { + kind: 'text', + x: 1, + y: 2, + text: '101', + fontSize: 0.15, + fill: '#ffffff', + stroke: '#334155', + strokeWidth: 0.04, + paintOrder: 'stroke', + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('fill="#334155"') + expect(markup).not.toContain('paint-order') + expect(markup).not.toContain('stroke=') + }) + + test('resolves generic annotation text from paper points only in document mode', () => { + const geometry = { + kind: 'text', + x: 2, + y: 3, + text: 'VERIFY DIMENSIONS', + fontSize: 0.16, + upright: true, + } satisfies FloorplanGeometry + + const liveMarkup = renderToStaticMarkup( + + + , + ) + const documentMarkup = renderToStaticMarkup( + + + , + ) + + expect(liveMarkup).toContain('font-size="0.16"') + expect(documentMarkup).toContain('font-size="0.08"') + }) + + test('uses a room-label paper profile while preserving room label hierarchy', () => { + const geometry = { + kind: 'group', + children: [ + { + kind: 'text', + x: 0, + y: 0, + text: 'KITCHEN', + fontSize: 0.2, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + { + kind: 'text', + x: 0, + y: 0.18, + text: '101', + fontSize: 0.16, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + { + kind: 'text', + x: 0, + y: 0.36, + text: 'CH: 2700', + fontSize: 0.11, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + upright: true, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('font-size="0.08"') + expect(markup).toContain('font-size="0.07"') + expect(markup).toContain('font-size="0.055"') + expect(markup).toMatch(/translate\(0 0\.08625/) + expect(markup).toMatch(/translate\(0 0\.18625/) + expect(markup).toMatch(/translate\(0 0\.27375/) + }) + + test('uses paper stroke profiles for leaders and opening marks in document mode', () => { + const geometry = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { + kind: 'polyline', + points: [ + [0, 0], + [1, 0], + [1.4, 0], + ], + stroke: '#334155', + strokeWidth: 0.9, + vectorEffect: 'non-scaling-stroke', + }, + { + kind: 'rect', + x: 2, + y: 2, + width: 0.42, + height: 0.32, + fill: '#ffffff', + stroke: '#334155', + strokeWidth: 0.02, + }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('stroke-width="0.9"') + expect(markup).toContain('vector-effect="non-scaling-stroke"') + expect(markup).toContain('height="0.14"') + expect(markup).toContain('rx="0.07"') + }) + + test('registers fixed mark pills as annotation obstacles', () => { + const geometry = { + kind: 'group', + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 }, + { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 }, + { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true }, + ], + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle=""') + }) + + test('registers semantic plan primitives as annotation obstacles', () => { + const markup = renderToStaticMarkup( + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle="outline"') + }) + + test('registers fixed annotation categories as obstacles', () => { + const roomLabel = { + kind: 'text', + x: 0, + y: 0, + text: 'KITCHEN', + fontSize: 0.18, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + } satisfies FloorplanGeometry + const stairArrow = { + kind: 'polyline', + points: [ + [0, 0], + [0.5, 0.5], + ], + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } satisfies FloorplanGeometry + + const markup = renderToStaticMarkup( + + + + , + ) + + expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"') + expect(markup).toContain('data-floorplan-annotation-obstacle="outline"') + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx index 316c78ee..70c95b64 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -2,6 +2,29 @@ import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core' import { memo, useEffect, useState } from 'react' +import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' +import { + floorplanAnnotationObstacleMode, + isFloorplanAnnotationObstacleGeometry, +} from './floorplan-annotation-layout' +import { + FloorplanDimensionRenderer, + FloorplanDimensionStringRenderer, +} from './floorplan-dimension-renderer' +import { resolveFloorplanLabelAngle } from './floorplan-label-angle' + +const STATIC_LABEL_UNITS_PER_PIXEL = 0.01 +const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8 +const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8 +const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7 +const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5 +const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7 +const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5 +const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75 +const DOCUMENT_MARK_HEIGHT_PT = 14 +const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5 + +type FloorplanRenderMode = 'screen' | 'pdf' /** * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by @@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react' export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({ geometry, pointerEventsOverride, + sceneRotationDeg = 0, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode = 'screen', }: { geometry: FloorplanGeometry pointerEventsOverride?: string + sceneRotationDeg?: number + annotationUnitsPerPoint?: number + screenUnitsPerPixel?: number + renderMode?: FloorplanRenderMode }) { - return renderNode(geometry, 0, pointerEventsOverride) + return renderNode( + geometry, + 0, + pointerEventsOverride, + sceneRotationDeg, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode, + ) }) function styleAttrs( g: FloorplanGeometry & { kind: Exclude }, pointerEventsOverride?: string, + annotationUnitsPerPoint?: number, + renderMode: FloorplanRenderMode = 'screen', ) { // Shared SVG attribute mapping for any styled primitive. Keeps the per- // primitive switch arms terse and ensures new style fields land @@ -54,37 +95,241 @@ function styleAttrs( pointerEvents?: string cursor?: string } + const annotationMetadata = readFloorplanGeometryMetadata(g) + const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint) + const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect + const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth + const strokeWidth = + renderMode === 'pdf' && + vectorEffect === 'non-scaling-stroke' && + resolvedStrokeWidth !== undefined + ? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth) + : resolvedStrokeWidth return { - fill: s.fill ?? 'none', + 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g), + 'data-floorplan-annotation-role': annotationMetadata.annotationRole, + fill: documentStyle.fill ?? s.fill ?? 'none', fillOpacity: s.fillOpacity, - stroke: s.stroke, - strokeWidth: s.strokeWidth, + stroke: documentStyle.stroke ?? s.stroke, + strokeWidth, strokeDasharray: s.strokeDasharray, strokeLinecap: s.strokeLinecap, strokeLinejoin: s.strokeLinejoin, strokeOpacity: s.strokeOpacity, opacity: s.opacity, - vectorEffect: s.vectorEffect, + vectorEffect, pointerEvents: pointerEventsOverride ?? s.pointerEvents, style: s.cursor ? { cursor: s.cursor } : undefined, } } +export function resolveDocumentFloorplanAnnotationStyle( + geometry: FloorplanGeometry & { kind: Exclude }, + annotationUnitsPerPoint?: number, +): { + fill?: string + stroke?: string + strokeWidth?: number + vectorEffect?: 'non-scaling-stroke' +} { + if (annotationUnitsPerPoint === undefined) return {} + const styled = geometry as FloorplanGeometry & { + stroke?: string + strokeWidth?: number + vectorEffect?: 'non-scaling-stroke' + } + if (!styled.stroke && geometry.kind !== 'text') return {} + + if (geometry.kind === 'text') { + const sourceFontSize = Math.max(geometry.fontSize, 1e-6) + const sourceStrokeWidth = geometry.strokeWidth ?? 0 + const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0 + const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint) + return { + strokeWidth: + geometry.stroke && outlineRatio > 0 + ? Math.max( + DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint, + fontSize * outlineRatio, + ) + : undefined, + } + } + + return { + strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint), + vectorEffect: 'non-scaling-stroke', + } +} + +function documentTextFontSize( + geometry: Extract, + annotationUnitsPerPoint: number, +): number { + return documentTextSizePt(geometry) * annotationUnitsPerPoint +} + +function documentTextSizePt(geometry: Extract): number { + switch (readFloorplanGeometryMetadata(geometry).annotationRole) { + case 'room-label': + if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT + if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT + return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT + case 'column-center': + case 'stair-annotation': + return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT + default: + return DOCUMENT_DEFAULT_TEXT_SIZE_PT + } +} + +function documentStrokeWidth( + geometry: { strokeWidth?: number }, + annotationUnitsPerPoint: number, +): number { + return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5)) +} + +export function documentRectGeometryAttrs( + geometry: Extract, + annotationUnitsPerPoint?: number, +) { + if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) { + return { + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + rx: geometry.rx, + ry: geometry.ry, + } + } + + const centerX = geometry.x + geometry.width / 2 + const centerY = geometry.y + geometry.height / 2 + const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint + const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height) + return { + x: centerX - width / 2, + y: centerY - height / 2, + width, + height, + rx: height / 2, + ry: height / 2, + } +} + +function isAnnotationMarkRect(geometry: Extract): boolean { + return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5 +} + +export function documentCircleGeometryAttrs( + geometry: Extract, + annotationUnitsPerPoint?: number, +) { + if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) { + return { r: geometry.r } + } + return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) } +} + +function isAnnotationMarkCircle(geometry: Extract): boolean { + return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25 +} + +export function resolveDocumentAnnotationGroupChildren( + children: FloorplanGeometry[], + annotationUnitsPerPoint?: number, +): FloorplanGeometry[] { + if (annotationUnitsPerPoint === undefined) return children + + const next = [...children] + let start = 0 + while (start < next.length) { + const first = next[start] + if (!isDocumentTextLine(first)) { + start++ + continue + } + + let end = start + 1 + while (end < next.length && isSameDocumentTextRun(first, next[end])) end++ + if (end - start > 1) { + const run = next.slice(start, end) as Extract[] + const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint) + for (let index = 0; index < adjusted.length; index++) { + const line = adjusted[index] + if (line) next[start + index] = line + } + } + start = end + } + + return next +} + +function isDocumentTextLine( + geometry: FloorplanGeometry | undefined, +): geometry is Extract { + return geometry?.kind === 'text' && geometry.upright === true +} + +function isSameDocumentTextRun( + first: Extract, + candidate: FloorplanGeometry | undefined, +): candidate is Extract { + return ( + isDocumentTextLine(candidate) && + Math.abs(candidate.x - first.x) < 1e-6 && + candidate.textAnchor === first.textAnchor && + readFloorplanGeometryMetadata(candidate).annotationRole === + readFloorplanGeometryMetadata(first).annotationRole + ) +} + +function positionDocumentTextRun( + run: Extract[], + annotationUnitsPerPoint: number, +): Extract[] { + const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length + const steps = run.slice(0, -1).map((line, index) => { + const next = run[index + 1] ?? line + const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next)) + return largerFontPt * 1.25 * annotationUnitsPerPoint + }) + const totalHeight = steps.reduce((sum, step) => sum + step, 0) + let y = centerY - totalHeight / 2 + return run.map((line, index) => { + if (index > 0) y += steps[index - 1] ?? 0 + return { ...line, y } + }) +} + function renderNode( g: FloorplanGeometry, keyHint: number, pointerEventsOverride?: string, + sceneRotationDeg = 0, + annotationUnitsPerPoint?: number, + screenUnitsPerPixel?: number, + renderMode: FloorplanRenderMode = 'screen', ): React.ReactElement | null { switch (g.kind) { case 'path': - return + return ( + + ) case 'polygon': return ( ) @@ -93,34 +338,38 @@ function renderNode( ) - case 'rect': + case 'rect': { + const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint) return ( ) + } - case 'circle': + case 'circle': { + const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint) return ( ) + } case 'line': return ( @@ -130,25 +379,65 @@ function renderNode( x2={g.x2} y1={g.y1} y2={g.y2} - {...styleAttrs(g, pointerEventsOverride)} + {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)} /> ) - case 'text': + case 'text': { + const fontSize = + annotationUnitsPerPoint !== undefined + ? documentTextFontSize(g, annotationUnitsPerPoint) + : g.fontSize + const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint) + const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke + const fill = + pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff' + ? g.stroke + : (g.fill ?? '#171717') + if (g.upright) { + return ( + + + {g.text} + + + ) + } return ( ) + } + + case 'dimension': + return ( + + ) + + case 'dimension-string': + return ( + + ) + + case 'dimension-label': { + const unitsPerPixel = + annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL + const documentMode = annotationUnitsPerPoint !== undefined + const outlined = g.appearance === 'outlined' + const pdfOutlined = outlined && renderMode === 'pdf' + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10) + const textWidth = g.text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright) + const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})` + + return ( + + {outlined && !pdfOutlined ? null : ( + + )} + + {g.text} + + + ) + } case 'image': return ( @@ -174,15 +550,32 @@ function renderNode( case 'group': { const transform = formatTransform(g.transform) + const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint) return ( - - {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))} + + {children.map((child, i) => + renderNode( + child, + i, + pointerEventsOverride, + sceneRotationDeg, + annotationUnitsPerPoint, + screenUnitsPerPixel, + renderMode, + ), + )} ) } - // The interactive primitives (hatch / hit-line / endpoint-handle / - // dimension-label) need the SVG context + theme palette + units-per- + // The remaining interactive primitives (hatch / hit-line / endpoint-handle) + // need the SVG context + theme palette + units-per- // pixel that only the registry layer has access to. They're rendered // by `floorplan-registry-layer.tsx`'s interactive walker instead. If // a caller routes one of these through this pure renderer it diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index e2e208a8..c8ef7df1 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -3,15 +3,25 @@ import type { AnyNode, AnyNodeId, FloorplanAffordanceSession, + FloorplanGeometry, LiveNodeOverrides, } from '@pascal-app/core' import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { z } from 'zod' +import { + FLOORPLAN_NODE_EXTENSION_KEY, + floorplanGeometryMetadata, +} from '../../../lib/floorplan/floorplan-extension' import { cancelFloorplanAffordanceDrag, collectFloorplanDependencyNodes, + collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, floorplanHandleDoubleClickAffordance, + InteractiveGeometry, + splitFloorplanOverlay, subscribeFloorplanAffordanceToolCancel, } from './floorplan-registry-layer' @@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => { }) }) +describe('floorplan annotation overlay routing', () => { + test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => { + const noop = () => {} + const renderAt180Degrees = (geometry: FloorplanGeometry) => + renderToStaticMarkup( + createElement( + 'svg', + null, + createElement(InteractiveGeometry, { + activeDragId: null, + activeRotateNodeId: null, + geometry, + hatchPatternId: undefined, + hoveredHandleId: null, + isMarqueeSelectionActive: false, + nodeId: 'wall_test' as AnyNodeId, + onHandleDoubleClick: noop, + onHandleHoverChange: noop, + onHandlePointerDown: noop, + onMoveHandlePointerDown: noop, + palette: undefined, + sceneRotationDeg: 180, + unitsPerPixel: 0.01, + }), + ), + ) + const dimensionString = ( + end: readonly [number, number], + offsetNormal: readonly [number, number], + ): FloorplanGeometry => ({ + kind: 'dimension-string', + segments: [{ start: [0, 0], end, text: '2m' }], + offsetNormal, + offsetDistance: 0.55, + extensionOvershoot: 0.12, + textPosition: 'above', + }) + + expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)') + expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)') + }) + + test('keeps a fixed mark pill together in the overlay pass', () => { + const mark = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 }, + { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 }, + { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true }, + ], + } satisfies FloorplanGeometry + + expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark }) + }) + + test('keeps fixed annotation symbols in the overlay pass for collision layout', () => { + const columnCenter = { + kind: 'line', + x1: 0, + y1: 0, + x2: 1, + y2: 0, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + } satisfies FloorplanGeometry + + expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter }) + }) +}) + describe('computeAffectedSiblingIds', () => { beforeEach(() => { nodeRegistry._reset() @@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => { ]) }) }) + +describe('collectFloorplanLinkedLevelNodes', () => { + test('projects a node onto a linked destination level with its real children', () => { + nodeRegistry._reset() + registerNode({ + kind: 'linked-floorplan-test', + schemaVersion: 1, + schema: z.object({ type: z.literal('linked-floorplan-test') }) as never, + category: 'structure', + defaults: () => ({}) as never, + floorplan: () => null, + extensions: { + [FLOORPLAN_NODE_EXTENSION_KEY]: { + linkedLevelIds: () => ['level_upper' as AnyNodeId], + }, + }, + } as unknown as AnyNodeDefinition) + const child = { + id: 'linked_child', + type: 'linked-child', + parentId: 'linked_parent', + } as unknown as AnyNode + const parent = { + id: 'linked_parent', + type: 'linked-floorplan-test', + parentId: 'level_lower', + children: [child.id], + } as unknown as AnyNode + const nodes = { [parent.id]: parent, [child.id]: child } + + expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([ + { id: parent.id, node: parent, children: [child] }, + ]) + expect( + collectFloorplanLinkedLevelNodes( + nodes, + 'level_upper' as AnyNodeId, + new Set([parent.id as AnyNodeId]), + ), + ).toEqual([]) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index a94b7b48..3959bb0f 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -33,6 +33,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -47,6 +48,16 @@ import { snapDirectRotationDelta, } from '../../../lib/direct-manipulation' import { createEditorApi } from '../../../lib/editor-api' +import { + type FloorplanAnnotationVisibility, + filterFloorplanAnnotationGeometry, +} from '../../../lib/floorplan/annotation-visibility' +import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination' +import { + createFloorplanContextExtensions, + type FloorplanWallDimensionReference, + getFloorplanNodeExtension, +} from '../../../lib/floorplan/floorplan-extension' import { clientToPlan } from '../../../lib/floorplan/plan-coords' import { type ActiveInteractionScope, @@ -60,7 +71,10 @@ import { import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' +import useDrawingView from '../../../store/use-drawing-view' import useEditor from '../../../store/use-editor' +import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' +import useFloorplanPreflight from '../../../store/use-floorplan-preflight' import useInteractionScope, { useEndpointReshape, useMovingNode, @@ -74,6 +88,14 @@ import { startFloorplanGroupRotate, } from '../floorplan-group-move' import { useFloorplanRender } from '../floorplan-render-context' +import { + floorplanAnnotationObstacleMode, + isFloorplanAnnotationObstacleGeometry, + observeSvgAnnotationLayoutChanges, + resolveSvgAnnotationCollisions, + svgAnnotationLabelId, +} from './floorplan-annotation-layout' +import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { resolveFloorplanLabelAngle } from './floorplan-label-angle' @@ -276,6 +298,8 @@ type NodeDeps = { node: AnyNode live: LiveTransform | undefined unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference selected: boolean highlighted: boolean hovered: boolean @@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedBuildingId = useViewer((s) => s.selection.buildingId) const unit = useViewer((s) => s.unit) - const showMeasurements = useViewer((s) => s.showMeasurements) + const metricNotation = useViewer((s) => s.metricNotation) const selectedIds = useViewer((s) => s.selection.selectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) @@ -423,6 +447,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // selectors freeze to `undefined` so drag publishes do not re-render the // hidden floor-plan tree. const floorplanVisible = useEditor((s) => s.viewMode !== '3d') + const drawingType = useDrawingView((s) => s.drawingType) + const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility) + const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference) // Elevator builders read runtime state imperatively, so entries include this // rare-changing ref in their cache deps. const interactiveElevators = useInteractive((s) => s.elevators) @@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { if (!isNodeKindEnabled(node.type, installedPlugins)) return - const def = nodeRegistry.get(node.type) + const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) + if (!drawingNode) return + const def = nodeRegistry.get(drawingNode.type) if (!def?.floorplan) return - if (node.type === 'measurement' && !showMeasurements) return const dependsOnSiblingInputs = !!( def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides || def.floorplanAffectedIds ) - const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } + const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides out.push(descriptor) } @@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { visit(levelId as AnyNodeId) + const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined + if (activeLevelNode) { + const collectedIds = new Set(out.map((entry) => entry.id)) + for (const linked of collectFloorplanLinkedLevelNodes( + nodes, + levelId as AnyNodeId, + collectedIds, + )) { + pushEntry(linked.id, linked.node, { + children: linked.children, + siblings: [], + parent: activeLevelNode, + }) + } + } + // Building-scoped kinds (`def.floorplanScope === 'building'`) live // as siblings of the level, not under it — the `visit(levelId)` DFS // above doesn't reach them. Walk every node of those kinds whose @@ -871,7 +915,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // builders that gate on the current floor — e.g. elevator service // range — keep working). Pure registry-driven dispatch: no kind // name appears in this file. - const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined const activeBuildingId = activeLevelNode ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null @@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) } return { entries: out, levelNodeIdsByType } - }, [installedPlugins, levelId, nodes, showMeasurements]) + }, [drawingType, installedPlugins, levelId, nodes]) // ── Generic 2D affordance dispatch ───────────────────────────────── // @@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ))} + {/* Dashed group bbox — shows what a group drag carries along while a multi-selection exists, rides the live delta mid-drag, and doubles as the group's whole-area drag handle. */} @@ -1403,9 +1453,136 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) }) +function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { + const markerRef = useRef(null) + const [layoutEpoch, setLayoutEpoch] = useState(0) + const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) + const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) + const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) + const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) + useLayoutEffect(() => { + if (!active) return + const svg = markerRef.current?.ownerSVGElement + if (!svg) return + return observeSvgAnnotationLayoutChanges(svg, () => { + setLayoutEpoch((epoch) => epoch + 1) + }) + }, [active]) + useLayoutEffect(() => { + // The epoch is only a trigger; collision inputs are measured from the live SVG below. + void layoutEpoch + if (!active) { + resetPreflightIssues() + return + } + const svg = markerRef.current?.ownerSVGElement + if (!svg) return + const preflightIssues = resolveSvgAnnotationCollisions(svg, { + layoutOverrides: annotationLayoutOverrides, + }) + setPreflightIssues(preflightIssues) + + const labels = Array.from( + svg.querySelectorAll('[data-floorplan-annotation-label]'), + ) + const cleanup: Array<() => void> = [] + for (const [index, label] of labels.entries()) { + const id = svgAnnotationLabelId(label, index) + label.dataset.floorplanAnnotationId = id + label.style.pointerEvents = 'all' + label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + label.style.cursor = 'grabbing' + const matrix = label.getScreenCTM() + if (!matrix) return + const start = { x: event.clientX, y: event.clientY } + const existing = annotationLayoutOverrides[id] ?? { + ...readFloorplanAnnotationLayoutOffset(label), + pinned: true, + } + let latest = existing + let moved = false + const onPointerMove = (moveEvent: PointerEvent) => { + moved = true + const local = screenVectorToFloorplanAnnotationLocal( + matrix, + moveEvent.clientX - start.x, + moveEvent.clientY - start.y, + ) + latest = { + dx: existing.dx + local.x, + dy: existing.dy + local.y, + pinned: true, + } + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), + ) + } + const onPointerUp = () => { + label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + if (moved) setAnnotationLayoutOverride(id, latest) + } + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + } + const onDoubleClick = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setAnnotationLayoutOverride(id, null) + } + label.addEventListener('pointerdown', onPointerDown) + label.addEventListener('dblclick', onDoubleClick) + cleanup.push(() => { + label.removeEventListener('pointerdown', onPointerDown) + label.removeEventListener('dblclick', onDoubleClick) + label.style.pointerEvents = '' + label.style.cursor = '' + }) + } + return () => { + for (const fn of cleanup) fn() + } + }, [ + active, + annotationLayoutOverrides, + layoutEpoch, + resetPreflightIssues, + setAnnotationLayoutOverride, + setPreflightIssues, + ]) + return +} + +function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) { + const determinant = matrix.a * matrix.d - matrix.b * matrix.c + if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 } + return { + x: (matrix.d * dx - matrix.c * dy) / determinant, + y: (-matrix.b * dx + matrix.a * dy) / determinant, + } +} + +function readFloorplanAnnotationLayoutOffset(label: SVGGElement) { + const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0) + const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0) + return { + dx: Number.isFinite(dx) ? dx : 0, + dy: Number.isFinite(dy) ? dy : 0, + } +} + type FloorplanRegistryEntryProps = { activeDragId: string | null activeRotateNodeId: AnyNodeId | null + annotationVisibility: FloorplanAnnotationVisibility ctxOverrides: FloorplanContextOverrides | undefined floorplanVisible: boolean geometryCacheRef: { current: Map } @@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = { setMovingNodeOrigin: ReturnType['setMovingNodeOrigin'] siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference unitsPerPixel: number visibilityRootId: AnyNodeId | undefined } @@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = { const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ activeDragId, activeRotateNodeId, + annotationVisibility, ctxOverrides, floorplanVisible, geometryCacheRef, @@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ setMovingNodeOrigin, siblingEpoch, unit, + metricNotation, + wallDimensionReference, unitsPerPixel, visibilityRootId, }: FloorplanRegistryEntryProps): React.ReactElement | null { @@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }) const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null + const visibleGeometry = rawGeometry + ? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility) + : null // Multi-selection shows highlight only: strip this member's edit handles / // dimension chrome (all of which live in the overlay pass) while keeping // its highlighted body geometry. const geometry = - rawGeometry && suppressHandles && pass === 'overlay' - ? stripHandleChrome(rawGeometry) - : rawGeometry + visibleGeometry && suppressHandles && pass === 'overlay' + ? stripHandleChrome(visibleGeometry) + : visibleGeometry if (!geometry) return null const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop @@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = { selected: boolean siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference visibilityRootId: AnyNodeId | undefined } @@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }: BuildFloorplanEntryGeometryArgs): CacheEntry | null { const def = nodeRegistry.get(node.type) @@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({ node, live, unit, + metricNotation, + wallDimensionReference, selected, highlighted, hovered, @@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({ const viewState = { selected, unit, + metricNotation, + wallDimensionReference, highlighted, hovered, moving, @@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({ siblings: ctxOverrides.siblings, parent: ctxOverrides.parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation, + purpose: 'edit', + wallDimensionReference, + }), viewState: palette ? { selected, @@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = { onMoveHandlePointerDown: (event: ReactPointerEvent) => void } -const InteractiveGeometry = memo(function InteractiveGeometry({ +export const InteractiveGeometry = memo(function InteractiveGeometry({ geometry, unitsPerPixel, palette, @@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ case 'group': { const transform = formatGroupTransform(g.transform) return ( - + {g.children.map((child, i) => renderInteractive(child, i))} ) @@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ const textWidth = g.text.length * labelUnitsPerPixel * 6.2 const plateW = textWidth + padX * 2 const plateH = fontSize + padY * 2 + const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})` return ( {outlined ? null : ( - const stroke = g.stroke ?? palette.measurementStroke - // Offset endpoints along the outward normal — this is where the - // dimension line sits, parallel to the edge. - const ox = g.offsetNormal[0] * g.offsetDistance - const oy = g.offsetNormal[1] * g.offsetDistance - const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy] - const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy] - - // Extension line endpoints — extend past the dimension line by - // `extensionOvershoot` so the tip clears the dimension stroke. - const eOvershoot = g.extensionOvershoot - const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot) - const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot) - const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy] - const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy] - - const dx = dEnd[0] - dStart[0] - const dy = dEnd[1] - dStart[1] - const length = Math.hypot(dx, dy) - if (length < 1e-6) return <> - const dirX = dx / length - const dirY = dy / length - - // Plan-unit constants matching the legacy `floorplan- - // measurements-layer.tsx`. `strokeWidth` is intentionally a - // raw value (not multiplied by `unitsPerPixel`) because every - // stroke here uses `vectorEffect: non-scaling-stroke` — the - // browser interprets it as screen-pixel-stable. Multiplying - // by `unitsPerPixel` would shrink the strokes by ~100× and - // make them invisible. Tick length, dash pattern, font size, - // and the label gap stay in plan units (they're geometry, - // not stroke width). - const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2 - const perpX = -dirY * tickHalf - const perpY = dirX * tickHalf - - const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE - const labelGap = 0.5 // plan units — gap in the dimension line for the label - const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04) - - const midX = (dStart[0] + dEnd[0]) / 2 - const midY = (dStart[1] + dEnd[1]) / 2 - const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] - const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] - - // Keep the label parallel to the dimension line, but decide the - // 180° flip from the on-SCREEN angle, not the local one. The parent - // `` is rotated by `sceneRotationDeg` (default 90° in the floor - // plan), so a label kept upright in local coords still renders - // upside down for half of the wall orientations. Same fix as the - // `dimension-label` case above. - let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI - let screenDeg = labelDeg + sceneRotationDeg - screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 - if (screenDeg > 90) labelDeg -= 180 - else if (screenDeg <= -90) labelDeg += 180 - return ( - - {/* Extension lines (dashed). */} - - - {/* Dimension line: two halves with the label in between. */} - - - {/* End ticks. */} - - - {/* Rotated label centered in the gap. */} - - {g.text} - - + ) } case 'text': { @@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ // horizontally on screen even when the floor-plan view is // rotated (default `sceneRotationDeg` is 90°). return ( - + ) } @@ -2853,6 +2935,9 @@ export function buildContext( viewState: { selected: boolean unit: 'metric' | 'imperial' + metricNotation?: 'meters' | 'millimeters' + purpose?: 'edit' | 'document' + wallDimensionReference?: FloorplanWallDimensionReference highlighted: boolean hovered: boolean moving: boolean @@ -2892,6 +2977,11 @@ export function buildContext( siblings, parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation: viewState.metricNotation ?? 'meters', + purpose: viewState.purpose ?? 'edit', + wallDimensionReference: viewState.wallDimensionReference, + }), viewState: viewState.palette ? { selected: viewState.selected, @@ -2905,6 +2995,29 @@ export function buildContext( } } +export function collectFloorplanLinkedLevelNodes( + nodes: Record, + levelId: AnyNodeId, + excludedIds: ReadonlySet = new Set(), +): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> { + const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = [] + for (const [rawId, node] of Object.entries(nodes)) { + if (!node) continue + const definition = nodeRegistry.get(node.type) + const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds + if (!definition?.floorplan || !linkedLevelIds) continue + const id = rawId as AnyNodeId + if (excludedIds.has(id)) continue + if (!linkedLevelIds(node).includes(levelId)) continue + const childIds = (node as { children?: AnyNodeId[] }).children + const children = Array.isArray(childIds) + ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child) + : [] + linked.push({ id, node, children }) + } + return linked +} + /** * Stable id for a handle on a node, derived from the node id + opaque * payload. Used to track hover / active visual state when multiple @@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set([ 'move-arrow', 'rotate-arrow', 'dimension', + 'dimension-string', 'dimension-label', 'equal-spacing-badge', ]) @@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): { base: FloorplanGeometry | null overlay: FloorplanGeometry | null } { + if (isFloorplanAnnotationObstacleGeometry(g)) { + return { base: null, overlay: g } + } if (OVERLAY_KINDS.has(g.kind)) { return { base: null, overlay: g } } @@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { 'node', 'live', 'unit', + 'metricNotation', + 'wallDimensionReference', 'selected', 'highlighted', 'hovered', diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index deb71ffe..b1cef1bb 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) { return Math.max(minimum, Math.round(stair.stepCount ?? 10)) } +function getFloorplanStairBreakStep(stepCount: number) { + return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68)) +} + function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') { return 0 @@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) - const dashedFromIndex = Math.floor(stepCount * 0.68) + if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) { + return null + } return ( = dashedFromIndex ? '0.1 0.08' : undefined} strokeWidth={index === stepCount ? '1.8' : '1.15'} vectorEffect="non-scaling-stroke" x1={toSvgX(innerPoint.x)} @@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const angle = sectorStartAngle + stepSweep * index const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) + if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) { + return null + } return ( - {treadBars.map((treadBar, treadIndex) => ( - - ))} + {treadBars + .slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1)) + .map((treadBar, treadIndex) => ( + + ))} ))} {arrow?.polyline && arrow.polyline.length >= 2 ? ( diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9baf8848..b3d8b128 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer' +import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { @@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, frontSide: 'unknown', @@ -2719,9 +2721,10 @@ function formatMeasurement( value: number, unit: 'metric' | 'imperial', metersPerUnit: number | null = null, + metricNotation: 'meters' | 'millimeters' = 'meters', ) { const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value - return formatLinearMeasurement(measuredValue, unit) + return formatLinearMeasurement(measuredValue, unit, metricNotation) } function formatNumber(value: number, fractionDigits = 2) { @@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({ unitsPerPixel: number }) { const cursor = useFloorplanDraftPreview((s) => s.cursorPoint) + const metricNotation = useViewer((state) => state.metricNotation) if (!cursor) { return null } @@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({ label={`Ref ${formatMeasurement( Math.hypot(cursor[0] - start[0], cursor[1] - start[1]), unit, + null, + metricNotation, )}`} palette={palette} start={start} @@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({ unit: 'metric' | 'imperial' unitsPerPixel: number }) { + const metricNotation = useViewer((state) => state.metricNotation) if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) { return null } @@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({ labelAngleDeg += 180 } - const label = formatMeasurement(length, unit) + const label = formatMeasurement(length, unit, null, metricNotation) const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth) const height = fontSize + padY * 2 @@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({ unitsPerPixel: number sceneRotationDeg: number }) { + const metricNotation = useViewer((state) => state.metricNotation) const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) @@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({ } return { - lengthLabel: formatMeasurement(length, unit), + lengthLabel: formatMeasurement(length, unit, null, metricNotation), midpoint: [ (wallDraftStart[0] + wallDraftEnd[0]) / 2, (wallDraftStart[1] + wallDraftEnd[1]) / 2, @@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({ direction: [dx / length, dy / length] as WallPlanPoint, angleLabels, } - }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls]) + }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls]) return ( <> @@ -5244,6 +5252,7 @@ export function FloorplanPanel({ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) const showGrid = useViewer((state) => state.showGrid) const showGuides = useViewer((state) => state.showGuides) const setShowGuides = useViewer((state) => state.setShowGuides) @@ -11169,7 +11178,12 @@ export function FloorplanPanel({ Drawn line
- {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} + {formatMeasurement( + pendingReferenceScale.measuredLengthUnits, + unit, + null, + metricNotation, + )}
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({ + {floorplanSceneSlot} {/* Cursor-driven placement ghost for movingNode when the diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx index 793f9d2b..11d608d2 100644 --- a/packages/editor/src/components/editor/measurement-pill.tsx +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react' // Canonical in-world dimension formatter — metric metres or imperial // feet/inches. Shared by every measurement readout so they read the same. -export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string { +export function formatMeasurement( + value: number, + unit: 'metric' | 'imperial', + metricNotation: 'meters' | 'millimeters' = 'meters', +): string { if (unit === 'imperial') { const feet = value * 3.280_84 const wholeFeet = Math.floor(feet) @@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s if (inches === 12) return `${wholeFeet + 1}'0"` return `${wholeFeet}'${inches}"` } + if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm` return `${Number.parseFloat(value.toFixed(2))}m` } diff --git a/packages/editor/src/components/editor/quick-measurement-card.tsx b/packages/editor/src/components/editor/quick-measurement-card.tsx index 127d3093..420887e5 100644 --- a/packages/editor/src/components/editor/quick-measurement-card.tsx +++ b/packages/editor/src/components/editor/quick-measurement-card.tsx @@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react' import { memo } from 'react' import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements' -function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string { +function formatMetric( + metric: QuickMeasurementMetric, + unit: 'metric' | 'imperial', + metricNotation: 'meters' | 'millimeters', +): string { if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2) if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2) - return formatLinearMeasurement(metric.value, unit) + return formatLinearMeasurement(metric.value, unit, metricNotation) } export const QuickMeasurementCard = memo(function QuickMeasurementCard({ report, unit, + metricNotation, lensState, }: { report: QuickMeasurementReport unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' lensState: 'live' | 'pinned' }) { const pinned = lensState === 'pinned' @@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({ {metric.label}
- {formatMetric(metric, unit)} + {formatMetric(metric, unit, metricNotation)}
))} diff --git a/packages/editor/src/components/editor/quick-measurement-hud.tsx b/packages/editor/src/components/editor/quick-measurement-hud.tsx index dbd96fbb..df49068d 100644 --- a/packages/editor/src/components/editor/quick-measurement-hud.tsx +++ b/packages/editor/src/components/editor/quick-measurement-hud.tsx @@ -12,6 +12,7 @@ export function QuickMeasurementHud() { const viewMode = useEditor((state) => state.viewMode) const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode)) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) if (!entry) return null @@ -21,7 +22,12 @@ export function QuickMeasurementHud() { data-quick-measure-hud >
- +
) diff --git a/packages/editor/src/components/ui/action-menu/measurement-control.tsx b/packages/editor/src/components/ui/action-menu/measurement-control.tsx index 0a203d4f..d383868e 100644 --- a/packages/editor/src/components/ui/action-menu/measurement-control.tsx +++ b/packages/editor/src/components/ui/action-menu/measurement-control.tsx @@ -1,12 +1,17 @@ 'use client' -import { useViewer } from '@pascal-app/viewer' +import type { + ConstructionDimensionChainMode, + ConstructionDimensionMode, +} from '@pascal-app/core' import { Box, Check, ChevronDown, - Eye, - EyeOff, + CircleIcon, + Crosshair, + Grid2X2, + Minus, Ruler, ScanSearch, Square, @@ -37,27 +42,64 @@ const measurementMenuOptions = [ ...measurementOptions, ] as const +const constructionDimensionOptions = [ + { mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler }, + { mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints }, + { mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon }, + { mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon }, + { mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair }, + { mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus }, + { mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon }, + { mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle }, + { mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 }, +] as const satisfies readonly { + mode: ConstructionDimensionMode + chainMode: ConstructionDimensionChainMode + label: string + icon: typeof Ruler +}[] + export function MeasurementControl() { const [isOpen, setIsOpen] = useState(false) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const selectedKind = useEditor((state) => state.lastMeasurementKind) const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind) + const constructionDimensionChainMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.chainMode, + ) + const constructionDimensionMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.mode, + ) const setMode = useEditor((state) => state.setMode) const setPhase = useEditor((state) => state.setPhase) const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) const setToolDefaults = useEditor((state) => state.setToolDefaults) - const showMeasurements = useViewer((state) => state.showMeasurements) - const setShowMeasurements = useViewer((state) => state.setShowMeasurements) + const setViewMode = useEditor((state) => state.setViewMode) const selectedOption = measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0] const isActive = mode === 'build' && tool === 'measurement' + const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension' + const activeConstructionDimensionOption = constructionDimensionOptions.find( + (option) => + option.mode === (constructionDimensionMode ?? 'linear') && + option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'), + ) + const isControlActive = isActive || isConstructionDimensionActive const isSmartActive = isActive && activeToolKind === 'smart' - const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon - const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label + const SelectedIcon = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.icon ?? Ruler) + : isSmartActive + ? ScanSearch + : selectedOption.icon + const selectedLabel = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.label ?? 'Linear dimension') + : isSmartActive + ? 'Smart' + : selectedOption.label const activateMeasurement = (kind: CreatableMeasurementKind) => { setPhase('structure') @@ -69,7 +111,7 @@ export function MeasurementControl() { } const handlePrimaryClick = () => { - if (isActive) { + if (isControlActive) { setMode('select') return } @@ -84,15 +126,27 @@ export function MeasurementControl() { setTool('measurement') } + const activateConstructionDimension = ( + dimensionMode: ConstructionDimensionMode, + chainMode: ConstructionDimensionChainMode, + ) => { + setPhase('structure') + setStructureLayer('elements') + setViewMode('2d') + setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode }) + setMode('build') + setTool('construction-dimension') + } + return (
@@ -138,7 +192,7 @@ export function MeasurementControl() { const isSmart = option.kind === 'smart' const isSelected = isSmart ? isSmartActive - : !isSmartActive && option.kind === selectedKind + : !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind return ( + {constructionDimensionOptions.map((option) => { + const OptionIcon = option.icon + const isSelected = + isConstructionDimensionActive && activeConstructionDimensionOption === option + return ( + + ) + })}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 8c54a739..49ef0bdd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,4 +1,9 @@ -import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core' +import { + clearSceneHistory, + emitter, + useScene, + validateBuildJson, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' @@ -394,14 +399,14 @@ export function SettingsPanel({
-
Floorplan
+
Floor plan
+
+
+ + +
+ +
+ {WALL_DATUM_OPTIONS.map((option) => ( + + ))} +
+ + ))} + + + + ) +} + function WallFaceBandSection({ node, onUpdate, diff --git a/packages/nodes/src/wall/schema.ts b/packages/nodes/src/wall/schema.ts index fdd964f0..e96cea2e 100644 --- a/packages/nodes/src/wall/schema.ts +++ b/packages/nodes/src/wall/schema.ts @@ -8,5 +8,20 @@ * imports a single canonical type. */ -export type { WallNode as WallNodeType } from '@pascal-app/core' -export { WallNode } from '@pascal-app/core' +export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, + WallNode as WallNodeType, +} from '@pascal-app/core' +export { + getWallAssemblyDatumReferenceId, + getWallAssemblyLayers, + getWallAssemblyThickness, + getWallDatumEligibleLayers, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, + WallAssemblyLayerRole, + WallDimensionDatum, + WallNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 8f7b9cbc..98ef3220 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -234,6 +234,7 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode { visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, thickness: DRAFT_WALL_THICKNESS, diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index d2fa4467..4370cb3c 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,6 +6,11 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { + buildWindowFloorplanSchedule, + computeWindowFloorplanLevelData, +} from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' @@ -161,9 +166,14 @@ export const windowDefinition: NodeDefinition = { kind: 'window', snapProfile: 'item', facingIndicator: true, - schemaVersion: 1, + schemaVersion: 2, schema: WindowNode, category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildWindowFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, // Same schema-driven defaults trick as door: parse a stub, strip // id/type. Window also has many fields with zod `.default()` set. @@ -211,6 +221,7 @@ export const windowDefinition: NodeDefinition = { // Stage C: floor-plan polygon. ctx.parent gives the wall for direction // + thickness — same shape as door. floorplan: buildWindowFloorplan, + computeFloorplanLevelData: computeWindowFloorplanLevelData, floorplanDependsOnSiblings: true, // Opening symbols position from `ctx.parent` (the host wall); merge the // walls' live drag overrides so the symbol tracks a wall / group drag in diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index cb37e717..ddf3e670 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -5,6 +5,11 @@ import type { WallNode, WindowNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata } from '@pascal-app/editor' +import { + buildOpeningMarkAnnotation, + type OpeningFloorplanLevelData, +} from '../shared/opening-documentation' import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' /** @@ -102,6 +107,7 @@ export function buildWindowFloorplan( strokeWidth: showSelectedChrome ? 1.9 : 1.25, vectorEffect: 'non-scaling-stroke', strokeLinejoin: 'round', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), }, // Inset glass-pane outline. { @@ -169,5 +175,13 @@ export function buildWindowFloorplan( } } + const markAnnotation = buildOpeningMarkAnnotation( + node, + wall, + ctx.levelData as OpeningFloorplanLevelData | undefined, + { stroke: showSelectedChrome ? '#f97316' : '#334155' }, + ) + if (markAnnotation) children.push(markAnnotation) + return { kind: 'group', children } } diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index a1abde0f..1636f691 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -22,6 +22,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { @@ -220,6 +221,8 @@ export default function WindowPanel() { parentId: node.parentId, width: node.width, height: node.height, + roughOpeningWidth: node.roughOpeningWidth, + roughOpeningHeight: node.roughOpeningHeight, windowType: node.windowType, operationState: node.operationState, awningDirection: node.awningDirection, @@ -413,6 +416,21 @@ export default function WindowPanel() { /> + + + + {showWindowTypeSection && (
diff --git a/packages/nodes/src/window/schema.ts b/packages/nodes/src/window/schema.ts index f1f47eaa..addb4039 100644 --- a/packages/nodes/src/window/schema.ts +++ b/packages/nodes/src/window/schema.ts @@ -1 +1,5 @@ -export { WindowNode } from '@pascal-app/core' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index c527825c..bebd7fa9 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -3,6 +3,7 @@ import { resolveAutoZonePolygon, ZoneNode as ZoneNodeSchema, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildZoneFloorplan } from './floorplan' import { @@ -14,6 +15,7 @@ import { import { zoneFloorplanMoveTarget } from './floorplan-move' import { zoneParametrics } from './parametrics' import { zoneQuickMeasurement } from './quick-measurement' +import { buildRoomFloorplanSchedule } from './room-documentation' import { ZoneNode } from './schema' /** @@ -25,9 +27,14 @@ import { ZoneNode } from './schema' export const zoneDefinition: NodeDefinition = { kind: 'zone', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 2, schema: ZoneNode, category: 'site', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildRoomFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, defaults: () => { const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' }) diff --git a/packages/nodes/src/zone/floorplan.test.ts b/packages/nodes/src/zone/floorplan.test.ts new file mode 100644 index 00000000..ab666c7c --- /dev/null +++ b/packages/nodes/src/zone/floorplan.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { type FloorplanGeometry, type GeometryContext, ZoneNode } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildZoneFloorplan } from './floorplan' + +const context = { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, +} satisfies GeometryContext + +function textChildren(geometry: FloorplanGeometry | null) { + if (geometry?.kind !== 'group') return [] + return geometry.children.filter((child) => child.kind === 'text') +} + +describe('buildZoneFloorplan room documentation', () => { + test('keeps a generic zone label unchanged', () => { + const zone = ZoneNode.parse({ + id: 'zone_landscape', + name: 'Courtyard', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + + expect(textChildren(buildZoneFloorplan(zone, context))).toEqual([ + expect.objectContaining({ kind: 'text', text: 'Courtyard', upright: true }), + ]) + }) + + test('centers room name, number, finish, and height information as room annotations', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + }) + + const labels = textChildren(buildZoneFloorplan(room, context)) + expect(labels.map((label) => ('text' in label ? label.text : ''))).toEqual([ + 'Office', + '101', + 'FL: Timber · WL: Paint · CL: ACT', + 'CH: 2.7m · Business', + ]) + expect(labels.every((label) => label.kind === 'text' && label.upright)).toBe(true) + expect( + labels.every((label) => readFloorplanGeometryMetadata(label).annotationRole === 'room-label'), + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts index b8333dbb..8806f6fe 100644 --- a/packages/nodes/src/zone/floorplan.ts +++ b/packages/nodes/src/zone/floorplan.ts @@ -5,6 +5,12 @@ import { resolveAutoZonePolygon, type ZoneNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + formatConstructionLength, +} from '../shared/construction-length' +import { buildRoomClearDimensions } from './room-clear-dimensions' /** * Stage C floor-plan builder for zone. Zones are colored polygons — @@ -21,6 +27,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp if (!ring || ring.length < 3) return null const view = ctx.viewState + const floorplanContext = readFloorplanContext(ctx) const palette = view?.palette const isSelected = view?.selected ?? false const isHighlighted = view?.highlighted ?? false @@ -28,7 +35,8 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint) const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color - const fillOpacity = isSelected ? 0.28 : 0.16 + const isRoom = node.spaceRole === 'room' + const fillOpacity = isRoom ? (isSelected ? 0.12 : 0.04) : isSelected ? 0.28 : 0.16 const children: FloorplanGeometry[] = [ { @@ -91,9 +99,22 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp // it). Mirrors the legacy `FloorplanZoneLabel` so the look is // consistent. Centered on the polygon's area-weighted centroid; the // bbox-center fallback handles degenerate rings without throwing. + const [cx, cy] = polygonCentroid(ring) const name = node.name?.trim() - if (name) { - const [cx, cy] = polygonCentroid(ring) + if (isRoom) { + children.push( + ...buildRoomLabels( + node, + cx, + cy, + view?.unit ?? 'metric', + floorplanContext.purpose === 'document' ? 'document' : 'editor', + floorplanContext.metricNotation, + stroke, + ), + ) + children.push(...buildRoomClearDimensions(node, ctx)) + } else if (name) { children.push({ kind: 'text', x: cx, @@ -119,6 +140,61 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp } const ZONE_LABEL_FONT_SIZE = 0.2 +const ROOM_NAME_FONT_SIZE = 0.2 +const ROOM_NUMBER_FONT_SIZE = 0.16 +const ROOM_DETAIL_FONT_SIZE = 0.11 +const ROOM_LABEL_LINE_SPACING = 0.18 + +function buildRoomLabels( + node: ZoneNode, + x: number, + y: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: 'meters' | 'millimeters', + color: string, +): FloorplanGeometry[] { + const lines: Array<{ text: string; fontSize: number; fontWeight: number }> = [] + const name = node.name.trim() + if (name) lines.push({ text: name, fontSize: ROOM_NAME_FONT_SIZE, fontWeight: 700 }) + if (node.roomNumber) { + lines.push({ text: node.roomNumber, fontSize: ROOM_NUMBER_FONT_SIZE, fontWeight: 600 }) + } + + const finishes = [ + node.floorFinish ? `FL: ${node.floorFinish}` : '', + node.wallFinish ? `WL: ${node.wallFinish}` : '', + node.ceilingFinish ? `CL: ${node.ceilingFinish}` : '', + ].filter(Boolean) + if (finishes.length > 0) { + lines.push({ text: finishes.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + } + + const roomDetails = [ + `CH: ${formatConstructionLength(node.ceilingHeight, unit, profile, { metricNotation })}`, + ] + if (node.occupancy) roomDetails.push(node.occupancy) + lines.push({ text: roomDetails.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + + const startY = y - ((lines.length - 1) * ROOM_LABEL_LINE_SPACING) / 2 + return lines.map((line, index) => ({ + kind: 'text', + x, + y: startY + index * ROOM_LABEL_LINE_SPACING, + text: line.text, + fontSize: line.fontSize, + fill: color, + stroke: '#ffffff', + strokeWidth: line.fontSize * 0.18, + paintOrder: 'stroke', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: line.fontWeight, + textAnchor: 'middle', + dominantBaseline: 'central', + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + })) +} /** * Area-weighted centroid of a simple polygon (Shoelace formula). Falls diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index f07837fd..60668030 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -12,10 +12,12 @@ import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel, + MetricControl, PanelSection, + ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' type Point2D = readonly [number, number] @@ -151,6 +153,167 @@ function QuantityRow({ ) } +function RoomTextField({ + label, + onCommit, + value, +}: { + label: string + onCommit: (value: string) => void + value: string +}) { + const [draft, setDraft] = useState(value) + const cancelRef = useRef(false) + + useEffect(() => setDraft(value), [value]) + + const commit = () => { + if (cancelRef.current) { + cancelRef.current = false + setDraft(value) + return + } + const next = draft.trim() + if (next !== value) onCommit(next) + else setDraft(value) + } + + return ( + + ) +} + +function RoomSelect({ + label, + onChange, + options, + value, +}: { + label: string + onChange: (value: string) => void + options: ReadonlyArray<{ label: string; value: string }> + value: string +}) { + return ( + + ) +} + +function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { + const updateNode = useScene((state) => state.updateNode) + const update = (patch: Partial) => updateNode(zone.id, patch) + const isRoom = zone.spaceRole === 'room' + + return ( + + update({ spaceRole: checked ? 'room' : 'generic' })} + /> + {isRoom ? ( + <> + update({ name })} + value={zone.name} + /> + update({ roomNumber })} + value={zone.roomNumber} + /> + + update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] }) + } + options={[ + { label: 'Auto-detect', value: 'auto' }, + { label: 'Enclosed', value: 'enclosed' }, + { label: 'Open', value: 'open' }, + ]} + value={zone.enclosureStatus} + /> + update({ occupancy })} + value={zone.occupancy} + /> + update({ floorFinish })} + value={zone.floorFinish} + /> + update({ wallFinish })} + value={zone.wallFinish} + /> + update({ ceilingFinish })} + value={zone.ceilingFinish} + /> + update({ ceilingHeight })} + precision={2} + step={0.05} + unit="m" + value={zone.ceilingHeight} + /> + + update({ + clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'], + }) + } + options={[ + { label: 'None', value: 'none' }, + { label: 'Inside faces', value: 'inside-faces' }, + { label: 'Finish faces', value: 'finish-faces' }, + ]} + value={zone.clearDimensionPolicy} + /> + + ) : null} + + ) +} + export default function ZoneQuantitiesPanel() { const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) @@ -193,48 +356,53 @@ export default function ZoneQuantitiesPanel() { if (!effectiveZone || !report) return null return ( - -
-
- {effectiveZone.name} - - {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} - + <> + + +
+
+ {effectiveZone.name} + + {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} + +
+
+ A + {formatAreaLabel(report.footprintArea, unit, 2)} + P + {formatLinearMeasurement(report.perimeter, unit)} +
-
- A - {formatAreaLabel(report.footprintArea, unit, 2)} - P - {formatLinearMeasurement(report.perimeter, unit)} + + + +
+ formatAreaLabel(value, unit, 2)} + label="Wall surface" + quantity={report.wallSurface} + /> + formatAreaLabel(value, unit, 2)} + label="Floor surface" + quantity={report.floorSurface} + /> + formatVolumeLabel(value, unit, 2)} + label="Volume" + quantity={report.volume} + />
-
- - - -
- formatAreaLabel(value, unit, 2)} - label="Wall surface" - quantity={report.wallSurface} - /> - formatAreaLabel(value, unit, 2)} - label="Floor surface" - quantity={report.floorSurface} - /> - formatVolumeLabel(value, unit, 2)} - label="Volume" - quantity={report.volume} - /> -
-
+ + ) } diff --git a/packages/nodes/src/zone/room-clear-dimensions.test.ts b/packages/nodes/src/zone/room-clear-dimensions.test.ts new file mode 100644 index 00000000..14f1afcd --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type FloorplanGeometry, + type GeometryContext, + WallNode, + ZoneNode, +} from '@pascal-app/core' +import { createFloorplanContextExtensions } from '@pascal-app/editor' +import { buildRoomClearDimensions } from './room-clear-dimensions' + +function enclosure(points: Array<[number, number]>) { + const walls = points.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_main', + start, + end: points[(index + 1) % points.length], + thickness: 0.2, + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: points, + autoFromWalls: true, + boundaryWallIds: walls.map((wall) => wall.id), + spaceRole: 'room', + clearDimensionPolicy: 'inside-faces', + }) + const nodes = Object.fromEntries([...walls, zone].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + return { context, nodes, walls, zone } +} + +function withFinishAssembly(wall: WallNode): WallNode { + return WallNode.parse({ + ...wall, + thickness: undefined, + assemblyLayers: [ + { + id: `${wall.id}_core`, + role: 'structure', + side: 'core', + thickness: 0.2, + datumEligible: ['structural-face'], + }, + { + id: `${wall.id}_interior-finish`, + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + { + id: `${wall.id}_exterior-finish`, + role: 'exterior-finish', + side: 'exterior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + ], + }) +} + +function dimensions(geometry: FloorplanGeometry[]) { + return geometry.filter( + (entry): entry is Extract => + entry.kind === 'dimension', + ) +} + +describe('buildRoomClearDimensions', () => { + test('dimensions the proven inside faces of a rectangular room', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.8m', '3.8m']) + expect(result.every((entry) => entry.stroke === '#123456')).toBe(true) + expect(result[0]?.start[0]).toBeCloseTo(1.316) + expect(result[0]?.start[1]).toBeCloseTo(0.1) + expect(result[0]?.end[0]).toBeCloseTo(1.316) + expect(result[0]?.end[1]).toBeCloseTo(2.9) + }) + + test('preserves clear spans when the room is rotated', () => { + const angle = Math.PI / 6 + const rotate = ([x, y]: [number, number]): [number, number] => [ + x * Math.cos(angle) - y * Math.sin(angle), + x * Math.sin(angle) + y * Math.cos(angle), + ] + const { context, zone } = enclosure( + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ].map(rotate), + ) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('consolidates collinear wall segments before proving the clear rectangle', () => { + const { context, zone } = enclosure([ + [0, 0], + [2, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('dimensions finish faces when every boundary wall has assembly finish datums', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const assembledWalls = walls.map(withFinishAssembly) + const assembledNodes = { ...nodes } + for (const wall of assembledWalls) assembledNodes[wall.id] = wall + + const result = dimensions( + buildRoomClearDimensions( + { ...zone, clearDimensionPolicy: 'finish-faces' }, + { + ...context, + resolve: (id) => assembledNodes[id], + }, + ), + ) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m']) + }) + + test('adds a room-to-room finish-face dimension for adjacent rectangular rooms', () => { + const walls = [ + WallNode.parse({ id: 'wall_a_bottom', parentId: 'level_main', start: [0, 0], end: [4, 0] }), + WallNode.parse({ id: 'wall_shared', parentId: 'level_main', start: [4, 0], end: [4, 3] }), + WallNode.parse({ id: 'wall_a_top', parentId: 'level_main', start: [4, 3], end: [0, 3] }), + WallNode.parse({ id: 'wall_a_left', parentId: 'level_main', start: [0, 3], end: [0, 0] }), + WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }), + WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }), + WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }), + ].map(withFinishAssembly) + const zoneA = ZoneNode.parse({ + id: 'zone_a', + parentId: 'level_main', + name: 'A', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_a_bottom', 'wall_shared', 'wall_a_top', 'wall_a_left'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const zoneB = ZoneNode.parse({ + id: 'zone_b', + parentId: 'level_main', + name: 'B', + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_b_bottom', 'wall_b_right', 'wall_b_top', 'wall_shared'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const nodes = Object.fromEntries( + [...walls, zoneA, zoneB].map((node) => [node.id, node]), + ) as Record + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [zoneB], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + + const result = dimensions(buildRoomClearDimensions(zoneA, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m', 'R-R 0.24m']) + expect(result.find((entry) => entry.text.startsWith('R-R'))?.text).toBe('R-R 0.24m') + }) + + test('dimensions proven rectilinear room bays beyond simple rectangles', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 2], + [2, 4], + [0, 4], + ]) + + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m']) + }) + + test('suppresses dimensions when the requested datum cannot be proven', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([]) + expect( + buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context), + ).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([]) + + const missingWallNodes = { ...nodes } + delete missingWallNodes[walls[0]!.id] + expect( + buildRoomClearDimensions(zone, { + ...context, + resolve: (id) => missingWallNodes[id], + }), + ).toEqual([]) + }) + + test('suppresses dimensions for a proven enclosure that is not rectangular', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 3], + [0, 2], + ]) + + expect(buildRoomClearDimensions(zone, context)).toEqual([]) + }) +}) diff --git a/packages/nodes/src/zone/room-clear-dimensions.ts b/packages/nodes/src/zone/room-clear-dimensions.ts new file mode 100644 index 00000000..18e2576c --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.ts @@ -0,0 +1,609 @@ +import { + detectSpacesForLevel, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallAssemblyFaceOffsets, + resolveWallAssemblyDatumReferences, + type SpaceBoundaryFace, + type WallNode, + type ZoneNode, +} from '@pascal-app/core' +import { readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionMetricNotation, + formatConstructionLength, +} from '../shared/construction-length' + +const LINE_TOLERANCE = 1e-4 +const ANGLE_TOLERANCE = 1e-3 +const MIN_CLEAR_SPAN = 0.3 +const MIN_ROOM_TO_ROOM_SPAN = 0.03 +const FIRST_DIMENSION_POSITION = 0.32 +const SECOND_DIMENSION_POSITION = 0.68 +const EXTENSION_OVERSHOOT = 0.08 + +type FaceLine = { + start: FloorplanPoint + end: FloorplanPoint +} + +type DimensionGeometry = Extract + +type ClearDimensionPolicy = Extract< + ZoneNode['clearDimensionPolicy'], + 'inside-faces' | 'finish-faces' +> + +export function buildRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, +): FloorplanGeometry[] { + if ( + node.spaceRole !== 'room' || + (node.clearDimensionPolicy !== 'inside-faces' && + node.clearDimensionPolicy !== 'finish-faces') || + node.enclosureStatus === 'open' || + !node.autoFromWalls || + !node.parentId || + node.boundaryWallIds.length < 3 + ) { + return [] + } + + const walls = node.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (walls.length !== node.boundaryWallIds.length) return [] + + const boundaryIds = new Set(node.boundaryWallIds) + const space = detectSpacesForLevel(node.parentId, walls).spaces.find( + (candidate) => + candidate.wallIds.length === boundaryIds.size && + candidate.wallIds.every((id) => boundaryIds.has(id)), + ) + if (!space) return [] + + const wallsById = new Map(walls.map((wall) => [wall.id, wall])) + const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy) + if (!faceLines) return [] + + const unit = ctx.viewState?.unit ?? 'metric' + const floorplanContext = readFloorplanContext(ctx) + const profile: ConstructionLengthProfile = + floorplanContext.purpose === 'document' ? 'document' : 'editor' + const metricNotation = floorplanContext.metricNotation + const stroke = ctx.viewState?.palette.measurementStroke ?? '#475569' + const rectangle = resolveClearFaceRectangle(faceLines) + const dimensions = rectangle + ? buildRectangleClearDimensions(rectangle, unit, profile, metricNotation, stroke) + : buildRectilinearClearDimensions(faceLines, unit, profile, metricNotation, stroke) + if (dimensions.length === 0) return [] + return [ + ...dimensions, + ...buildRoomToRoomClearDimensions( + node, + ctx, + space.boundaryFaces, + wallsById, + unit, + profile, + metricNotation, + stroke, + ), + ] +} + +function resolveClearFaceLines( + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + policy: ClearDimensionPolicy, +): FaceLine[] | null { + const faceLines: FaceLine[] = [] + for (const boundary of boundaryFaces) { + const wall = wallsById.get(boundary.wallId) + if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null + const line = offsetBoundaryFace(boundary, wall, policy) + if (!line) return null + faceLines.push(line) + } + + const merged = mergeCollinearFaces(faceLines) + return merged.length >= 4 ? merged : null +} + +function resolveClearFaceRectangle( + merged: readonly FaceLine[], +): [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] | null { + if (merged.length !== 4) return null + + const vertices = merged.map((line, index) => { + const previous = merged[(index + merged.length - 1) % merged.length]! + return intersectLines(previous, line) + }) + if (vertices.some((vertex) => vertex === null)) return null + const rectangle = vertices as [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] + const directions = rectangle.map((start, index) => + normalizedDirection(start, rectangle[(index + 1) % rectangle.length]!), + ) + if (directions.some((direction) => direction === null)) return null + const [first, second, third, fourth] = directions as [ + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + ] + if ( + Math.abs(dot(first, second)) > ANGLE_TOLERANCE || + Math.abs(dot(second, third)) > ANGLE_TOLERANCE || + Math.abs(dot(third, fourth)) > ANGLE_TOLERANCE || + Math.abs(dot(fourth, first)) > ANGLE_TOLERANCE || + dot(first, third) > -1 + ANGLE_TOLERANCE || + dot(second, fourth) > -1 + ANGLE_TOLERANCE + ) { + return null + } + return rectangle +} + +function buildRectangleClearDimensions( + rectangle: [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const first = dimensionAcrossOppositeFaces( + rectangle[0], + rectangle[1], + rectangle[3], + rectangle[2], + FIRST_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + const second = dimensionAcrossOppositeFaces( + rectangle[1], + rectangle[2], + rectangle[0], + rectangle[3], + SECOND_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + return first && second ? [first, second] : [] +} + +function buildRectilinearClearDimensions( + faceLines: readonly FaceLine[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const vertices = clearFacePolygon(faceLines) + if (!vertices || !isRectilinearPolygon(vertices)) return [] + + const dimensions: FloorplanGeometry[] = [] + const seen = new Set() + for (let firstIndex = 0; firstIndex < faceLines.length; firstIndex++) { + const first = faceLines[firstIndex]! + const firstDirection = normalizedDirection(first.start, first.end) + if (!firstDirection) return [] + + for (let secondIndex = firstIndex + 1; secondIndex < faceLines.length; secondIndex++) { + const second = faceLines[secondIndex]! + const secondDirection = normalizedDirection(second.start, second.end) + if (!secondDirection) return [] + if (Math.abs(dot(firstDirection, secondDirection)) < 1 - ANGLE_TOLERANCE) continue + + const dimension = dimensionBetweenOverlappingParallelFaces( + first, + second, + firstDirection, + vertices, + unit, + profile, + metricNotation, + stroke, + ) + if (!dimension) continue + const key = dimensionKey(dimension) + if (seen.has(key)) continue + seen.add(key) + dimensions.push(dimension) + } + } + return dimensions +} + +function offsetBoundaryFace( + boundary: SpaceBoundaryFace, + wall: WallNode, + policy: ClearDimensionPolicy, +): FaceLine | null { + const first = boundary.points[0] + const last = boundary.points[boundary.points.length - 1] + if (!(first && last)) return null + + const wallDirection = normalizedDirection(wall.start, wall.end) + if (!wallDirection) return null + const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]] + const side = boundary.face === 'front' ? 1 : -1 + const faces = getWallAssemblyFaceOffsets(wall) + const offset = + policy === 'finish-faces' + ? resolveFinishFaceOffset(wall, side) + : side > 0 + ? faces.exterior + : faces.interior + if (offset === null) return null + return { + start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset], + end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset], + } +} + +function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null { + if ((wall.assemblyLayers ?? []).length === 0) return null + const references = resolveWallAssemblyDatumReferences(wall).filter( + (reference) => reference.datum === 'finish-face', + ) + const matching = references + .filter((reference) => Math.sign(reference.offset) === side) + .map((reference) => reference.offset) + if (matching.length === 0) return null + return side > 0 ? Math.max(...matching) : Math.min(...matching) +} + +function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null { + const vertices = faceLines.map((line, index) => { + const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]! + return intersectLines(previous, line) + }) + return vertices.some((vertex) => vertex === null) ? null : (vertices as FloorplanPoint[]) +} + +function isRectilinearPolygon(vertices: readonly FloorplanPoint[]): boolean { + if (vertices.length < 4) return false + const directions = vertices.map((start, index) => + normalizedDirection(start, vertices[(index + 1) % vertices.length]!), + ) + if (directions.some((direction) => direction === null)) return false + for (let index = 0; index < directions.length; index++) { + const current = directions[index]! + const next = directions[(index + 1) % directions.length]! + if (Math.abs(dot(current, next)) > ANGLE_TOLERANCE) return false + } + return true +} + +function dimensionBetweenOverlappingParallelFaces( + first: FaceLine, + second: FaceLine, + direction: FloorplanPoint, + polygon: readonly FloorplanPoint[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const firstStart = dot(first.start, direction) + const firstEnd = dot(first.end, direction) + const secondStart = dot(second.start, direction) + const secondEnd = dot(second.end, direction) + const overlapStart = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd)) + const overlapEnd = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd)) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(first, direction, projection) + const end = projectPointToLineProjection(second, direction, projection) + const midpoint: FloorplanPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (!pointInPolygon(midpoint, polygon)) return null + + const axis = normalizedDirection(start, end) + if (!axis) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function pointInPolygon(point: FloorplanPoint, polygon: readonly FloorplanPoint[]): boolean { + let inside = false + for ( + let index = 0, previousIndex = polygon.length - 1; + index < polygon.length; + previousIndex = index++ + ) { + const current = polygon[index]! + const previous = polygon[previousIndex]! + const intersects = + current[1] > point[1] !== previous[1] > point[1] && + point[0] < + ((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) + + current[0] + if (intersects) inside = !inside + } + return inside +} + +function dimensionKey(dimension: DimensionGeometry): string { + const first = `${roundKey(dimension.start[0])},${roundKey(dimension.start[1])}` + const second = `${roundKey(dimension.end[0])},${roundKey(dimension.end[1])}` + return first < second ? `${first}|${second}` : `${second}|${first}` +} + +function roundKey(value: number): number { + return Math.round(value / LINE_TOLERANCE) +} + +function buildRoomToRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + if (node.clearDimensionPolicy !== 'finish-faces') return [] + + const neighboringRooms = ctx.siblings.filter( + (sibling): sibling is ZoneNode => + sibling.type === 'zone' && + sibling.id !== node.id && + String(node.id) < String(sibling.id) && + sibling.spaceRole === 'room' && + sibling.clearDimensionPolicy === 'finish-faces' && + sibling.enclosureStatus !== 'open' && + sibling.autoFromWalls && + sibling.parentId === node.parentId, + ) + if (neighboringRooms.length === 0) return [] + + const dimensions: FloorplanGeometry[] = [] + const currentBoundaryByWallId = new Map( + boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const neighbor of neighboringRooms) { + const sharedWallIds = neighbor.boundaryWallIds.filter((wallId) => + currentBoundaryByWallId.has(wallId), + ) + if (sharedWallIds.length === 0) continue + + const neighborWalls = neighbor.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (neighborWalls.length !== neighbor.boundaryWallIds.length) continue + const neighborWallIds = new Set(neighbor.boundaryWallIds) + const neighborSpace = detectSpacesForLevel(neighbor.parentId ?? '', neighborWalls).spaces.find( + (candidate) => + candidate.wallIds.length === neighborWallIds.size && + candidate.wallIds.every((id) => neighborWallIds.has(id)), + ) + if (!neighborSpace) continue + const neighborBoundaryByWallId = new Map( + neighborSpace.boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const wallId of sharedWallIds) { + const wall = wallsById.get(wallId) + const currentBoundary = currentBoundaryByWallId.get(wallId) + const neighborBoundary = neighborBoundaryByWallId.get(wallId) + if (!(wall && currentBoundary && neighborBoundary)) continue + const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces') + const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces') + if (!(currentLine && neighborLine)) continue + const dimension = dimensionAcrossSharedRoomWall( + currentLine, + neighborLine, + unit, + profile, + metricNotation, + stroke, + ) + if (dimension) dimensions.push(dimension) + } + } + + return dimensions +} + +function dimensionAcrossSharedRoomWall( + currentLine: FaceLine, + neighborLine: FaceLine, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const direction = normalizedDirection(currentLine.start, currentLine.end) + if (!direction) return null + const neighborDirection = normalizedDirection(neighborLine.start, neighborLine.end) + if (!neighborDirection || Math.abs(dot(direction, neighborDirection)) < 1 - ANGLE_TOLERANCE) { + return null + } + + const currentStart = dot(currentLine.start, direction) + const currentEnd = dot(currentLine.end, direction) + const neighborStart = dot(neighborLine.start, direction) + const neighborEnd = dot(neighborLine.end, direction) + const overlapStart = Math.max( + Math.min(currentStart, currentEnd), + Math.min(neighborStart, neighborEnd), + ) + const overlapEnd = Math.min( + Math.max(currentStart, currentEnd), + Math.max(neighborStart, neighborEnd), + ) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(currentLine, direction, projection) + const end = projectPointToLineProjection(neighborLine, direction, projection) + const clear = distance(start, end) + if (clear < MIN_ROOM_TO_ROOM_SPAN) return null + const axis = normalizedDirection(start, end) + if (!axis) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: `R-R ${formatConstructionLength(clear, unit, profile, { metricNotation })}`, + stroke, + } +} + +function projectPointToLineProjection( + line: FaceLine, + direction: FloorplanPoint, + projection: number, +): FloorplanPoint { + const originProjection = dot(line.start, direction) + return [ + line.start[0] + direction[0] * (projection - originProjection), + line.start[1] + direction[1] * (projection - originProjection), + ] +} + +function mergeCollinearFaces(lines: readonly FaceLine[]): FaceLine[] { + const merged: FaceLine[] = [] + for (const line of lines) { + const previous = merged[merged.length - 1] + if (previous && canMerge(previous, line)) previous.end = line.end + else merged.push({ ...line }) + } + + while (merged.length > 1) { + const first = merged[0]! + const last = merged[merged.length - 1]! + if (!canMerge(last, first)) break + first.start = last.start + merged.pop() + } + return merged +} + +function canMerge(first: FaceLine, second: FaceLine): boolean { + const firstDirection = normalizedDirection(first.start, first.end) + const secondDirection = normalizedDirection(second.start, second.end) + if (!(firstDirection && secondDirection)) return false + return ( + dot(firstDirection, secondDirection) > 1 - ANGLE_TOLERANCE && + pointLineDistance(second.start, first) <= LINE_TOLERANCE + ) +} + +function intersectLines(first: FaceLine, second: FaceLine): FloorplanPoint | null { + const firstDirection: FloorplanPoint = [ + first.end[0] - first.start[0], + first.end[1] - first.start[1], + ] + const secondDirection: FloorplanPoint = [ + second.end[0] - second.start[0], + second.end[1] - second.start[1], + ] + const denominator = cross(firstDirection, secondDirection) + if (Math.abs(denominator) <= LINE_TOLERANCE) return null + const delta: FloorplanPoint = [second.start[0] - first.start[0], second.start[1] - first.start[1]] + const parameter = cross(delta, secondDirection) / denominator + return [ + first.start[0] + firstDirection[0] * parameter, + first.start[1] + firstDirection[1] * parameter, + ] +} + +function dimensionAcrossOppositeFaces( + firstStart: FloorplanPoint, + firstEnd: FloorplanPoint, + oppositeStart: FloorplanPoint, + oppositeEnd: FloorplanPoint, + position: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry | null { + const start = interpolate(firstStart, firstEnd, position) + const end = interpolate(oppositeStart, oppositeEnd, position) + const direction = normalizedDirection(start, end) + if (!direction) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + return { + kind: 'dimension', + start, + end, + offsetNormal: [-direction[1], direction[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function normalizedDirection( + start: readonly [number, number], + end: readonly [number, number], +): FloorplanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + return length <= LINE_TOLERANCE ? null : [dx / length, dy / length] +} + +function pointLineDistance(point: FloorplanPoint, line: FaceLine): number { + const direction = normalizedDirection(line.start, line.end) + if (!direction) return Number.POSITIVE_INFINITY + return Math.abs(cross(direction, [point[0] - line.start[0], point[1] - line.start[1]])) +} + +function interpolate(start: FloorplanPoint, end: FloorplanPoint, t: number): FloorplanPoint { + return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t] +} + +function distance(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +function dot(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[0] + first[1] * second[1] +} + +function cross(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[1] - first[1] * second[0] +} diff --git a/packages/nodes/src/zone/room-documentation.test.ts b/packages/nodes/src/zone/room-documentation.test.ts new file mode 100644 index 00000000..8d5bf2ab --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode, ZoneNode } from '@pascal-app/core' +import { buildRoomFloorplanSchedule } from './room-documentation' + +function room(overrides: Partial = {}) { + return ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + ...overrides, + }) +} + +function nodesFor(zones: ZoneNode[]) { + const level = LevelNode.parse({ + id: 'level_main', + children: zones.map((zone) => zone.id), + }) + return Object.fromEntries([level, ...zones].map((node) => [node.id, node])) as Record< + string, + AnyNode + > +} + +describe('buildRoomFloorplanSchedule', () => { + test('includes only architectural rooms and formats their documented values', () => { + const office = room({ id: 'zone_office', roomNumber: '102' }) + const lobby = room({ + id: 'zone_lobby', + name: 'Lobby', + roomNumber: '101', + polygon: [ + [0, 0], + [5, 0], + [5, 2], + [0, 2], + ], + floorFinish: '', + }) + const courtyard = room({ + id: 'zone_courtyard', + name: 'Courtyard', + roomNumber: '100', + spaceRole: 'generic', + }) + const zones = [office, lobby, courtyard] + + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.title).toBe('ROOM SCHEDULE') + expect(schedule?.rows.map((row) => row.id)).toEqual(['zone_lobby', 'zone_office']) + expect(schedule?.rows[0]?.cells).toMatchObject({ + number: '101', + name: 'Lobby', + area: '10.00 m²', + floorFinish: '—', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: '2700', + occupancy: 'Business', + enclosure: 'Open', + }) + }) + + test('formats imperial schedule values', () => { + const office = room({ + polygon: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + }) + const schedule = buildRoomFloorplanSchedule({ + siblings: [office], + nodes: nodesFor([office]), + levelId: 'level_main', + unit: 'imperial', + }) + + expect(schedule?.rows[0]?.cells).toMatchObject({ + area: '10.8 ft²', + ceilingHeight: `8'-10 5/16"`, + }) + }) + + test('reports missing and duplicate room numbers plus unproven enclosure claims', () => { + const unnumbered = room({ + id: 'zone_unnumbered', + name: 'Storage', + roomNumber: '', + }) + const duplicateA = room({ id: 'zone_a', roomNumber: 'A01' }) + const duplicateB = room({ + id: 'zone_b', + name: 'Meeting', + roomNumber: 'a01', + enclosureStatus: 'enclosed', + }) + const zones = [unnumbered, duplicateA, duplicateB] + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.issues).toEqual([ + 'Room Storage has no room number', + 'Room a01 is marked enclosed but not proven', + 'Duplicate room number A01 (2 rooms)', + ]) + }) + + test('returns no schedule when the level has no architectural rooms', () => { + const zone = room({ spaceRole: 'generic' }) + expect( + buildRoomFloorplanSchedule({ + siblings: [zone], + nodes: nodesFor([zone]), + levelId: 'level_main', + unit: 'metric', + }), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/zone/room-documentation.ts b/packages/nodes/src/zone/room-documentation.ts new file mode 100644 index 00000000..7032cca5 --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.ts @@ -0,0 +1,125 @@ +import { + type AnyNode, + deriveZoneQuantityReport, + resolveAutoZonePolygon, + type ZoneNode, +} from '@pascal-app/core' +import type { FloorplanSchedule } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionLinearUnit, + formatConstructionLength, +} from '../shared/construction-length' + +const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671 +const ROOM_NUMBER_COLLATOR = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }) + +export function buildRoomFloorplanSchedule(args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: string + unit: ConstructionLinearUnit + profile?: ConstructionLengthProfile +}): FloorplanSchedule | null { + const rooms = args.siblings + .filter((zone) => zone.spaceRole === 'room') + .map((zone) => { + const polygon = resolveAutoZonePolygon(zone, (id) => args.nodes[id]) + const resolvedZone = polygon === zone.polygon ? zone : { ...zone, polygon } + return { zone: resolvedZone, report: deriveZoneQuantityReport(resolvedZone, args.nodes) } + }) + .sort((a, b) => compareRooms(a.zone, b.zone)) + + if (rooms.length === 0) return null + + return { + id: 'rooms', + title: 'ROOM SCHEDULE', + columns: [ + { key: 'number', label: 'NO.', weight: 0.7 }, + { key: 'name', label: 'ROOM NAME', weight: 1.35 }, + { key: 'area', label: 'AREA', weight: 0.9 }, + { key: 'floorFinish', label: 'FLOOR FINISH', weight: 1.15 }, + { key: 'wallFinish', label: 'WALL FINISH', weight: 1.15 }, + { key: 'ceilingFinish', label: 'CEILING FINISH', weight: 1.15 }, + { key: 'ceilingHeight', label: 'CLG. HT.', weight: 0.9 }, + { key: 'occupancy', label: 'OCCUPANCY / USE', weight: 1.25 }, + { key: 'enclosure', label: 'ENCLOSURE', weight: 0.9 }, + ], + rows: rooms.map(({ zone, report }) => ({ + id: zone.id, + cells: { + number: valueOrDash(zone.roomNumber), + name: valueOrDash(zone.name), + area: formatRoomArea(report.footprintArea, args.unit), + floorFinish: valueOrDash(zone.floorFinish), + wallFinish: valueOrDash(zone.wallFinish), + ceilingFinish: valueOrDash(zone.ceilingFinish), + ceilingHeight: formatConstructionLength( + zone.ceilingHeight, + args.unit, + args.profile ?? 'document', + ), + occupancy: valueOrDash(zone.occupancy), + enclosure: resolveEnclosure(zone, report.classification), + }, + })), + issues: collectRoomScheduleIssues(rooms), + } +} + +function compareRooms(a: ZoneNode, b: ZoneNode): number { + const numberComparison = ROOM_NUMBER_COLLATOR.compare(a.roomNumber.trim(), b.roomNumber.trim()) + if (numberComparison !== 0) return numberComparison + const nameComparison = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) + return nameComparison !== 0 ? nameComparison : a.id.localeCompare(b.id) +} + +function valueOrDash(value: string): string { + return value.trim() || '—' +} + +function formatRoomArea(squareMeters: number, unit: ConstructionLinearUnit): string { + if (!Number.isFinite(squareMeters)) return '—' + if (unit === 'metric') return `${squareMeters.toFixed(2)} m²` + return `${(squareMeters * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²` +} + +function resolveEnclosure(zone: ZoneNode, classification: 'footprint' | 'enclosed-room'): string { + if (zone.enclosureStatus === 'enclosed') return 'Enclosed' + if (zone.enclosureStatus === 'open') return 'Open' + return classification === 'enclosed-room' ? 'Enclosed' : 'Open' +} + +function collectRoomScheduleIssues( + rooms: ReadonlyArray<{ + zone: ZoneNode + report: { classification: 'footprint' | 'enclosed-room' } + }>, +): string[] { + const issues: string[] = [] + const numberedRooms = new Map() + + for (const { zone, report } of rooms) { + const number = zone.roomNumber.trim() + if (!number) { + issues.push(`Room ${zone.name.trim() || zone.id} has no room number`) + } else { + const normalized = number.toLocaleUpperCase() + const duplicates = numberedRooms.get(normalized) + if (duplicates) duplicates.push(zone) + else numberedRooms.set(normalized, [zone]) + } + + if (zone.enclosureStatus === 'enclosed' && report.classification !== 'enclosed-room') { + issues.push(`Room ${number || zone.name.trim() || zone.id} is marked enclosed but not proven`) + } + } + + for (const [normalizedNumber, duplicateRooms] of numberedRooms) { + if (duplicateRooms.length < 2) continue + issues.push(`Duplicate room number ${normalizedNumber} (${duplicateRooms.length} rooms)`) + } + + return issues +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 89472dcb..8aba75c9 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -129,7 +129,11 @@ export { } from './lib/texture-reference' export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat' export { useItemLightPool } from './store/use-item-light-pool' -export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer' +export { + applyCountryUnitDefault, + default as useViewer, + type MetricNotation, +} from './store/use-viewer' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { createColumnBoxGeometry, diff --git a/packages/viewer/src/store/use-viewer.test.ts b/packages/viewer/src/store/use-viewer.test.ts index 817b4045..4218d004 100644 --- a/packages/viewer/src/store/use-viewer.test.ts +++ b/packages/viewer/src/store/use-viewer.test.ts @@ -10,6 +10,7 @@ const resetMeasurementPreferences = () => { projectPreferences: {}, showMeasurements: true, unit: 'metric', + metricNotation: 'meters', }) } @@ -49,6 +50,17 @@ describe('measurement display preferences', () => { expect(useViewer.getState().projectPreferences).toEqual(preferences) expect(useViewer.getState().showMeasurements).toBe(false) }) + + test('selects millimeters as a metric display notation', () => { + useViewer.getState().setUnit('imperial') + useViewer.getState().setMetricNotation('millimeters') + + expect(useViewer.getState()).toMatchObject({ + unit: 'metric', + metricNotation: 'millimeters', + unitExplicit: true, + }) + }) }) describe('external selection highlights', () => { diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 1fd8bc20..7190c178 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -10,6 +10,7 @@ import type { ColorPreset, RenderShading } from '../lib/materials' import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' +export type MetricNotation = 'meters' | 'millimeters' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -83,6 +84,8 @@ type ViewerState = { unit: 'metric' | 'imperial' setUnit: (unit: 'metric' | 'imperial') => void + metricNotation: MetricNotation + setMetricNotation: (notation: MetricNotation) => void /** True once the user explicitly picked a unit. Until then `unit` is a * locale-derived default and is not persisted, so the default can keep * tracking the browser locale across sessions. */ @@ -186,6 +189,7 @@ type PersistedViewerState = Partial< | 'edges' | 'shadows' | 'unit' + | 'metricNotation' | 'unitExplicit' | 'levelMode' | 'wallMode' @@ -198,6 +202,7 @@ const RENDER_SHADINGS = ['solid', 'rendered'] as const const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const const EDGE_MODES = ['off', 'soft', 'strong'] as const const UNITS = ['metric', 'imperial'] as const +const METRIC_NOTATIONS = ['meters', 'millimeters'] as const const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const @@ -309,6 +314,7 @@ function normalizePersistedViewerState(value: unknown): PersistedViewerState { edges: pickString(state.edges, EDGE_MODES, 'soft'), shadows: typeof state.shadows === 'boolean' ? state.shadows : true, unit: pickString(state.unit, UNITS, detectDefaultUnit()), + metricNotation: pickString(state.metricNotation, METRIC_NOTATIONS, 'meters'), unitExplicit: typeof state.unit === 'string' && UNITS.includes(state.unit as ViewerState['unit']), levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'), @@ -391,8 +397,11 @@ const useViewer = create()( setShadows: (shadows) => set({ shadows }), unit: detectDefaultUnit(), + metricNotation: 'meters', unitExplicit: false, setUnit: (unit) => set({ unit, unitExplicit: true }), + setMetricNotation: (metricNotation) => + set({ unit: 'metric', metricNotation, unitExplicit: true }), levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), @@ -545,6 +554,7 @@ const useViewer = create()( edges: state.edges, shadows: state.shadows, ...(state.unitExplicit ? { unit: state.unit } : {}), + metricNotation: state.metricNotation, levelMode: state.levelMode, wallMode: state.wallMode, projectPreferences: state.projectPreferences, diff --git a/wiki/architecture/measurements.md b/wiki/architecture/measurements.md index 29f81085..f9b7d935 100644 --- a/wiki/architecture/measurements.md +++ b/wiki/architecture/measurements.md @@ -60,7 +60,7 @@ Measurement nodes must remain in `AnyNode`, `LevelNode.children`, the built-in n These functions are pure and receive the same read-only `GeometryContext` used by registered geometry. They must not import Three.js, editor state, or `useScene`. Feature IDs describe semantic roles (`wall:face:left`, `wall:height`, `roof:ridge:0`); labels never act as identifiers. -The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. +The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. Curved walls additionally publish `wall:curve:center`, allowing radius, center-mark, chord, arc-length, and angular construction dimensions to bind their complete defining geometry and follow later curve edits. Arc-length and angular drafting use four explicit clicks: first arc/ray point, center/vertex, second arc/ray point, then label-line position; their anchors persist in point-center-point order. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. `resolveMeasurementNode` derives current free-point geometry from the scene snapshot. Renderers subscribe to referenced nodes, their parents, and ephemeral node overrides; the floor-plan cache uses `def.floorplanDependencies` and the same override-merged resolver. A host edit therefore changes measurement geometry and value during the drag and after commit without writing the measurement node or adding history entries. diff --git a/wiki/floorplan-chapter-17-assessment.md b/wiki/floorplan-chapter-17-assessment.md new file mode 100644 index 00000000..6dfb85cd --- /dev/null +++ b/wiki/floorplan-chapter-17-assessment.md @@ -0,0 +1,203 @@ +# Floor Plan Chapter 17 Assessment + +## Purpose + +This document compares the guidance in `Chapter_17_Floor_Plan_Dimensions_and_Notes.pdf` with Pascal's current floor-plan implementation. It records what the chapter teaches, what the editor already supports, and the remaining construction-document gaps. + +The review covered the full 19-page chapter and the floor-plan stack across: + +- Core floor-plan, wall, opening, and measurement schemas. +- The registry-owned `FloorplanGeometry` contract. +- Editor 2D rendering and interaction layers. +- Node-specific floor-plan builders. +- Automatic wall and opening dimension planning. +- Persistent measurements and smart measurement. +- Door/window documentation and schedules. +- Per-level PDF export. + +## What the chapter is teaching + +The chapter is primarily about construction communication, not merely measuring geometry. Its main principles are: + +1. A drawing must locate and size every construction-critical feature without requiring field workers to guess, scale the drawing, or perform unnecessary arithmetic. +2. Dimensions must be organized into consistent strings that remain readable and uncrowded. +3. The selected datum must match the construction method: centerline, face of stud, face of finish, masonry opening, rough opening, or another explicit reference. +4. Dimension graphics must follow a consistent standard: thin lines, extension-line gaps, extension-line overshoot, uniform terminators, readable aligned text, and predictable spacing. +5. Exterior strings normally progress from detailed opening/partition information to the overall building dimension. +6. Local or specific notes identify individual features through leaders. General notes apply to the whole drawing and are normally numbered in a dedicated sheet area. +7. Door/window schedules and feature notes may replace repeated dimensions when they communicate the information more clearly. +8. Drawing scale, paper-space text size, line weight, and sheet composition are part of the construction-document contract. +9. Curved, circular, masonry, concrete, and foundation-related construction require different dimension semantics from ordinary wood-frame walls. + +## Current implementation + +### Automatic construction dimensions + +`packages/nodes/src/wall/construction-dimensions.ts` already produces coordinated level-wide construction dimensions. The exterior hierarchy includes: + +1. Opening widths. +2. Door and window center locations. +3. Intersecting partition references. +4. Structural columns. +5. Facade jogs, projections, and recesses. +6. Overall facade dimensions. +7. A structural overall dimension when an exterior column row extends beyond the wall envelope. + +The planner also supports: + +- Collinear wall runs that form one facade. +- Disconnected facade runs. +- Angled exterior walls. +- Exterior-side classification. +- Wall-thickness-aware partition references. +- Interior partition strings, including geometrically enclosed partitions whose side metadata remains stale after wall splitting. +- Subdivision chains on every exterior orientation when internal walls divide a facade into multiple runs. +- Hosted door and window widths. +- Interior clear spans bounded by adjacent wall faces. +- Suppression of very short accidental segments. +- Associative updates when the contributing model geometry changes. + +`packages/nodes/src/wall/floorplan.ts` integrates these dimensions into the registry-driven wall floor-plan builder. + +### Dimension graphics + +`packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx` implements several conventions from the chapter: + +- Aligned dimension lines. +- A gap between the feature and extension line. +- Extension lines that pass beyond the dimension line. +- Consistent 45-degree architectural slash terminators. +- Thin dimension and extension lines. +- Text above the dimension line. +- Text that remains readable when the plan is rotated. +- Explicit aligned baselines for stepped facade dimensions. +- Separate edit and document presentation profiles. +- True modeled wall thickness in document output while retaining interactive legibility in edit mode. +- Paper-space dimension text, tick, extension-gap, overshoot, and label-offset sizing in PDF output. +- Whole-millimetre document notation without an `mm` suffix, while retaining metre notation in the interactive editor. +- Short-segment values outside the dimension ticks when the value cannot fit inside. + +### Automatic annotation layout + +`packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts` now resolves automatic dimension-value collisions in both the live floor plan and PDF composition. It supports: + +- Label-to-label separation, including dense clusters. +- Stable same-string drawing order and priority for farther-out architectural strings. +- Movement along the dimension string before crossing into an adjacent tier. +- Fixed door/window mark pills as obstacles. +- Semantic architectural obstacles for walls, wall corners, door symbols and swing envelopes, windows, and columns. +- Sampled diagonal wall outlines, avoiding the oversized screen-aligned bounds produced by rotated walls. +- Outside-end placement for short values, followed by outside-start when the end side is blocked. +- Matching baseline extensions when a short value changes sides. +- A leader and true tick-to-tick baseline when both outside positions require further relocation. + +The former orange/red dashed collision overlay was removed because it displayed stale pre-layout conflicts on top of labels that the automatic resolver had already made readable. Any future unresolved-collision reporting should live in a separate preflight surface rather than being painted over the drawing. + +`packages/nodes/src/shared/construction-length.ts` formats imperial construction dimensions using feet, inches, and reduced fractions rounded to the nearest sixteenth. + +### Persistent measurements + +The existing measurement system is broader than the chapter's drafting examples. It supports: + +- Distance. +- Angle. +- Area. +- Perimeter. +- Prism volume. +- Free and associative semantic anchors. +- Wall, roof, slab, ceiling, zone, and site features. +- Live updates when referenced geometry changes. +- Dangling-reference presentation and explicit detach behavior. +- 2D and 3D drafting and editing. +- Smart transient measurement reports. + +The architecture is documented in `wiki/architecture/measurements.md`. These measurements are analysis annotations; they are not yet a complete replacement for architectural construction-dimension strings. + +### Door and window documentation + +`packages/nodes/src/shared/opening-documentation.ts` provides: + +- Deterministic automatic door and window marks. +- Explicit mark overrides. +- Duplicate explicit-mark warnings. +- Mark bubbles and leaders. +- Door schedules. +- Window schedules. +- Nominal dimensions. +- Optional verified rough-opening dimensions. +- Window sill and head heights. +- Door operation, frame, and hardware fields. + +The rough-opening fields intentionally remain optional rather than being invented from the nominal modeled opening size. + +### Rooms, stairs, and other plan graphics + +- Zones render a centered name but currently represent generic colored polygons rather than a complete architectural room model. +- Stairs render footprints, treads, and direction arrows, but do not yet emit a complete construction stair note. +- Columns can contribute structural center references to automatic exterior strings. +- The generic floor-plan registry already renders walls, doors, windows, slabs, ceilings, zones, roofs, stairs, columns, furniture, MEP nodes, and annotation nodes through a common geometry contract. + +### PDF export + +`packages/editor/src/lib/floorplan/floorplan-export.tsx` currently provides: + +- Per-level PDF plan pages. +- North-up orientation that accounts for building rotation. +- Full and structure-only export scopes. +- Door and window schedule pages. +- Registry-driven geometry matching the live floor-plan builders. +- Conversion of non-scaling SVG strokes for PDF output. +- Preservation of persistent measurement value labels in full export. +- Respect for the existing measurement-visibility preference. +- Document-purpose wall rendering at modeled thickness. +- Document metric notation and initial paper-space sizing for construction dimensions and measurement labels. +- The same automatic annotation collision layout used by the live floor plan. + +The plan is fitted to an A4 landscape page. It is not yet plotted at a fixed architectural scale. + +## Important current limitations + +### Interactive measurement and construction dimension are different concepts + +The measurement system stores geometric analysis annotations. The wall planner creates automatic construction strings. There is no dedicated manual construction-dimension object that lets a drafter pick references, place a baseline, add points to a continuous string, and later reposition or suppress individual segments. + +### The current datum is not truly face of stud + +`WallNode` stores total thickness and finish materials but does not describe studs, sheathing, finish layers, veneer, air space, concrete block, or furring. Automatic dimensions can reference a generic wall face, but the model cannot yet prove that this face is a structural stud face or finish face. + +### Paper-space control is only partially implemented + +Exported construction dimensions and measurement labels now resolve their main text, tick, extension-gap, overshoot, and label-offset sizes from paper points. Note text, mark bubbles, room labels, remaining line-weight categories, and fixed user-selectable drawing scales still require the drawing-sheet work. + +### Construction dimensions have no independent visibility layer + +The live floor plan exposes independent visibility controls for automatic dimensions, manual dimensions, measurements, opening marks, structural grids, room labels, and stair annotations. Full export intentionally includes every supported annotation category regardless of the live-view toggles. + +### Automatic collision layout has no persistent manual override + +Automatic placement now handles adjacent labels, short values, opening marks, and the first set of architectural obstacles. It does not yet let a drafter pin a chosen label position, suppress a segment, or persist a view-specific layout override. Broader fixed-symbol coverage and a separate unresolved-collision preflight also remain. + +### Curved and circular construction dimensions + +Curved walls emit an automatic radius leader and center mark in live plans and document output, matching the chapter's curved-wall callout method. Manual associative construction dimensions cover radius, diameter, center, chord, arc-length, coordinate-pattern, and angular-pattern workflows, with curved-wall defining geometry resolved from stable semantic host features. + +### Construction systems are not semantically modeled + +The editor cannot yet apply different documentation rules for wood framing, masonry veneer, concrete block, structural masonry, or solid concrete because those assembly semantics do not exist in the wall model. + +### The floor plan has no drawing-sheet model + +The export layer produces plan and schedule pages, but there is no persistent drawing sheet with view identity, scale, title block, drawing number, note blocks, graphic scale, north arrow, or per-view annotation visibility. + +## Features that should not be copied blindly + +The chapter was published in 2012. Its example sizes and clearances are useful drafting and design references, but they should not be treated as current building-code requirements. + +Any implementation of hallway, fixture, door, stair, appliance, or room-clearance checks should: + +- Be configurable by jurisdiction and standard profile. +- Be presented as an advisory or verification result unless code provenance is known. +- Avoid embedding manufacturer-dependent rough openings or product sizes as universal facts. +- Avoid silently omitting dimensions merely because a feature is commonly considered standard. + +The product should prefer explicit model data, verified manufacturer data, and user-controlled documentation policies. diff --git a/wiki/floorplan-pdf-export-library-research.md b/wiki/floorplan-pdf-export-library-research.md new file mode 100644 index 00000000..69de739f --- /dev/null +++ b/wiki/floorplan-pdf-export-library-research.md @@ -0,0 +1,269 @@ +# Floor-plan PDF export library research + +Date: 2026-07-21 + +## Decision summary + +The missing dimension values are a conversion-boundary problem, not a limitation of PDF text. +The current export builds an SVG in the DOM and asks `svg2pdf.js` to reinterpret that SVG as PDF. +That makes the result depend on how the converter handles nested transforms, inherited SVG styles, +font discovery, text baselines, paint order, and non-scaling strokes. Replacing `svg2pdf.js` with a +second automatic SVG converter leaves those same risks in place. + +The reliable design is to render the existing semantic `FloorplanGeometry` directly into PDF +primitives. Dimension values must be emitted with the PDF library's native text API, and dimension +lines/ticks must be emitted with explicit point widths. That preserves selectable vector text and +removes SVG/CSS interpretation from the critical path. + +Recommended choices: + +1. **Smallest and lowest-risk:** keep jsPDF but stop sending dimension annotations through + `svg2pdf.js`. Draw dimensions as a native jsPDF overlay with `doc.text`, `doc.line`, and + `doc.rect`. This is the best implementation choice even though it is not a library replacement. +2. **If a different library is required:** use **PDFKit directly**, rendering from + `FloorplanGeometry`. It has the strongest current combination of browser support, native vector + drawing, transformation support, font embedding, and active maintenance. +3. **Do not choose another automatic SVG converter** as the primary fix. In particular, + `SVG-to-PDFKit` has been inactive since 2022 and documents unsupported features and browser font + loading caveats. + +No production code was changed as part of this research. + +## Current architecture and why it matters + +The current code already has the right source model for a direct PDF backend: + +- Node builders return semantic `FloorplanGeometry`, including `dimension`, `dimension-string`, + `dimension-label`, lines, paths, polygons, circles, and groups. +- [`floorplan-dimension-renderer.tsx`](../packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx) + resolves each dimension's line endpoints, ticks, label point, label angle, font size, and label + placement. +- [`floorplan-export.tsx`](../packages/editor/src/lib/floorplan/floorplan-export.tsx) currently mounts + a React SVG off-screen and converts it with jsPDF + `svg2pdf.js`. + +That means a new export backend does not need to infer measurements from DOM nodes. It can traverse +the same geometry tree and emit native PDF operations deterministically. + +The current converter itself says that custom fonts must be registered before conversion, calls +itself "by no means perfect," and notes that its visual tests can vary because of text measurement. +Those are material warnings for small, rotated architectural labels. +[Official `svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) + +## Requirements + +The selected approach should provide: + +- visible dimension values at every rotation; +- selectable/searchable PDF text; +- embedded or otherwise deterministic fonts; +- explicit thin vector strokes in PDF points; +- lines, curves, polygons, circles, fills, clips, and nested transforms; +- browser-side generation and Blob/download support; +- compatibility with React and TypeScript in this monorepo; +- an API that can be tested without visual browser automation. + +## Comparison + +| Rank | Approach | Native/selectable text | Fonts | Transforms and thin vectors | Browser/TypeScript fit | Maintenance | Integration cost | +|---:|---|---|---|---|---|---|---| +| 1 | Direct jsPDF drawing, optionally as a hybrid overlay | Yes; `text()` emits PDF text and supports an angle or matrix | Custom TTF through VFS + `addFont` | Explicit `setLineWidth`; advanced mode exposes transformation matrices | Already installed and browser-first; official typings | Active; current 4.x docs and releases | Low for annotation overlay, medium for full renderer | +| 2 | Direct PDFKit renderer | Yes; native PDF text | TTF, OTF, WOFF, WOFF2, TTC, dfont; subsetting | Canvas-like vectors, SVG path data, save/restore, translate/rotate/scale/transform, explicit line width | Browser supported, but Blob stream/bundling and separate TS types add work | Active; current 0.19.x releases | Medium-high | +| 3 | Chromium print-to-PDF | Browser's own text/SVG renderer; generally preserves vector text | Uses loaded web fonts; Puppeteer waits for fonts by default | Browser-native SVG/CSS transforms and strokes | Not a pure browser-side library: needs print UI or a headless-browser service | Very active | Low rendering rewrite, high operational cost | +| 4 | `@react-pdf/renderer` | Native `` and SVG `` | `Font.register`; TTF and WOFF | SVG primitives, group transforms, explicit strokes; `Canvas` wraps PDFKit operations | Browser + server React APIs, Blob provider, bundled typings | Active releases and commits | High because DOM SVG is not reusable as-is | +| 5 | `pdf-lib` direct renderer | Native `drawText` with rotation | Standard fonts; custom fonts through `@pdf-lib/fontkit` | Lines, shapes, individual SVG path data, explicit thickness; lower-level transform work | Browser-compatible and TypeScript-native | Stable but inactive upstream since November 2021 | High | +| 6 | `SVG-to-PDFKit` automatic conversion | Supports SVG text/tspan/textPath | Requires pre-registration or a callback; does not wait for async browser font loading | Supports common transforms, but documents unsupported `vector-effect` | Browser possible through PDFKit; has a declaration file | Last commit August 2022; no published GitHub releases | Medium | +| 7 | Canvg raster fallback | No; text becomes pixels | Whatever the canvas resolved at rasterization time | Visually faithful at sufficient resolution, but all output is raster | Browser-friendly and TypeScript-based | Maintained; 4.0.3 released in 2025 | Low-medium | + +## Approach details + +### 1. Direct jsPDF primitives — recommended incremental implementation + +jsPDF already exposes all operations needed for dimension annotations: + +- `text(text, x, y, { angle, align, baseline })` for actual PDF text; +- transformation matrices in advanced mode; +- `setLineWidth(width)` in the document's declared units; +- custom font registration with `addFileToVFS`, `addFont`, and `setFont`. + +The official source documentation shows that `text()` writes a PDF text object (`BT`, font +selection, text position, `Tj`, `ET`) rather than rasterizing the label. It also documents angle and +matrix transforms. [jsPDF text and line-width documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html), +[font and advanced-mode guide](https://parallax.github.io/jsPDF/docs/index.html) + +Practical design: + +1. Keep the current `svg2pdf.js` pass temporarily for non-annotation geometry. +2. Exclude all `dimension`, `dimension-string`, and `dimension-label` geometry from that SVG pass. +3. Resolve them into a small `PdfDimensionAnnotation` display list containing witness lines, + dimension line, tick segments, label text, label anchor, angle, font size, and background box. +4. Transform model coordinates into page points once. +5. Draw the background plate, lines, ticks, then `doc.text()` with explicit fill color and embedded + font. +6. Later, move walls and other geometry to the same native backend if desired. + +Why this is ranked first: it eliminates the observed failure path while retaining the installed PDF +engine, page setup, headers, schedules, and save flow. It also provides a narrow regression-test +surface: generated PDF content can be inspected for each expected label string. + +### 2. Direct PDFKit — recommended full replacement library + +PDFKit runs in both Node and the browser. Its official documentation includes: + +- selectable text and embedded font support for TTF, OTF, WOFF, WOFF2, TTC, and dfont; +- vector `moveTo`, `lineTo`, Bézier and quadratic curves; +- parsing of SVG **path data** (not an entire SVG DOM); +- save/restore, translate, rotate, scale, and arbitrary transform operations; +- explicit stroke widths and Blob output in the browser. + +[PDFKit browser setup](https://pdfkit.org/docs/getting_started.html), +[vector and transform APIs](https://pdfkit.org/docs/vector.html), +[text and font APIs](https://pdfkit.org/docs/text.html) + +PDFKit 0.19 raised its documented browser floor to Firefox 115 and Safari/iOS 16, and its current +release line continues to include text, font, SVG-path, and browser fixes. +[Official PDFKit releases](https://github.com/foliojs/pdfkit/releases) + +Practical design: + +1. Add an exhaustive `renderFloorplanGeometryToPdfKit` visitor. +2. Give the visitor a coordinate transform from model metres to PDF points, including the page's + Y-axis inversion and plan rotation. +3. Draw every dimension label using `doc.text()` after `save/translate/rotate`. +4. Register an exact project font before rendering and use explicit point sizes. +5. Pipe to a browser Blob stream and keep the existing download UX. + +Tradeoffs: this is a cleaner long-term backend but a larger initial migration. PDFKit's npm package +does not currently advertise bundled declarations in its package manifest, so the TypeScript package +would normally also use `@types/pdfkit`. Browser output uses a Node-style stream, commonly adapted +with `blob-stream`. Both add integration weight compared with the existing jsPDF save flow. + +### 3. Chromium/browser printing + +Printing a dedicated page lets the browser render the same SVG, CSS, transforms, and fonts that it +renders on screen. `window.print()` is widely available, and print-specific CSS can control the +page. [MDN `window.print`](https://developer.mozilla.org/en-US/docs/Web/API/Window/print), +[MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing) + +For automatic downloads, Puppeteer's `Page.pdf()` uses Chromium print output. Its documented +options include CSS page-size preference, background graphics, and waiting for +`document.fonts.ready` (enabled by default). +[Puppeteer PDF guide](https://pptr.dev/guides/pdf-generation), +[Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) + +This is the best path when exact browser-rendering parity outweighs infrastructure cost. It is not +a pure client library: either the user must use the print dialog, or the application needs a trusted +server/desktop process running Chromium. That is a substantial architectural change for the current +browser-side download. + +### 4. `@react-pdf/renderer` + +React-pdf produces PDFs in the browser and server and offers browser Blob/download components. It +has its own PDF primitives, including SVG `Line`, `Path`, `Text`, `Tspan`, and `G`. Its documented +presentation attributes include `strokeWidth`, `transform`, `textAnchor`, and +`dominantBaseline`. Group transforms apply to children. Its `Canvas` painter wraps PDFKit methods, +including `text`, `path`, `rotate`, `lineWidth`, `translate`, and `scale`. +[React-pdf SVG APIs](https://react-pdf.org/svg), +[components and Canvas API](https://react-pdf.org/components), +[font registration](https://react-pdf.org/fonts) + +This is viable and actively maintained. It is not a drop-in renderer for the existing React DOM SVG: +the floor plan must be rebuilt with React-pdf's component types or drawn through its Canvas painter. +For a CAD-like plan, that gives no decisive rendering advantage over direct PDFKit while adding a +second React renderer and layout engine. It is more attractive if the broader drawing-sheet document +will be rebuilt declaratively. + +### 5. `pdf-lib` + +`pdf-lib` runs in browsers and is written in TypeScript. It provides native `drawText` with rotation, +explicit line thickness, rectangles/circles/ellipses, and individual SVG path drawing. Custom fonts +are embedded through `@pdf-lib/fontkit`. +[Official examples](https://pdf-lib.js.org/), +[`PDFPage` drawing API](https://pdf-lib.js.org/docs/api/classes/pdfpage), +[`DrawTextOptions`](https://pdf-lib.js.org/docs/api/interfaces/drawtextoptions) + +It does not parse a complete SVG document; its SVG support is for one path-data string at a time. +Consequently, it requires the same complete `FloorplanGeometry` visitor as PDFKit, with a less +convenient graphics-state/transform API for this use case. The upstream repository's latest commit +and release are from November 2021, so it is not the preferred new dependency for a renderer being +introduced in 2026. [Official commit history](https://github.com/Hopding/pdf-lib/commits/master/), +[official releases](https://github.com/Hopding/pdf-lib/releases) + +### 6. `SVG-to-PDFKit` + +`SVG-to-PDFKit` is the only credible alternate JavaScript full-SVG converter found. Its documented +coverage includes SVG text/tspan/textPath, transforms, paths, clips, masks, fonts, gradients, and +patterns. However, it explicitly does not support `vector-effect`, warns that browser fonts must be +registered before conversion because it does not wait for asynchronous loading, warns that bugs +remain, and has not received a commit since August 2022. +[Official repository and support table](https://github.com/alafr/SVG-to-PDFKit), +[official commit history](https://github.com/alafr/SVG-to-PDFKit/commits/master/) + +It is therefore not a sensible replacement for `svg2pdf.js`. It changes the converter without +removing the converter boundary. + +### 7. Canvg raster fallback + +Canvg parses SVG and renders it to Canvas; its stated purpose includes SVG rasterization. +[Official repository](https://github.com/canvg/canvg), +[official API](https://canvg.js.org/api) + +Rendering the plan at high device-pixel density and embedding the canvas as PNG would make missing +text unlikely after `document.fonts.ready`, because the browser/canvas has already converted the +glyphs to pixels. It is an acceptable emergency fallback or diagnostic control. It does not meet the +core deliverable: dimension text is not selectable, all geometry becomes raster, thin lines depend +on export resolution, and large plans produce larger PDFs. + +## Proposed implementation sequence + +If implementation is approved, use this order: + +1. Add a library-independent PDF display list or visitor over `FloorplanGeometry`. +2. Implement dimensions first: lines, ticks, background plates, and native text. +3. Embed one exact non-variable TTF font and wait for/load it explicitly. +4. Preserve all document sizes in PDF points; do not use CSS pixels for line weights. +5. Keep the existing SVG conversion only for unported geometry during the transition. +6. Add structural tests that inspect the generated PDF for expected text strings and page count. +7. Add fixture coverage for horizontal, vertical, diagonal, rotated-plan, short/outside-label, + metric, and imperial dimensions. +8. Only after the annotation path is proven, decide whether to port the remaining geometry and + remove `svg2pdf.js`. + +For a mandated new library, substitute a direct PDFKit backend at steps 2–5 and port geometry kinds +incrementally. Do not introduce `SVG-to-PDFKit` as an intermediate layer. + +## Acceptance criteria for the eventual implementation + +- Every dimension value in the source geometry is present as extractable text in the generated PDF. +- Horizontal, vertical, and diagonal values remain readable at plan rotations of 0°, 45°, 90°, and + arbitrary building rotations. +- Dimension lines render at an explicit target such as 0.5 pt and ticks at 0.75 pt regardless of + plan scale. +- The chosen font is embedded or a deliberate standard PDF font is used. +- Label backing plates are drawn before text and do not obscure glyphs. +- Text extraction verifies representative metric and imperial labels. +- Geometry remains vector except for explicitly documented raster-only assets. + +## Primary sources + +- [jsPDF repository](https://github.com/parallax/jsPDF) +- [jsPDF documentation](https://parallax.github.io/jsPDF/docs/index.html) +- [jsPDF source/API documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html) +- [`svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) +- [`svg2pdf.js` releases](https://github.com/yWorks/svg2pdf.js/releases) +- [PDFKit repository](https://github.com/foliojs/pdfkit) +- [PDFKit browser setup](https://pdfkit.org/docs/getting_started.html) +- [PDFKit vector graphics](https://pdfkit.org/docs/vector.html) +- [PDFKit text and fonts](https://pdfkit.org/docs/text.html) +- [PDFKit releases](https://github.com/foliojs/pdfkit/releases) +- [React-pdf components](https://react-pdf.org/components) +- [React-pdf SVG primitives](https://react-pdf.org/svg) +- [React-pdf fonts](https://react-pdf.org/fonts) +- [React-pdf releases](https://github.com/diegomura/react-pdf/releases) +- [`pdf-lib` documentation](https://pdf-lib.js.org/) +- [`pdf-lib` `PDFPage` API](https://pdf-lib.js.org/docs/api/classes/pdfpage) +- [`pdf-lib` repository](https://github.com/Hopding/pdf-lib) +- [`SVG-to-PDFKit` repository](https://github.com/alafr/SVG-to-PDFKit) +- [Canvg repository](https://github.com/canvg/canvg) +- [Puppeteer PDF generation](https://pptr.dev/guides/pdf-generation) +- [Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) +- [MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing)