editor: complete floorplan construction documentation (#531)

* Add roof surface placement support for items

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

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

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

* fixed conflict

* feat(floorplan): add construction dimension strings

* feat(floorplan): coordinate opening dimensions

* feat(floorplan): add opening documentation

* feat(floorplan): add construction dimensions and notes

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

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

* feat(floorplan): harden construction document output

* feat(floorplan): add annotation collision diagnostics

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

* feat(floorplan): automatically separate overlapping labels

* fix(floorplan): resolve dense label overlaps

* fix(floorplan): remove stale collision warning overlays

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

* feat(floorplan): place short dimension values outside

* fix(floorplan): preserve dimension string order

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

* feat(floorplan): add dimension side fallback leaders

* docs(floorplan): update chapter 17 implementation status

* fix(floorplan): dimension subdivided interior walls

* feat: add associative floor plan dimensions

* feat: add continuous construction dimension strings

* feat: add structural floor plan grids

* feat: coordinate columns with structural grids

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

* feat: add architectural room documentation

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

* feat: generate architectural room schedules

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

* feat: add reliable room clear dimensions

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

* feat: add architectural stair documentation

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

* feat: add typed specialty construction notes

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

* feat: add curved and circular dimensions

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

* feat: coordinate floor plan drawing types

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

* feat: add associative curved wall dimensions

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

* fix: render automatic curved wall dimensions

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

* fix: use radius callout for curved walls

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

* Implement construction dimension string editing

* Add construction dimension standards controls

* Apply drawing standards to automatic dimensions

* Add floorplan overhead and reference visibility controls

* Add view-specific dimension segment suppression

* Add persistent drawing sheet model

* Plot floorplan exports at fixed scale

* Apply paper-space annotation profiles

* Compose floorplan PDF sheets

* Support sheet paper sizes and preflight

* Persist pinned annotation layout overrides

* Expand annotation collision obstacles

* Add floorplan annotation preflight surface

* Add reusable drawing sheet general notes

* Add drawing sheet keyed note instances

* Add drawing sheet document markers

* Expand construction note leader terminators

* Add wall assembly layer model

* Resolve wall assembly datum references

* Add wall assembly floorplan graphics

* Add opening documentation dimension policies

* Add finish-face room clear dimensions

* Extend room clear dimensions to rectilinear rooms

* Add construction module advisories

* Add clearance advisory profiles

* Add dimension completeness audit

* Expand dimension completeness audit

* Include preflight issues in completeness audit

* feat: complete floorplan construction documentation

* refactor: remove construction note node

* feat: refine floorplan documentation and unit display

* fix(editor): improve floorplan PDF dimensions

* fix(floorplan): refresh annotation collision layout

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

* feat(floorplan): refine construction dimension references

* fix(floorplan): align documentation tools with architecture

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-22 14:02:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2adb50a340
commit 77442861d9
181 changed files with 25002 additions and 800 deletions
+2
View File
@@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { BuildTab } from '@/components/build-tab' import { BuildTab } from '@/components/build-tab'
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
import { import {
CommunityViewerToolbarLeft, CommunityViewerToolbarLeft,
CommunityViewerToolbarRight, CommunityViewerToolbarRight,
@@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor'
export default function Home() { export default function Home() {
return ( return (
<div className="relative h-screen w-screen"> <div className="relative h-screen w-screen">
<FloorplanConstructionPreflight />
{PROJECT_ID === 'local-editor' && ( {PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2"> <div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur"> <div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
+48 -26
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import { nodeRegistry } from '@pascal-app/core' 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 { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -13,24 +18,6 @@ import {
} from '@/components/toolbar-tooltip' } from '@/components/toolbar-tooltip'
import { cn } from '@/lib/utils' 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" * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
* group tile — its own sub-grid, like Roof's "Features". * group tile — its own sub-grid, like Roof's "Features".
@@ -53,7 +40,8 @@ type BuildType = {
/** Raster asset tile (legacy Build sidebar artwork). */ /** Raster asset tile (legacy Build sidebar artwork). */
iconSrc: string iconSrc: string
/** Present for structure-tool types (absent for paint mode and the MEP group). */ /** Present for structure-tool types (absent for paint mode and the MEP group). */
kind?: BuildToolKind kind?: string
paletteOrder?: number
/** Non-placement special mode. */ /** Non-placement special mode. */
mode?: 'material-paint' mode?: 'material-paint'
} }
@@ -67,7 +55,7 @@ type MepItem = {
} }
// Same icons + ordering as the community Build sidebar, minus presets. // 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: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, { 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' }, { 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 // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
// tools had in the community Build sidebar. // tools had in the community Build sidebar.
const MEP_ITEMS: MepItem[] = [ const MEP_ITEMS: MepItem[] = [
@@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [
* Activate a raw structure draw/cursor tool. Mirrors the editor's own * Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
*/ */
function activateBuildTool(kind: BuildToolKind | MepToolKind): void { function activateBuildTool(kind: string): void {
const ed = useEditor.getState() const ed = useEditor.getState()
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
if (preferredView) ed.setViewMode(preferredView)
ed.setPhase('structure') ed.setPhase('structure')
ed.setStructureLayer('elements') ed.setStructureLayer('elements')
ed.setCatalogCategory(null) ed.setCatalogCategory(null)
@@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void {
ed.setStructureLayer('elements') ed.setStructureLayer('elements')
ed.setCatalogCategory(null) ed.setCatalogCategory(null)
ed.setMode('build') ed.setMode('build')
ed.setTool(kind as Parameters<typeof ed.setTool>[0]) ed.setTool(kind)
} }
/** /**
@@ -165,6 +186,7 @@ export function BuildTab() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow) const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
const buildTypes = useMemo(collectBuildTypes, [])
// The fitting / follow tools are armed from a segment's panel, not a grid // 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 // tile — keep the segment tile lit so the panel (and the way back) stays
@@ -245,9 +267,9 @@ export function BuildTab() {
didInitRef.current = true didInitRef.current = true
const ed = useEditor.getState() const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return 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) if (firstType) handleTypeClick(firstType)
}, [handleTypeClick]) }, [buildTypes, handleTypeClick])
return ( return (
<div className="flex h-full flex-col gap-3 p-3"> <div className="flex h-full flex-col gap-3 p-3">
@@ -256,7 +278,7 @@ export function BuildTab() {
className="grid gap-1.5" className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
> >
{BUILD_TYPES.map((type) => { {buildTypes.map((type) => {
const active = isTypeActive(type) const active = isTypeActive(type)
return ( return (
<Tooltip key={type.id}> <Tooltip key={type.id}>
@@ -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<typeof setTimeout> | undefined
const unsubscribe = useScene.subscribe((state) => {
if (pending) clearTimeout(pending)
pending = setTimeout(() => setNodes(state.nodes), 100)
})
return () => {
if (pending) clearTimeout(pending)
unsubscribe()
}
}, [])
return nodes
}
+180 -6
View File
@@ -2,6 +2,7 @@
import { Icon as IconifyIcon } from '@iconify/react' import { Icon as IconifyIcon } from '@iconify/react'
import { import {
DRAWING_TYPE_OPTIONS,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -10,7 +11,9 @@ import {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
useDrawingView,
useEditor, useEditor,
useFloorplanAnnotationVisibility,
useSidebarStore, useSidebarStore,
type ViewMode, type ViewMode,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -32,12 +35,16 @@ import {
EyeOff, EyeOff,
Footprints, Footprints,
Grid2X2, Grid2X2,
Layers3,
Magnet, Magnet,
PenLine, PenLine,
Ruler, Ruler,
ScanLine,
SlidersHorizontal, SlidersHorizontal,
Sparkles, Sparkles,
SquareUserRound,
SwatchBook, SwatchBook,
Tag,
} from 'lucide-react' } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { type ReactNode, useCallback } from 'react' import { type ReactNode, useCallback } from 'react'
@@ -133,6 +140,22 @@ const SHADING_OPTIONS = [
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
] as const ] 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() { function ViewModeControl() {
const viewMode = useEditor((state) => state.viewMode) const viewMode = useEditor((state) => state.viewMode)
const setViewMode = useEditor((state) => state.setViewMode) 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 (
<div className={TOOLBAR_CONTAINER}>
<DropdownMenu>
<ToolbarTooltip label="Select coordinated drawing">
<DropdownMenuTrigger asChild>
<button
aria-label={`Drawing type: ${active.label}`}
className="flex items-center gap-1.5 px-2.5 font-medium text-foreground/90 text-xs transition-colors hover:bg-white/8"
type="button"
>
<Layers3 className="h-3.5 w-3.5" />
<span>{active.label}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent
align="start"
className="w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
side="bottom"
sideOffset={8}
>
{DRAWING_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setDrawingType(option.id)}>
<Layers3 className="h-4 w-4" />
<span>{option.label}</span>
{drawingType === option.id ? <Check className="ml-auto h-4 w-4" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function CollapseSidebarButton() { function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((state) => state.isCollapsed) const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) 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' const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
function DisplayMenu() { function DisplayMenu() {
const viewMode = useEditor((state) => state.viewMode)
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid) const setShowGrid = useViewer((state) => state.setShowGrid)
const showMeasurements = useViewer((state) => state.showMeasurements) const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements) const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit) 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 cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode) const setCameraMode = useViewer((state) => state.setCameraMode)
const shading = useViewer((state) => state.shading) const shading = useViewer((state) => state.shading)
@@ -296,6 +365,14 @@ function DisplayMenu() {
const setShadows = useViewer((state) => state.setShadows) const setShadows = useViewer((state) => state.setShadows)
const magneticSnap = useEditor((state) => state.magneticSnap) const magneticSnap = useEditor((state) => state.magneticSnap)
const setMagneticSnap = useEditor((state) => state.setMagneticSnap) 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 = const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
@@ -337,17 +414,82 @@ function DisplayMenu() {
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" /> <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
{viewMode !== '2d' ? (
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))} onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
> >
<Ruler className="h-4 w-4" /> <Ruler className="h-4 w-4" />
<span>Measurements</span> <span>{viewMode === 'split' ? '3D measurements' : 'Measurements'}</span>
{showMeasurements ? ( {showMeasurements ? (
<Eye className="ml-auto h-4 w-4 text-foreground" /> <Eye className="ml-auto h-4 w-4 text-foreground" />
) : ( ) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" /> <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
) : null}
{viewMode !== '3d' ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Layers3 className="h-4 w-4" />
<span>Floor plan annotations</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
const OptionIcon = option.icon
const visible = annotationVisibility[option.id]
return (
<DropdownMenuItem
key={option.id}
onSelect={(e) =>
keepOpen(e, () => setAnnotationCategory(option.id, !visible))
}
>
<OptionIcon className="h-4 w-4" />
<span>{option.name}</span>
{visible ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Ruler className="h-4 w-4" />
<span>Wall dimensions</span>
<span className="ml-auto text-muted-foreground text-xs">
{
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
(option) => option.id === wallDimensionReference,
)?.name
}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
onSelect={(event) =>
keepOpen(event, () => setWallDimensionReference(option.id))
}
>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{wallDimensionReference === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
) : null}
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}> <DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" /> <Magnet className="h-4 w-4" />
<span>Magnetic snap</span> <span>Magnetic snap</span>
@@ -377,17 +519,48 @@ function DisplayMenu() {
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuSub>
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} <DropdownMenuSubTrigger>
>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]"> <span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
{unit === 'metric' ? 'm' : 'ft'} {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'}
</span> </span>
<span>Units</span> <span>Units</span>
<span className="ml-auto text-muted-foreground text-xs"> <span className="ml-auto text-muted-foreground text-xs">
{unit === 'metric' ? 'Metric' : 'Imperial'} {unit === 'imperial'
? 'Feet & inches'
: metricNotation === 'millimeters'
? 'Millimeters'
: 'Meters'}
</span> </span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
<DropdownMenuItem onSelect={() => setMetricNotation('meters')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
m
</span>
<span>Meters</span>
{unit === 'metric' && metricNotation === 'meters' ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => setMetricNotation('millimeters')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
mm
</span>
<span>Millimeters</span>
{unit === 'metric' && metricNotation === 'millimeters' ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setUnit('imperial')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
ft
</span>
<span>Feet & inches</span>
{unit === 'imperial' ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() {
<> <>
<CollapseSidebarButton /> <CollapseSidebarButton />
<ViewModeControl /> <ViewModeControl />
<DrawingTypeControl />
</> </>
) )
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+63 -69
View File
@@ -98,7 +98,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -124,7 +124,7 @@
}, },
"packages/editor": { "packages/editor": {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -145,35 +145,37 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0", "@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0", "pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8", "three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.1", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.1", "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -201,7 +203,7 @@
}, },
"packages/ifc-converter": { "packages/ifc-converter": {
"name": "@pascal-app/ifc-converter", "name": "@pascal-app/ifc-converter",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@pascal-app/core": "*", "@pascal-app/core": "*",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -215,7 +217,7 @@
}, },
"packages/mcp": { "packages/mcp": {
"name": "@pascal-app/mcp", "name": "@pascal-app/mcp",
"version": "0.3.1", "version": "0.3.2",
"bin": { "bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js", "pascal-mcp": "./dist/bin/pascal-mcp.js",
}, },
@@ -225,22 +227,22 @@
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/node": "^22.19.20", "@types/node": "^22.19.20",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
}, },
}, },
"packages/nodes": { "packages/nodes": {
"name": "@pascal-app/nodes", "name": "@pascal-app/nodes",
"version": "0.1.0", "version": "0.1.1",
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.0", "@pascal-app/core": "^0.9.2",
"@pascal-app/editor": "^0.9.0", "@pascal-app/editor": "^0.9.2",
"@pascal-app/viewer": "^0.9.0", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -249,9 +251,9 @@
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.0", "@pascal-app/core": "^0.9.2",
"@pascal-app/editor": "^0.9.0", "@pascal-app/editor": "^0.9.2",
"@pascal-app/viewer": "^0.9.0", "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
@@ -283,7 +285,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"three-bvh-csg": "^0.0.18", "three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",
@@ -297,7 +299,7 @@
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "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=="], "@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.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=="], "@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=="], "@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/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=="], "@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/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@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/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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="],
+64
View File
@@ -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
+9
View File
@@ -9,10 +9,12 @@ import type {
CeilingNode, CeilingNode,
ChimneyNode, ChimneyNode,
ColumnNode, ColumnNode,
ConstructionDimensionNode,
CupolaNode, CupolaNode,
DoorNode, DoorNode,
DormerNode, DormerNode,
DownspoutNode, DownspoutNode,
DrawingSheetNode,
DuctFittingNode, DuctFittingNode,
DuctSegmentNode, DuctSegmentNode,
DuctTerminalNode, DuctTerminalNode,
@@ -42,6 +44,7 @@ import type {
SpawnNode, SpawnNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
StructuralGridNode,
TurbineVentNode, TurbineVentNode,
WallNode, WallNode,
WindowNode, WindowNode,
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode> export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode> export type CeilingEvent = NodeEvent<CeilingNode>
export type ColumnEvent = NodeEvent<ColumnNode> export type ColumnEvent = NodeEvent<ColumnNode>
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
export type RoofEvent = NodeEvent<RoofNode> export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode> export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode> export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode> export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode> export type ElevatorEvent = NodeEvent<ElevatorNode>
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
export type SkylightEvent = NodeEvent<SkylightNode> export type SkylightEvent = NodeEvent<SkylightNode>
export type DormerEvent = NodeEvent<DormerNode> export type DormerEvent = NodeEvent<DormerNode>
export type DownspoutEvent = NodeEvent<DownspoutNode> export type DownspoutEvent = NodeEvent<DownspoutNode>
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode> export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
export type DuctFittingEvent = NodeEvent<DuctFittingNode> export type DuctFittingEvent = NodeEvent<DuctFittingNode>
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode> export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
NodeEvents<'spawn', SpawnEvent> & NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> & NodeEvents<'column', ColumnEvent> &
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
NodeEvents<'roof', RoofEvent> & NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> & NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'structural-grid', StructuralGridEvent> &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> & NodeEvents<'scan', ScanEvent> &
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
NodeEvents<'skylight', SkylightEvent> & NodeEvents<'skylight', SkylightEvent> &
NodeEvents<'dormer', DormerEvent> & NodeEvents<'dormer', DormerEvent> &
NodeEvents<'downspout', DownspoutEvent> & NodeEvents<'downspout', DownspoutEvent> &
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> &
NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> &
NodeEvents<'duct-terminal', DuctTerminalEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> &
+6
View File
@@ -9,8 +9,10 @@ export type {
CeilingEvent, CeilingEvent,
ChimneyEvent, ChimneyEvent,
ColumnEvent, ColumnEvent,
ConstructionDimensionEvent,
DoorEvent, DoorEvent,
DormerEvent, DormerEvent,
DrawingSheetEvent,
ElevatorEvent, ElevatorEvent,
EventSuffix, EventSuffix,
FenceEvent, FenceEvent,
@@ -34,6 +36,7 @@ export type {
SpawnEvent, SpawnEvent,
StairEvent, StairEvent,
StairSegmentEvent, StairSegmentEvent,
StructuralGridEvent,
WallEvent, WallEvent,
WindowEvent, WindowEvent,
ZoneEvent, ZoneEvent,
@@ -88,6 +91,7 @@ export {
closestMeasurementFeatureBinding, closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE, MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback, measurementAnchorFallback,
measurementAnchorReferenceNodeIds,
measurementAngle, measurementAngle,
measurementArea, measurementArea,
measurementAreaVector, measurementAreaVector,
@@ -98,6 +102,7 @@ export {
measurementPerimeter, measurementPerimeter,
measurementPrismVolume, measurementPrismVolume,
measurementReferenceNodeIds, measurementReferenceNodeIds,
remapMeasurementAnchors,
remapMeasurementReferences, remapMeasurementReferences,
} from './lib/measurement-geometry' } from './lib/measurement-geometry'
export { export {
@@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise'
export { export {
getClampedWallCurveOffset, getClampedWallCurveOffset,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallArcData,
getWallChordFrame, getWallChordFrame,
getWallCurveFrameAt, getWallCurveFrameAt,
getWallCurveLength, getWallCurveLength,
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types' import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement' import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
import { import {
areMeasurementPointsCoplanar, areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding, closestMeasurementFeatureBinding,
measurementAnchorReferenceNodeIds,
measurementAngle, measurementAngle,
measurementArea, measurementArea,
measurementAreaVector, measurementAreaVector,
@@ -12,6 +13,7 @@ import {
measurementNormal, measurementNormal,
measurementPerimeter, measurementPerimeter,
measurementPrismVolume, measurementPrismVolume,
remapMeasurementAnchors,
} from './measurement-geometry' } from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => { 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, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
}) })
test('remaps and collects references for arbitrary anchor strings', () => {
const anchors: MeasurementAnchor[] = [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [4, 0, 0],
},
]
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
const remapped = remapMeasurementAnchors(
anchors,
new Map([
['wall_a', 'wall_a_copy'],
['wall_b', 'wall_b_copy'],
]),
)
const first = remapped[0]!
const last = remapped[2]!
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
})
}) })
+40 -10
View File
@@ -1,4 +1,5 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types' import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
import type { import type {
MeasurementAnchor, MeasurementAnchor,
MeasurementPayload, MeasurementPayload,
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
measurement: MeasurementPayload, measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>, idMap: ReadonlyMap<string, string>,
): MeasurementPayload { ): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => { const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
if (Array.isArray(anchor)) return anchor remapMeasurementAnchors([anchor], idMap)[0]!
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
switch (measurement.kind) { switch (measurement.kind) {
case 'distance': case 'distance':
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
} }
} }
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { export function remapMeasurementAnchors(
const anchors = anchors: readonly MeasurementAnchor[],
measurement.kind === 'distance' || measurement.kind === 'angle' idMap: ReadonlyMap<string, string>,
? measurement.points ): MeasurementAnchor[] {
: measurement.base return anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
})
}
export function remapConstructionDimensionReferences(
dimension: ConstructionDimensionNode,
idMap: ReadonlyMap<string, string>,
): ConstructionDimensionNode {
const controllingDimensionId = dimension.controllingDimensionId
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
dimension.controllingDimensionId)
: null
return {
...dimension,
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
controllingDimensionId,
}
}
export function measurementAnchorReferenceNodeIds(
anchors: readonly MeasurementAnchor[],
): AnyNodeId[] {
const ids = new Set<string>() const ids = new Set<string>()
for (const anchor of anchors) { for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId) if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
return [...ids] as AnyNodeId[] 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 { export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0] if (points.length < 3) return [0, 0, 0]
+1 -1
View File
@@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport( export function deriveZoneQuantityReport(
zone: ZoneNode, zone: ZoneNode,
sceneNodes: Record<string, AnyNode>, sceneNodes: Readonly<Record<string, AnyNode>>,
): ZoneQuantityReport { ): ZoneQuantityReport {
const levelId = zone.parentId const levelId = zone.parentId
const levelNodes = levelId const levelNodes = levelId
+3
View File
@@ -65,6 +65,8 @@ export type {
Capabilities, Capabilities,
CapabilityCtx, CapabilityCtx,
CuttableConfig, CuttableConfig,
DimensionTerminator,
DimensionTextPosition,
DistributionRole, DistributionRole,
DragAction, DragAction,
DuplicableConfig, DuplicableConfig,
@@ -88,6 +90,7 @@ export type {
FloorplanPoint, FloorplanPoint,
FloorplanStyle, FloorplanStyle,
GeometryContext, GeometryContext,
GroupMoveSnapArgs,
HostableConfig, HostableConfig,
IconRef, IconRef,
Issue, Issue,
+110 -1
View File
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
) { ) {
const anchor = clonedMeasurement.measurement.points[0] const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false) 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)!) 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', () => { test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' }) const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], { const { nodes } = cloneNodesInto([orig], {
+12 -2
View File
@@ -1,5 +1,9 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry' import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import { generateId } from '../schema/base' import { generateId } from '../schema/base'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement // Generic, opinion-free primitives the host app composes to implement
@@ -141,7 +145,7 @@ export function cloneNodesInto(
const out: AnyNode[] = [] const out: AnyNode[] = []
let root: AnyNode | null = null let root: AnyNode | null = null
for (const original of nodes) { 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)! const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId ;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved // parentId: root's parentId becomes opts.parentId (or preserved
@@ -169,6 +173,12 @@ export function cloneNodesInto(
if (cloned.type === 'measurement') { if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) 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 (original.id === opts.rootId) {
if (opts.position) { if (opts.position) {
+62 -1
View File
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs. * `scene:` refs.
*/ */
materials?: Record<SceneMaterialId, SceneMaterial> materials?: Record<SceneMaterialId, SceneMaterial>
/** Opaque host/plugin context. Core never interprets extension values. */
extensions?: Readonly<Record<string, unknown>>
/** /**
* Optional view state — only populated for `def.floorplan` builders. The * Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary * 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 FloorplanPoint = readonly [x: number, y: number]
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
export type DimensionTextPosition = 'above' | 'centered'
export type FloorplanStyle = { export type FloorplanStyle = {
stroke?: string stroke?: string
fill?: string fill?: string
strokeWidth?: number strokeWidth?: number
strokeDasharray?: string strokeDasharray?: string
opacity?: number opacity?: number
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
/** /**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps * 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°). * of the floor-plan's scene rotation (default 90°).
*/ */
upright?: boolean upright?: boolean
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
} }
/** /**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated * Bitmap overlay — captured top-down asset thumbnail, AI-generated
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
children: FloorplanGeometry[] children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */ /** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number } transform?: { translate?: FloorplanPoint; rotate?: number }
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
} }
/** /**
* Hatched fill overlay — same polygon shape as the kind's main fill but * Hatched fill overlay — same polygon shape as the kind's main fill but
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
kind: 'dimension' kind: 'dimension'
start: FloorplanPoint start: FloorplanPoint
end: 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. */ /** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */ /** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number offsetDistance: number
/** How far past the offset point the extension line continues. */ /** How far past the offset point the extension line continues. */
extensionOvershoot: number 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 text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */ /** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string stroke?: string
} }
| {
kind: 'dimension-string'
segments: readonly {
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}[]
/** Outward-pointing unit normal shared by every segment in the string. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from each measured origin to its dimension line. */
offsetDistance: number
/** How far past each offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position shared by every segment. Defaults above the line. */
textPosition?: DimensionTextPosition
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
// ─── FloorplanAffordance ───────────────────────────────────────────── // ─── FloorplanAffordance ─────────────────────────────────────────────
// //
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schemaVersion: number schemaVersion: number
schema: S schema: S
category: NodeCategory category: NodeCategory
/** Opaque host/plugin contributions. Core stores but never interprets them. */
extensions?: Readonly<Record<string, unknown>>
surfaceRole?: SurfaceRole surfaceRole?: SurfaceRole
/** /**
* Show a floor direction-triangle while placing/moving — the kind has a * Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
portConnectivityFollow?: boolean portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'> defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities capabilities: Capabilities
relations?: Relations relations?: Relations
+64 -2
View File
@@ -51,8 +51,33 @@ export {
ColumnStyle, ColumnStyle,
ColumnSupportStyle, ColumnSupportStyle,
} from './nodes/column' } 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 { CupolaNode } from './nodes/cupola'
export { DoorNode, DoorSegment } from './nodes/door' export {
DoorNode,
DoorSegment,
OpeningConstructionType,
OpeningDimensionReference,
} from './nodes/door'
export { export {
DormerNode, DormerNode,
type DormerSurfaceMaterialRole, type DormerSurfaceMaterialRole,
@@ -60,6 +85,25 @@ export {
getEffectiveDormerSurfaceMaterial, getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer' } from './nodes/dormer'
export { DownspoutNode } from './nodes/downspout' 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 { DuctFittingNode } from './nodes/duct-fitting'
export { DuctSegmentNode } from './nodes/duct-segment' export { DuctSegmentNode } from './nodes/duct-segment'
export { DuctTerminalNode } from './nodes/duct-terminal' export { DuctTerminalNode } from './nodes/duct-terminal'
@@ -200,9 +244,13 @@ export {
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent' export { TurbineVentNode } from './nodes/turbine-vent'
export type { export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallBandSurfaceSlotId, WallBandSurfaceSlotId,
WallFaceBand, WallFaceBand,
WallFaceBandConfig, WallFaceBandConfig,
@@ -215,11 +263,18 @@ export {
buildEnabledWallFaceBandPatch, buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch, buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallBandSlotId, getWallBandSlotId,
getWallDatumEligibleLayers,
getWallFaceBandConfig, getWallFaceBandConfig,
getWallFaceBandForHeight, getWallFaceBandForHeight,
getWallSurfaceMaterialSignature, getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot, getWallSurfaceSideFromBandSlot,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT, WALL_CROWN_DEFAULT,
@@ -230,11 +285,18 @@ export {
WALL_SLOT_DEFAULT, WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS, WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS, WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode, WallNode,
WallTreatmentSide, WallTreatmentSide,
WallTrimProfile, WallTrimProfile,
} from './nodes/wall' } from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window' export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
WindowType,
} from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
+5 -2
View File
@@ -1,13 +1,16 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { DrawingSheetNode } from './drawing-sheet'
import { ElevatorNode } from './elevator' import { ElevatorNode } from './elevator'
import { LevelNode } from './level' import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({ export const BuildingNode = BaseNode.extend({
id: objectId('building'), id: objectId('building'),
type: nodeType('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]), 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]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe( }).describe(
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building Building node - used to represent a building
- position: position in site coordinate system - position: position in site coordinate system
- rotation: rotation in site coordinate system - rotation: rotation in site coordinate system
- children: array of level nodes and building-level systems such as elevators - children: array of level nodes, building-level systems such as elevators, and drawing sheets
`, `,
) )
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
ConstructionDimensionNode,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './construction-dimension'
describe('ConstructionDimensionNode', () => {
test('creates valid free-anchor defaults', () => {
const node = ConstructionDimensionNode.parse({})
expect(node.type).toBe('construction-dimension')
expect(node.id).toMatch(/^construction-dimension_/)
expect(node.anchors).toEqual([
[0, 0, 0],
[1, 0, 0],
])
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
expect(node.chainMode).toBe('point-to-point')
expect(node).toMatchObject({
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
})
})
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[3, 0, 0],
],
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({
baseline: { origin: [0, 0], direction: [0, 0] },
}).success,
).toBe(false)
})
test('accepts continuous strings with three or more anchors', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
chainMode: 'continuous',
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
.success,
).toBe(false)
})
test('accepts curved and circular notation settings', () => {
expect(
ConstructionDimensionNode.safeParse({
mode: 'diameter',
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
}).success,
).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
})
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
const node = ConstructionDimensionNode.parse({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(node).toMatchObject({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
})
test('coordinates one associative dimension across persistent drawing types', () => {
const node = ConstructionDimensionNode.parse({
drawingType: 'foundation-plan',
drawingOverrides: [
{ drawingType: 'floor-plan', presentation: 'controlled' },
{ drawingType: 'roof-plan', presentation: 'shown' },
],
controllingDimensionId: 'construction-dimension_foundation',
})
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
})
test('stores only drawing presentations that differ from the primary defaults', () => {
const node = ConstructionDimensionNode.parse({})
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
expect(shown).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
])
expect(
setConstructionDimensionDrawingPresentation(
{ ...node, drawingOverrides: shown },
'roof-plan',
'omit',
),
).toEqual([])
})
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
const node = ConstructionDimensionNode.parse({})
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
node,
'floor-plan',
[3, 1, 1, -1],
)
expect(drawingOverrides).toEqual([
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1, 3],
},
])
expect(
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
?.suppressedSegmentIndexes,
).toEqual([1, 3])
expect(
setConstructionDimensionDrawingSuppressedSegments(
{ ...node, drawingOverrides },
'floor-plan',
[],
),
).toEqual([])
})
})
@@ -0,0 +1,205 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MeasurementAnchor } from './measurement'
const FiniteCoordinate = z.number().finite()
export const ConstructionDimensionBaseline = z
.object({
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
})
.superRefine((baseline, ctx) => {
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['direction'],
message: 'Construction dimension baseline direction must be non-zero',
})
}
})
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
export const ConstructionDimensionMode = z.enum([
'linear',
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
])
export const ConstructionDrawingType = z.enum([
'floor-plan',
'foundation-plan',
'reflected-ceiling-plan',
'roof-plan',
'site-plan',
])
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
export const ConstructionDimensionDrawingOverride = z.object({
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
})
export const ConstructionDimensionDatumPolicy = z.enum([
'centerline',
'wall-face',
'structural-face',
'finish-face',
])
export const ConstructionDimensionTerminator = z.enum([
'architectural-tick',
'filled-arrow',
'open-arrow',
'dot',
])
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
export const ConstructionDimensionNode = BaseNode.extend({
id: objectId('construction-dimension'),
type: nodeType('construction-dimension'),
anchors: z
.array(MeasurementAnchor)
.min(2)
.default([
[0, 0, 0],
[1, 0, 0],
]),
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
mode: ConstructionDimensionMode.default('linear'),
featureCount: z.number().int().min(1).max(999).default(1),
showCenterMark: z.boolean().default(true),
prefix: z.string().max(40).default(''),
suffix: z.string().max(40).default(''),
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
textPosition: ConstructionDimensionTextPosition.default('above'),
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
}).describe(
dedent`
Construction dimension node - an associative floor-plan construction dimension
- anchors: two or more free or semantic feature anchors that supply the witness origins
- baseline.origin: a point on the independently placed dimension line
- baseline.direction: the fixed plan direction used to project the witness origins
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
- showCenterMark: displays the resolved circle/angle center where applicable
- prefix/suffix/textOverride: document notation overrides without changing geometry
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
- drawingType: the primary persistent drawing that owns the dimension
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
`,
)
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
export type ConstructionDimensionDrawingPresentation = z.infer<
typeof ConstructionDimensionDrawingPresentation
>
export type ConstructionDimensionDrawingOverride = z.infer<
typeof ConstructionDimensionDrawingOverride
>
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
export type ConstructionDimensionImperialPrecision = z.infer<
typeof ConstructionDimensionImperialPrecision
>
export type ConstructionDimensionMetricNotation = z.infer<
typeof ConstructionDimensionMetricNotation
>
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
export function resolveConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingPresentation {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
}
export function resolveConstructionDimensionDrawingOverride(
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingOverride | null {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override ?? null
}
export function setConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = {
drawingType,
presentation,
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
}
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function setConstructionDimensionDrawingSuppressedSegments(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
suppressedSegmentIndexes: readonly number[],
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const presentation = existing?.presentation ?? defaultPresentation
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
}
function isDefaultConstructionDimensionDrawingOverride(
override: ConstructionDimensionDrawingOverride,
defaultPresentation: ConstructionDimensionDrawingPresentation,
): boolean {
return (
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
)
}
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
(left, right) => left - right,
)
}
+23
View File
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment> export type DoorSegment = z.infer<typeof DoorSegment>
export const DoorCategory = z.enum(['interior', 'garage']) 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([ export const DoorType = z.enum([
'hinged', 'hinged',
'double', 'double',
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
export type DoorCategory = z.infer<typeof DoorCategory> export type DoorCategory = z.infer<typeof DoorCategory>
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
export type DoorType = z.infer<typeof DoorType> export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle> export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9), width: z.number().default(0.9),
height: z.number().default(2.1), 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 // Door family
doorCategory: DoorCategory.default('interior'), doorCategory: DoorCategory.default('interior'),
doorType: DoorType.default('hinged'), doorType: DoorType.default('hinged'),
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode } from './building'
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
describe('DrawingSheetNode', () => {
test('creates persistent sheet defaults', () => {
const sheet = DrawingSheetNode.parse({})
expect(sheet.type).toBe('drawing-sheet')
expect(sheet.id).toMatch(/^drawing-sheet_/)
expect(sheet).toMatchObject({
sheetNumber: 'A1.0',
sheetTitle: 'Floor Plan',
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
annotationProfile: 'architectural-default',
placedViews: [],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [],
keyedNoteInstances: [],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
titleBlock: {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
},
})
})
test('stores placed views, notes, schedules, and title-block metadata', () => {
const sheet = DrawingSheetNode.parse({
sheetNumber: 'A2.1',
sheetTitle: 'Enlarged Plans',
paperSize: 'custom',
customPaperWidth: 24,
customPaperHeight: 36,
placedViews: [
{
id: 'drawing-view_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
levelId: 'level_main',
scale: '1/4"=1\'-0"',
viewport: { x: 1, y: 1, width: 12, height: 8 },
},
],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-slab-1',
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
},
{
id: 'keyed-note-instance_patch-slab-2',
definitionId: 'keyed-note_patch-slab',
position: [5, 4],
},
],
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
],
schedules: [
{
id: 'sheet-schedule_room',
scheduleType: 'room',
title: 'Room Schedule',
region: { x: 15, y: 1, width: 6, height: 5 },
},
],
titleBlock: {
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
},
})
expect(sheet.placedViews[0]).toMatchObject({
drawingType: 'floor-plan',
levelId: 'level_main',
annotationProfile: 'architectural-default',
showNorthArrow: true,
showGraphicScale: true,
})
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
expect(sheet.generalNoteSets[0]).toMatchObject({
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ text: 'COORDINATE WITH OWNER.' }],
})
expect(sheet.keyedNoteLegend[0]).toEqual({
key: 'A',
text: 'ALIGN WITH EXISTING WALL.',
})
expect(sheet.keyedNoteDefinitions[0]).toEqual({
id: 'keyed-note_patch-slab',
key: 'A',
text: 'PATCH EXISTING SLAB.',
})
expect(sheet.keyedNoteInstances).toHaveLength(2)
expect(sheet.keyedNoteInstances[0]).toMatchObject({
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
})
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
expect(sheet.documentMarkers).toHaveLength(2)
expect(sheet.documentMarkers[0]).toMatchObject({
kind: 'wall-tag',
label: 'W1',
position: [2, 3],
})
expect(sheet.documentMarkers[1]).toMatchObject({
kind: 'revision-cloud',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
})
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
expect(sheet.titleBlock).toMatchObject({
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
drawnBy: '',
})
})
test('can live under a building instead of a level', () => {
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
'level_main',
sheet.id,
])
})
test('remaps sheet-local identities and their references together', () => {
const sheet = DrawingSheetNode.parse({
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a1',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
},
],
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
schedules: [{ id: 'sheet-schedule_a' }],
})
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
sheet.generalNoteSets[0]?.notes[0]?.id,
)
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
})
})
@@ -0,0 +1,263 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, generateId, nodeType, objectId } from '../base'
import { ConstructionDrawingType } from './construction-dimension'
const PositiveFinite = z.number().finite().positive()
const SheetCoordinate = z.number().finite().min(0)
export const DrawingSheetPaperSize = z.enum([
'letter',
'tabloid',
'arch-a',
'arch-b',
'arch-c',
'a4',
'a3',
'custom',
])
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
export const DrawingSheetScale = z.enum([
'1:20',
'1:25',
'1:50',
'1:75',
'1:100',
'1/8"=1\'-0"',
'1/4"=1\'-0"',
'1/2"=1\'-0"',
'1"=1\'-0"',
])
export const DrawingSheetAnnotationProfile = z.enum([
'architectural-default',
'presentation',
'permit',
])
export const DrawingSheetRect = z.object({
x: SheetCoordinate.default(0),
y: SheetCoordinate.default(0),
width: PositiveFinite.default(1),
height: PositiveFinite.default(1),
})
export const DrawingSheetPlacedView = z.object({
id: objectId('drawing-view'),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingNumber: z.string().trim().min(1).max(24).default('1'),
title: z.string().trim().min(1).max(80).default('Floor Plan'),
levelId: objectId('level').nullable().default(null),
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
showNorthArrow: z.boolean().default(true),
showGraphicScale: z.boolean().default(true),
})
export const DrawingSheetGeneralNote = z.object({
id: objectId('sheet-note'),
number: z.number().int().positive().default(1),
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
})
export const DrawingSheetGeneralNoteSet = z.object({
id: objectId('sheet-note-set'),
name: z.string().trim().min(1).max(80).default('General Notes'),
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
})
export const DrawingSheetKeyedNote = z.object({
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteDefinition = z.object({
id: objectId('keyed-note'),
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteInstance = z.object({
id: objectId('keyed-note-instance'),
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
})
export const DrawingSheetDocumentMarkerKind = z.enum([
'wall-tag',
'glazing-tag',
'assembly-tag',
'section-callout',
'elevation-callout',
'detail-reference',
'delta-marker',
'revision-cloud',
])
export const DrawingSheetDocumentMarker = z.object({
id: objectId('sheet-marker'),
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
label: z.string().trim().min(1).max(32).default('1'),
title: z.string().trim().max(120).default(''),
sheetReference: z.string().trim().max(24).default(''),
drawingReference: z.string().trim().max(24).default(''),
revisionId: z.string().trim().max(16).default(''),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
points: z
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
.max(64)
.default([]),
})
export const DrawingSheetSchedulePlacement = z.object({
id: objectId('sheet-schedule'),
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
title: z.string().trim().min(1).max(80).default('Room Schedule'),
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
})
export const DrawingSheetTitleBlock = z.object({
projectName: z.string().trim().max(120).default(''),
projectNumber: z.string().trim().max(40).default(''),
clientName: z.string().trim().max(120).default(''),
drawnBy: z.string().trim().max(40).default(''),
checkedBy: z.string().trim().max(40).default(''),
issueDate: z.string().trim().max(40).default(''),
revision: z.string().trim().max(20).default(''),
})
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
}
export const DrawingSheetNode = BaseNode.extend({
id: objectId('drawing-sheet'),
type: nodeType('drawing-sheet'),
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
paperSize: DrawingSheetPaperSize.default('arch-b'),
orientation: DrawingSheetOrientation.default('landscape'),
customPaperWidth: PositiveFinite.nullable().default(null),
customPaperHeight: PositiveFinite.nullable().default(null),
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
}).describe(
dedent`
Drawing sheet node - persistent construction-document sheet metadata
- sheetNumber/sheetTitle: sheet identity in the drawing set
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
- schedules/titleBlock: sheet-level documentation content and title-block metadata
`,
)
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
/**
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
* External scene references are preserved when they are not present in
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
*/
export function remapDrawingSheetReferences(
sheet: DrawingSheetNode,
sceneIdMap: ReadonlyMap<string, string>,
): DrawingSheetNode {
const placedViewIds = new Map(
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
)
const noteSetIds = new Map(
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
)
const noteIds = new Map(
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
(note) => [note.id, generateId('sheet-note')] as const,
),
)
const keyedDefinitionIds = new Map(
sheet.keyedNoteDefinitions.map(
(definition) => [definition.id, generateId('keyed-note')] as const,
),
)
return {
...sheet,
placedViews: sheet.placedViews.map((view) => ({
...view,
id: placedViewIds.get(view.id)!,
levelId: view.levelId
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
: null,
})),
generalNoteSetIds: sheet.generalNoteSetIds.map(
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
),
generalNoteSets: sheet.generalNoteSets.map((set) => ({
...set,
id: noteSetIds.get(set.id)!,
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
})),
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
...definition,
id: keyedDefinitionIds.get(definition.id)!,
})),
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
...instance,
id: generateId('keyed-note-instance'),
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
instance.definitionId) as typeof instance.definitionId,
placedViewId: instance.placedViewId
? ((placedViewIds.get(instance.placedViewId) ??
instance.placedViewId) as typeof instance.placedViewId)
: null,
})),
documentMarkers: sheet.documentMarkers.map((marker) => ({
...marker,
id: generateId('sheet-marker'),
placedViewId: marker.placedViewId
? ((placedViewIds.get(marker.placedViewId) ??
marker.placedViewId) as typeof marker.placedViewId)
: null,
})),
schedules: sheet.schedules.map((schedule) => ({
...schedule,
id: generateId('sheet-schedule'),
})),
}
}
+4
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling' import { CeilingNode } from './ceiling'
import { ColumnNode } from './column' import { ColumnNode } from './column'
import { ConstructionDimensionNode } from './construction-dimension'
import { DuctFittingNode } from './duct-fitting' import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment' import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal' import { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import { ShelfNode } from './shelf'
import { SlabNode } from './slab' import { SlabNode } from './slab'
import { SpawnNode } from './spawn' import { SpawnNode } from './spawn'
import { StairNode } from './stair' import { StairNode } from './stair'
import { StructuralGridNode } from './structural-grid'
import { WallNode } from './wall' import { WallNode } from './wall'
import { ZoneNode } from './zone' import { ZoneNode } from './zone'
@@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({
WallNode.shape.id, WallNode.shape.id,
FenceNode.shape.id, FenceNode.shape.id,
ColumnNode.shape.id, ColumnNode.shape.id,
ConstructionDimensionNode.shape.id,
StructuralGridNode.shape.id,
ItemNode.shape.id, ItemNode.shape.id,
ZoneNode.shape.id, ZoneNode.shape.id,
SlabNode.shape.id, SlabNode.shape.id,
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { LevelNode } from './level'
import { StructuralGridNode } from './structural-grid'
describe('StructuralGridNode', () => {
test('fills stable construction-document defaults', () => {
const grid = StructuralGridNode.parse({})
expect(grid.id).toStartWith('structural-grid_')
expect(grid).toMatchObject({
type: 'structural-grid',
start: [0, 0],
end: [0, 5],
label: '1',
showStartBubble: true,
showEndBubble: true,
})
})
test('is accepted as a level child', () => {
expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([
'structural-grid_axis-1',
])
})
test('rejects empty labels and zero-length concerns stay in authoring', () => {
expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow()
expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({
start: [1, 1],
end: [1, 1],
})
})
})
@@ -0,0 +1,22 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const StructuralGridNode = BaseNode.extend({
id: objectId('structural-grid'),
type: nodeType('structural-grid'),
start: z.tuple([z.number(), z.number()]).default([0, 0]),
end: z.tuple([z.number(), z.number()]).default([0, 5]),
label: z.string().trim().min(1).max(12).default('1'),
showStartBubble: z.boolean().default(true),
showEndBubble: z.boolean().default(true),
}).describe(
dedent`
Structural grid node - a persistent floor-plan datum axis with identification bubbles
- start/end: level-local plan coordinates defining the grid axis extent
- label: axis identifier, commonly numeric in one direction and alphabetic in the other
- showStartBubble/showEndBubble: independently control the two endpoint identifiers
`,
)
export type StructuralGridNode = z.infer<typeof StructuralGridNode>
+216 -6
View File
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
import { import {
buildEnabledWallFaceBandPatch, buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch, buildWallFaceBandCountPatch,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
getWallFaceBandConfig, getWallFaceBandConfig,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT, WALL_CROWN_DEFAULT,
@@ -13,7 +19,8 @@ import {
WALL_SKIRTING_SLOT_DEFAULT, WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS, WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig, WallFaceBandConfig,
type WallNode, WallNode,
type WallNode as WallNodeType,
WallTrimConfig, WallTrimConfig,
} from './wall' } from './wall'
@@ -99,7 +106,7 @@ describe('wall face bands', () => {
lowerInterior: 'library:stale-lower', lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle', middleExterior: 'library:stale-middle',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>) } as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({ expect(patch.faceBands).toEqual({
enabled: true, enabled: true,
@@ -135,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish', exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top', topInterior: 'library:stale-top',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
3, 3,
) )
@@ -159,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle', middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper', upperExterior: 'library:stale-upper',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>) } as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({ expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
@@ -187,7 +194,7 @@ describe('wall face bands', () => {
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
3, 3,
) )
@@ -219,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior', upperExterior: 'library:painted-top-exterior',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
4, 4,
) )
@@ -260,3 +267,206 @@ describe('wall trim profiles', () => {
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
}) })
}) })
describe('wall assembly layers', () => {
test('defaults to legacy thickness when no assembly layers are modeled', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(wall.assemblyLayers).toEqual([])
expect(getWallAssemblyThickness(wall)).toBe(0.14)
})
test('stores role, side, thickness, material reference, and datum eligibility', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face', 'finish-face'],
},
],
})
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
'interior-gwb',
'brick-veneer',
])
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
{ id: 'stud-core', role: 'structure', side: 'core' },
])
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
interior: -0.061,
exterior: 0.135,
})
})
test('resolves stable datum references for legacy single-thickness walls', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
{
id: 'wall:structural-face:interior',
datum: 'structural-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:structural-face:exterior',
datum: 'structural-face',
side: 'exterior',
offset: 0.07,
},
{
id: 'wall:finish-face:interior',
datum: 'finish-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:finish-face:exterior',
datum: 'finish-face',
side: 'exterior',
offset: 0.07,
},
])
})
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-sheathing',
role: 'exterior-sheathing',
side: 'exterior',
thickness: 0.012,
materialRef: 'library:sheathing',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const references = resolveWallAssemblyDatumReferences(wall)
expect(references).toContainEqual({
id: 'wall:centerline:center',
datum: 'centerline',
side: 'center',
offset: 0,
})
expect(references).toContainEqual({
id: 'wall:structural-face:interior:stud-core',
datum: 'structural-face',
side: 'interior',
layerId: 'stud-core',
offset: -0.045,
})
expect(references).toContainEqual({
id: 'wall:structural-face:exterior:stud-core',
datum: 'structural-face',
side: 'exterior',
layerId: 'stud-core',
offset: 0.045,
})
expect(references).toContainEqual({
id: 'wall:finish-face:interior:interior-gwb',
datum: 'finish-face',
side: 'interior',
layerId: 'interior-gwb',
offset: -0.061,
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
),
).toMatchObject({
datum: 'finish-face',
side: 'exterior',
layerId: 'exterior-sheathing',
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
)?.offset,
).toBeCloseTo(0.057)
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
})
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
?.offset,
).toBeCloseTo(0.147)
expect(
resolveWallAssemblyDatumReference(
wall,
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
offset: 0.147,
})
})
})
+260
View File
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallAssemblyLayerRole = z.enum([
'structure',
'interior-finish',
'exterior-sheathing',
'exterior-finish',
'masonry-veneer',
'air-space',
'concrete-block',
'structural-masonry',
'solid-concrete',
'furring',
])
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
export const WallDimensionDatum = z.enum([
'centerline',
'structural-face',
'finish-face',
'veneer-face',
])
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
export const WallAssemblyLayer = z.object({
id: z.string().trim().min(1).max(80).default('structure'),
role: WallAssemblyLayerRole.default('structure'),
side: z.enum(['core', 'interior', 'exterior']).default('core'),
thickness: z.number().finite().positive().default(0.1),
materialRef: z.string().trim().max(120).default(''),
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
})
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
export type WallAssemblyDatumReference = {
id: string
datum: WallDimensionDatum
side: WallAssemblyDatumSide
layerId?: string
offset: number
}
export const WallNode = BaseNode.extend({ export const WallNode = BaseNode.extend({
id: objectId('wall'), id: objectId('wall'),
type: nodeType('wall'), type: nodeType('wall'),
@@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({
// in a follow-up once migrated scenes are the norm. // in a follow-up once migrated scenes are the norm.
slots: z.record(z.string(), z.string()).optional(), slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(), curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules. // Persisted slab-support host — see ItemNode.supportSlabId for the rules.
@@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({
dedent` dedent`
Wall node - used to represent a wall in the building Wall node - used to represent a wall in the building
- thickness: thickness in meters - thickness: thickness in meters
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
- height: height in meters - height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc - curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system - start: start point of the wall in level coordinate system
@@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId =
| 'upperExterior' | 'upperExterior'
| 'topExterior' | 'topExterior'
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
return wall.assemblyLayers ?? []
}
export function getWallAssemblyThickness(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): number {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return wall.thickness ?? 0.1
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
}
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
interior: number
exterior: number
} {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return { interior: -halfThickness, exterior: halfThickness }
}
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const interiorFinishThickness = layers
.filter((layer) => layer.side === 'interior')
.reduce((sum, layer) => sum + layer.thickness, 0)
const exteriorFinishThickness = layers
.filter((layer) => layer.side === 'exterior')
.reduce((sum, layer) => sum + layer.thickness, 0)
return {
interior: -coreThickness / 2 - interiorFinishThickness,
exterior: coreThickness / 2 + exteriorFinishThickness,
}
}
export function getWallDatumEligibleLayers(
wall: Pick<WallNode, 'assemblyLayers'>,
datum: WallDimensionDatum,
): WallAssemblyLayer[] {
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
}
export function getWallAssemblyDatumReferenceId(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
layerId?: string,
): string {
return ['wall', datum, side, layerId].filter(Boolean).join(':')
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function getWallAssemblyLayerSpans(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyLayerSpan[] {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const coreInteriorFace = -coreThickness / 2
const coreExteriorFace = coreThickness / 2
const spans: WallAssemblyLayerSpan[] = []
let coreOffset = coreInteriorFace
for (const layer of coreLayers) {
const interiorOffset = coreOffset
const exteriorOffset = coreOffset + layer.thickness
spans.push({ layer, interiorOffset, exteriorOffset })
coreOffset = exteriorOffset
}
let interiorOffset = coreInteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
const exteriorOffset = interiorOffset
const nextInteriorOffset = exteriorOffset - layer.thickness
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
interiorOffset = nextInteriorOffset
}
let exteriorOffset = coreExteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
const interiorFaceOffset = exteriorOffset
const nextExteriorOffset = interiorFaceOffset + layer.thickness
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
exteriorOffset = nextExteriorOffset
}
return spans
}
function createWallAssemblyDatumReference(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
offset: number,
layerId?: string,
): WallAssemblyDatumReference {
return {
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
datum,
side,
...(layerId ? { layerId } : {}),
offset,
}
}
export function resolveWallAssemblyDatumReferences(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyDatumReference[] {
const layers = wall.assemblyLayers ?? []
const references: WallAssemblyDatumReference[] = [
createWallAssemblyDatumReference('centerline', 'center', 0),
]
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return [
...references,
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
]
}
const spans = getWallAssemblyLayerSpans(wall)
for (const span of spans) {
if (span.layer.datumEligible.includes('structural-face')) {
if (span.layer.side === 'core') {
references.push(
createWallAssemblyDatumReference(
'structural-face',
'interior',
span.interiorOffset,
span.layer.id,
),
createWallAssemblyDatumReference(
'structural-face',
'exterior',
span.exteriorOffset,
span.layer.id,
),
)
} else {
const side = span.layer.side
references.push(
createWallAssemblyDatumReference(
'structural-face',
side,
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
span.layer.id,
),
)
}
}
if (span.layer.datumEligible.includes('finish-face')) {
const side = span.layer.side === 'core' ? 'center' : span.layer.side
const offset =
span.layer.side === 'interior'
? span.interiorOffset
: span.layer.side === 'exterior'
? span.exteriorOffset
: (span.interiorOffset + span.exteriorOffset) / 2
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
}
if (span.layer.datumEligible.includes('veneer-face')) {
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
}
}
if (!references.some((reference) => reference.datum === 'structural-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
)
}
if (!references.some((reference) => reference.datum === 'finish-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
)
}
return references
}
export function resolveWallAssemblyDatumReference(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
referenceId: string,
): WallAssemblyDatumReference | null {
return (
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
null
)
}
// Declared default appearance for an unpainted wall face in colored mode — // Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
// slot declaration (nodes) and the material resolver (viewer) share one value. // slot declaration (nodes) and the material resolver (viewer) share one value.
+22
View File
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
]) ])
export type WindowType = z.infer<typeof WindowType> export type WindowType = z.infer<typeof WindowType>
export const WindowConstructionType = z.enum(['framed', 'masonry'])
export const WindowDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
export const WindowNode = BaseNode.extend({ export const WindowNode = BaseNode.extend({
id: objectId('window'), id: objectId('window'),
type: nodeType('window'), type: nodeType('window'),
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5), width: z.number().default(1.5),
height: 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 // Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'), openingKind: z.enum(['window', 'opening']).default('window'),
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { ZoneNode } from './zone'
describe('ZoneNode architectural room data', () => {
test('keeps legacy zones generic while supplying room-safe defaults', () => {
const zone = ZoneNode.parse({
id: 'zone_legacy',
name: 'Landscape area',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
})
expect(zone).toMatchObject({
spaceRole: 'generic',
roomNumber: '',
enclosureStatus: 'auto',
floorFinish: '',
wallFinish: '',
ceilingFinish: '',
ceilingHeight: 2.7,
occupancy: '',
clearDimensionPolicy: 'none',
})
})
test('persists a complete architectural room profile', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
spaceRole: 'room',
roomNumber: '101',
enclosureStatus: 'enclosed',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 3,
occupancy: 'Business',
clearDimensionPolicy: 'inside-faces',
})
expect(room.spaceRole).toBe('room')
expect(room.roomNumber).toBe('101')
expect(room.clearDimensionPolicy).toBe('inside-faces')
})
})
+15
View File
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
// stored polygon remains a fallback for missing or temporarily open walls. // stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]), 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 // Visual styling
color: z.string().default('#3b82f6'), // Default blue color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}), metadata: z.json().optional().default({}),
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
- polygon: array of [x, z] points defining the zone boundary - polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop - autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure - 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 - color: hex color for visual styling
- metadata: zone metadata (optional) - metadata: zone metadata (optional)
`, `,
+6
View File
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling' import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney' import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column' import { ColumnNode } from './nodes/column'
import { ConstructionDimensionNode } from './nodes/construction-dimension'
import { CupolaNode } from './nodes/cupola' import { CupolaNode } from './nodes/cupola'
import { DoorNode } from './nodes/door' import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer' import { DormerNode } from './nodes/dormer'
import { DownspoutNode } from './nodes/downspout' import { DownspoutNode } from './nodes/downspout'
import { DrawingSheetNode } from './nodes/drawing-sheet'
import { DuctFittingNode } from './nodes/duct-fitting' import { DuctFittingNode } from './nodes/duct-fitting'
import { DuctSegmentNode } from './nodes/duct-segment' import { DuctSegmentNode } from './nodes/duct-segment'
import { DuctTerminalNode } from './nodes/duct-terminal' import { DuctTerminalNode } from './nodes/duct-terminal'
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn' import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair' import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment' import { StairSegmentNode } from './nodes/stair-segment'
import { StructuralGridNode } from './nodes/structural-grid'
import { TurbineVentNode } from './nodes/turbine-vent' import { TurbineVentNode } from './nodes/turbine-vent'
import { WallNode } from './nodes/wall' import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window' import { WindowNode } from './nodes/window'
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ElevatorNode, ElevatorNode,
LevelNode, LevelNode,
ColumnNode, ColumnNode,
ConstructionDimensionNode,
StructuralGridNode,
WallNode, WallNode,
FenceNode, FenceNode,
CabinetNode, CabinetNode,
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode, SkylightNode,
DormerNode, DormerNode,
DownspoutNode, DownspoutNode,
DrawingSheetNode,
DuctSegmentNode, DuctSegmentNode,
DuctFittingNode, DuctFittingNode,
DuctTerminalNode, DuctTerminalNode,
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene construction-dimension migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('normalizes the legacy reference presentation before parsing', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['construction-dimension_test'],
level: 0,
},
'construction-dimension_test': {
object: 'node',
id: 'construction-dimension_test',
type: 'construction-dimension',
parentId: 'level_test',
visible: true,
metadata: {},
reference: true,
referenceStyle: 'suffix',
drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }],
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
Record<string, unknown>
expect(dimension.reference).toBeUndefined()
expect(dimension.referenceStyle).toBeUndefined()
expect(dimension.drawingOverrides).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown' },
])
})
})
+25
View File
@@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next return next
} }
function migrateConstructionDimension(node: Record<string, any>) {
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
const hasLegacyDrawingOverride = drawingOverrides.some(
(entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
entry.presentation === 'reference',
)
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
return {
...dimension,
drawingOverrides: drawingOverrides.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
}),
}
}
// Walls whose top lands within this of the storey plane become plane-bound; // 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 // ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both. // follows-mode (step 3f) — same census-backed threshold for both.
@@ -687,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
} }
} }
if (node.type === 'construction-dimension') {
patchedNodes[id] = migrateConstructionDimension(node)
}
if (node.type === 'stair') { if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
+1 -1
View File
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
} }
} }
function getWallArcData(wall: WallCurveLike) { export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall) const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall) const sagitta = getClampedWallCurveOffset(wall)
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true, visible: true,
parentId: 'level_test', parentId: 'level_test',
children: [], children: [],
assemblyLayers: [],
start, start,
end, end,
thickness: 0.1, thickness: 0.1,
@@ -77,6 +77,119 @@ describe('forkSceneGraph', () => {
}) })
}) })
describe('construction-dimension clone references', () => {
function sceneWithControlledDimensions(): SceneGraph {
const site = makeNode('site_1', 'site', { children: ['level_1'] })
const level = makeNode('level_1', 'level', {
parentId: 'site_1',
children: ['construction-dimension_foundation', 'construction-dimension_floor'],
})
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
name: 'Foundation controller',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: null,
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
name: 'Floor dependent',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: controller.id,
})
return {
nodes: {
[site.id]: site,
[level.id]: level,
[controller.id]: controller,
[dependent.id]: dependent,
},
rootNodeIds: [site.id],
}
}
test('remaps controller IDs in whole-scene clones', () => {
const cloned = cloneSceneGraph(sceneWithControlledDimensions())
const dimensions = Object.values(cloned.nodes).filter(
(node) => node.type === 'construction-dimension',
)
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
test('remaps controller IDs in level-subtree clones', () => {
const scene = sceneWithControlledDimensions()
const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
})
describe('drawing-sheet clone references', () => {
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
const level = makeNode('level_main', 'level')
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const cloned = cloneSceneGraph({
nodes: { [level.id]: level, [sheet.id]: sheet },
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
})
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
expect(clonedLevel).toBeDefined()
expect(clonedSheet?.type).toBe('drawing-sheet')
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
clonedSheet.keyedNoteDefinitions[0]?.id,
)
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
}
})
})
describe('supportSlabId remap', () => { describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
+19 -3
View File
@@ -1,8 +1,12 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' import { 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 type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base' import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections' import type { Collection, CollectionId } from '../schema/collections'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = { export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
@@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) { for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId 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 // Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
if (clonedNode.type === 'measurement') { if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) 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 clonedNodes[newId] = clonedNode
} }
@@ -221,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc. // JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId ;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself // Remap parentId — but only for descendants, not the level node itself
@@ -274,6 +284,12 @@ export function cloneLevelSubtree(
if (cloned.type === 'measurement') { if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) 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) clonedNodes.push(cloned)
} }
+4 -2
View File
@@ -40,16 +40,16 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0", "@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0", "pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8", "three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -59,8 +59,10 @@
"@pascal-app/core": "^0.9.2", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.2", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides) const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit) const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const ctx = useFloorplanRender() const ctx = useFloorplanRender()
if (guides.length === 0) return null if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X. // offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeasurement(distMeters, unit) const distLabel = formatMeasurement(distMeters, unit, metricNotation)
const charWidth = pillFontSize * 0.55 const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2 const pillHeight = pillFontSize + pillPadY * 2
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
const draftLevelId = useMeasurementDraft((state) => state.levelId) const draftLevelId = useMeasurementDraft((state) => state.levelId)
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
useEffect(() => { useEffect(() => {
if (active) { if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
angle: Math.atan2(end[2] - start[2], end[0] - start[0]), angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
screenUpright: false, screenUpright: false,
text: formatLinearMeasurement(measurementDistance(start, end), unit), text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
} }
} else if (kind === 'angle' && livePoints.length >= 3) { } else if (kind === 'angle' && livePoints.length >= 3) {
const anglePoints = livePoints.slice(0, 3) as [ const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
text: text:
kind === 'area' kind === 'area'
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}` ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`, : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
} }
} }
} else if (kind === 'volume' && center && baseNormal) { } else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
(segmentStart[1] + segmentEnd[1]) / 2, (segmentStart[1] + segmentEnd[1]) / 2,
(segmentStart[2] + segmentEnd[2]) / 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, polygonPoints,
segmentLabel, segmentLabel,
} }
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag]) }, [
axisGuide,
baseNormal,
extrusionHeight,
hover,
kind,
metricNotation,
points,
stage,
unit,
vertexDrag,
])
if (smartActive) return <FloorplanQuickMeasureLayer /> if (smartActive) return <FloorplanQuickMeasureLayer />
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
text={`${hover.semantic.label}${ text={`${hover.semantic.label}${
hover.semantic.length === null hover.semantic.length === null
? '' ? ''
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}` : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
}`} }`}
textColor={labelText} textColor={labelText}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
@@ -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<unknown>, ComponentType<FloorplanToolContext>>()
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | 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<FloorplanToolContext['selectNode']>[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 ? (
<Suspense fallback={null}>
<Tool
activeLevelId={activeLevelId}
finishTool={finishTool}
gridSnapStep={gridSnapStep}
metricNotation={metricNotation}
sceneApi={sceneApi}
selectNode={selectNode}
toolDefaults={toolDefaults}
unit={unit}
/>
</Suspense>
) : null
}
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion' import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation' import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import { import {
createFreshPlacementSubtree, createFreshPlacementSubtree,
duplicatesAsFreshSubtree, duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement' } from '../../lib/fresh-planar-placement'
import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' 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 { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref' import { IconRefGlyph } from '../ui/icon-ref'
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path. * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* Walls are excluded — their move is reached via the side-arrow * Walls are excluded — their move is reached via the side-arrow
* handles emitted from `def.floorplan`, not via a menu button. * 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 * - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy * hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`. * `handleAddHole` in `floating-action-menu.tsx`.
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def. * `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() { export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion() const reducedMotion = useReducedMotion()
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined, s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
) as AnyNodeId | undefined ) as AnyNodeId | undefined
const movingNode = useMovingNode() const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D // 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 // Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries). // own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) 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 def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def const isRegistryKind = !!def
const isVisible = const isVisible =
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered isRegistryKind &&
def?.presentation?.actionMenu !== false &&
!movingNode &&
!isCurveReshape &&
isFloorplanHovered
const isWall = selectedKind === 'wall' const isWall = selectedKind === 'wall'
const quickActionNodes = useScene( const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), 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 = () => { const handleDuplicate = () => {
if (!node.parentId) return if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined} onAddHole={canAddHole ? handleAddHole : undefined}
onCurve={canCurve ? handleCurve : undefined}
onDelete={canDelete ? handleDelete : undefined} onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined} onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined} onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,408 @@
import { describe, expect, test } from 'bun:test'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
collectAnnotationLayoutPreflightIssues,
floorplanAnnotationObstacleMode,
observeSvgAnnotationLayoutChanges,
polylineObstacleRectangles,
resolveAnnotationLabelRectangles,
} from './floorplan-annotation-layout'
describe('floorplanAnnotationObstacleMode', () => {
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
}
})
})
@@ -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<string, Set<AnnotationObstacleRectangle>>()
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<AnnotationObstacleRectangle>()
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<Record<string, AnnotationLayoutOverride>>
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<string, AnnotationLabelShift>()
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<SVGGElement>('[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<string, { x: number; y: number }>()
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<SVGGraphicsElement>('[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<SVGLineElement>('[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<SVGLineElement>('[data-floorplan-dimension-leader]')) {
leader.setAttribute('visibility', 'hidden')
}
}
function applyOutsideStartDimensionLine(label: SVGGElement): void {
const dimension = label.closest('[data-floorplan-dimension]')
const line = dimension?.querySelector<SVGLineElement>('[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<SVGLineElement>('[data-floorplan-dimension-leader]')
const dimensionLine = dimension?.querySelector<SVGLineElement>('[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<string, { dx: number; dy: number; preference: number; cost: number }>()
const visited = new Set<string>()
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<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
right: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
): 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,
}
}
@@ -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<FloorplanGeometry, { kind: 'dimension' }>
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<FloorplanGeometry, { kind: 'dimension' }>
const layout = computeArchitecturalDimensionLayout(customDimension, 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={customDimension} />
</svg>,
)
expect(layout?.extensionStart).toEqual([0, 0.2])
expect(markup).toContain('<circle')
expect(markup).toContain('y="0.0525"')
})
test('moves a short value beyond its end tick and extends the dimension line', () => {
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(
<svg>
<FloorplanDimensionRenderer geometry={shortDimension} sceneRotationDeg={37} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
expect(markup).not.toContain('data-floorplan-dimension-leader=""')
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer
annotationUnitsPerPoint={0.01}
geometry={shortDimension}
sceneRotationDeg={37}
/>
</svg>,
)
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(
<svg>
<FloorplanDimensionRenderer geometry={dimension} />
</svg>,
)
expect(markup.match(/<line/g)).toHaveLength(5)
expect(markup).not.toContain('stroke-dasharray')
expect(markup).toContain('y="-0.12"')
expect(markup).toContain('13&#x27;-1 1/2&quot;')
expect(markup).toContain('paint-order="stroke"')
expect(markup).toContain('stroke="#ffffff"')
expect(markup).toContain('data-floorplan-annotation-priority="145"')
})
test('keeps farther-out architectural strings fixed before inner strings', () => {
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(
<svg>
<FloorplanDimensionRenderer annotationUnitsPerPoint={0.01} geometry={dimension} />
</svg>,
)
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(
<svg>
<FloorplanDimensionRenderer geometry={dimension} renderMode="pdf" />
</svg>,
)
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<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={stringGeometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-string=""')
expect(markup.match(/<line/g)).toHaveLength(8)
expect(markup).toContain('2m')
expect(markup).toContain('3m')
})
test('offsets automatic dimension-string lines when no explicit baseline is supplied', () => {
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<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={automaticString} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
})
})
@@ -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<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
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 (
<g data-floorplan-dimension="" pointerEvents="none">
<line
{...lineProps}
x1={layout.extensionStart[0]}
x2={layout.extensionStartTip[0]}
y1={layout.extensionStart[1]}
y2={layout.extensionStartTip[1]}
/>
<line
{...lineProps}
x1={layout.extensionEnd[0]}
x2={layout.extensionEndTip[0]}
y1={layout.extensionEnd[1]}
y2={layout.extensionEndTip[1]}
/>
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
{renderTerminator(
terminator,
layout.dimensionStart,
layout.dimensionEnd,
layout,
lineProps,
tickStrokeWidth,
)}
{renderTerminator(
terminator,
layout.dimensionEnd,
layout.dimensionStart,
layout,
lineProps,
tickStrokeWidth,
)}
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={geometry.text}
y={labelY}
/>
</g>
</g>
)
}
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<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
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 (
<g data-floorplan-dimension-string="" pointerEvents="none">
{[...extensionLines.values()].map((line, index) => (
<line
{...lineProps}
key={`extension-${index}`}
x1={line.start[0]}
x2={line.tip[0]}
y1={line.start[1]}
y2={line.tip[1]}
/>
))}
{segmentLayouts.map(({ index, layout }) => (
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
key={`dimension-line-${index}`}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
))}
{[...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 (
<g key={`label-${index}`}>
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={segment.text}
y={labelY}
/>
</g>
</g>
)
})}
</g>
)
}
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' ? (
<rect
data-floorplan-dimension-label-plate=""
fill="#ffffff"
height={plateHeight}
rx={fontSize * 0.12}
ry={fontSize * 0.12}
width={plateWidth}
x={-plateWidth / 2}
y={plateY}
/>
) : null}
<text
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={500}
paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
strokeWidth={renderMode === 'pdf' ? undefined : 3}
textAnchor="middle"
vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
x={0}
y={y}
>
{text}
</text>
</>
)
}
function renderTerminator(
terminator: DimensionTerminator,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
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 (
<circle
fill={lineProps.stroke}
key={key}
r={tickHalfLength * 0.45}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[1]}
/>
)
}
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 (
<polygon
fill={lineProps.stroke}
key={key}
points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
vectorEffect="non-scaling-stroke"
/>
)
}
return (
<g key={key}>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={left[0]}
y1={point[1]}
y2={left[1]}
/>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={right[0]}
y1={point[1]}
y2={right[1]}
/>
</g>
)
}
const [tickX, tickY] = layout.tickHalfVector
return (
<line
{...lineProps}
key={key}
strokeWidth={tickStrokeWidth}
x1={point[0] - tickX}
x2={point[0] + tickX}
y1={point[1] - tickY}
y2={point[1] + tickY}
/>
)
}
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)}`
}
@@ -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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('<line')
expect(markup).toContain('2.00m')
expect(markup).toContain('translate(0 -0.14)')
})
test('keeps screen-upright measurement labels readable in rotated exports', () => {
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} sceneRotationDeg={90} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} screenUnitsPerPixel={0.02} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer
geometry={geometry}
renderMode="pdf"
screenUnitsPerPixel={0.02}
/>
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const pdfMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle=""')
})
test('registers semantic plan primitives as annotation obstacles', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
],
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
}}
/>
</svg>,
)
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(
<svg>
<FloorplanGeometryRenderer geometry={roomLabel} />
<FloorplanGeometryRenderer geometry={stairArrow} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
})
@@ -2,6 +2,29 @@
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core' import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react' 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 * 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({ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry, geometry,
pointerEventsOverride, pointerEventsOverride,
sceneRotationDeg = 0,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode = 'screen',
}: { }: {
geometry: FloorplanGeometry geometry: FloorplanGeometry
pointerEventsOverride?: string 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( function styleAttrs(
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> }, g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
pointerEventsOverride?: string, pointerEventsOverride?: string,
annotationUnitsPerPoint?: number,
renderMode: FloorplanRenderMode = 'screen',
) { ) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per- // Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land // primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
pointerEvents?: string pointerEvents?: string
cursor?: 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 { 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, fillOpacity: s.fillOpacity,
stroke: s.stroke, stroke: documentStyle.stroke ?? s.stroke,
strokeWidth: s.strokeWidth, strokeWidth,
strokeDasharray: s.strokeDasharray, strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap, strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin, strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity, strokeOpacity: s.strokeOpacity,
opacity: s.opacity, opacity: s.opacity,
vectorEffect: s.vectorEffect, vectorEffect,
pointerEvents: pointerEventsOverride ?? s.pointerEvents, pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined, style: s.cursor ? { cursor: s.cursor } : undefined,
} }
} }
export function resolveDocumentFloorplanAnnotationStyle(
geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
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<FloorplanGeometry, { kind: 'text' }>,
annotationUnitsPerPoint: number,
): number {
return documentTextSizePt(geometry) * annotationUnitsPerPoint
}
function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): 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<FloorplanGeometry, { kind: 'rect' }>,
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<FloorplanGeometry, { kind: 'rect' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
}
export function documentCircleGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
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<FloorplanGeometry, { kind: 'circle' }>): 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<FloorplanGeometry, { kind: 'text' }>[]
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<FloorplanGeometry, { kind: 'text' }> {
return geometry?.kind === 'text' && geometry.upright === true
}
function isSameDocumentTextRun(
first: Extract<FloorplanGeometry, { kind: 'text' }>,
candidate: FloorplanGeometry | undefined,
): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
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<FloorplanGeometry, { kind: 'text' }>[],
annotationUnitsPerPoint: number,
): Extract<FloorplanGeometry, { kind: 'text' }>[] {
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( function renderNode(
g: FloorplanGeometry, g: FloorplanGeometry,
keyHint: number, keyHint: number,
pointerEventsOverride?: string, pointerEventsOverride?: string,
sceneRotationDeg = 0,
annotationUnitsPerPoint?: number,
screenUnitsPerPixel?: number,
renderMode: FloorplanRenderMode = 'screen',
): React.ReactElement | null { ): React.ReactElement | null {
switch (g.kind) { switch (g.kind) {
case 'path': case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} /> return (
<path
d={g.d}
key={keyHint}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'polygon': case 'polygon':
return ( return (
<polygon <polygon
key={keyHint} key={keyHint}
points={pointsToAttr(g.points)} points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
@@ -93,34 +338,38 @@ function renderNode(
<polyline <polyline
key={keyHint} key={keyHint}
points={pointsToAttr(g.points)} points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
case 'rect': case 'rect': {
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
return ( return (
<rect <rect
height={g.height} height={attrs.height}
key={keyHint} key={keyHint}
rx={g.rx} rx={attrs.rx}
ry={g.ry} ry={attrs.ry}
width={g.width} width={attrs.width}
x={g.x} x={attrs.x}
y={g.y} y={attrs.y}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
}
case 'circle': case 'circle': {
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
return ( return (
<circle <circle
cx={g.cx} cx={g.cx}
cy={g.cy} cy={g.cy}
key={keyHint} key={keyHint}
r={g.r} r={attrs.r}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
}
case 'line': case 'line':
return ( return (
@@ -130,25 +379,65 @@ function renderNode(
x2={g.x2} x2={g.x2}
y1={g.y1} y1={g.y1}
y2={g.y2} 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 ( return (
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text <text
dominantBaseline={g.dominantBaseline ?? 'middle'} dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'} fill={fill}
fontFamily={g.fontFamily} fontFamily={g.fontFamily}
fontSize={g.fontSize} fontSize={fontSize}
fontWeight={g.fontWeight}
opacity={g.opacity}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
pointerEvents={pointerEventsOverride}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
return (
<text
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={fontSize}
fontWeight={g.fontWeight} fontWeight={g.fontWeight}
key={keyHint} key={keyHint}
opacity={g.opacity} opacity={g.opacity}
paintOrder={g.paintOrder} paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
stroke={g.stroke} stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined} strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined} strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth} strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'} textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride} pointerEvents={pointerEventsOverride}
x={g.x} x={g.x}
@@ -157,6 +446,93 @@ function renderNode(
{g.text} {g.text}
</text> </text>
) )
}
case 'dimension':
return (
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
annotationUnitsPerPoint={annotationUnitsPerPoint}
renderMode={renderMode}
/>
)
case 'dimension-string':
return (
<FloorplanDimensionStringRenderer
annotationUnitsPerPoint={annotationUnitsPerPoint}
geometry={g}
key={keyHint}
renderMode={renderMode}
sceneRotationDeg={sceneRotationDeg}
/>
)
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 (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={labelTransform}
>
{outlined && !pdfOutlined ? null : (
<rect
data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
fill="#ffffff"
height={plateH}
opacity={0.92}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
stroke={pdfOutlined ? undefined : '#334155'}
strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
)}
<text
dominantBaseline="middle"
fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
fontFamily={
outlined && !pdfOutlined
? 'system-ui, -apple-system, sans-serif'
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
}
fontSize={fontSize}
fontWeight={outlined ? 500 : 600}
paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
stroke={outlined && !pdfOutlined ? '#334155' : undefined}
strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
textAnchor="middle"
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
case 'image': case 'image':
return ( return (
@@ -174,15 +550,32 @@ function renderNode(
case 'group': { case 'group': {
const transform = formatTransform(g.transform) const transform = formatTransform(g.transform)
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
return ( return (
<g key={keyHint} transform={transform}> <g
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))} data-floorplan-annotation-obstacle={
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
}
key={keyHint}
transform={transform}
>
{children.map((child, i) =>
renderNode(
child,
i,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
),
)}
</g> </g>
) )
} }
// The interactive primitives (hatch / hit-line / endpoint-handle / // The remaining interactive primitives (hatch / hit-line / endpoint-handle)
// dimension-label) need the SVG context + theme palette + units-per- // need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered // pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If // by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it // a caller routes one of these through this pure renderer it
@@ -3,15 +3,25 @@ import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
FloorplanAffordanceSession, FloorplanAffordanceSession,
FloorplanGeometry,
LiveNodeOverrides, LiveNodeOverrides,
} from '@pascal-app/core' } from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } 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 { z } from 'zod'
import {
FLOORPLAN_NODE_EXTENSION_KEY,
floorplanGeometryMetadata,
} from '../../../lib/floorplan/floorplan-extension'
import { import {
cancelFloorplanAffordanceDrag, cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes, collectFloorplanDependencyNodes,
collectFloorplanLinkedLevelNodes,
computeAffectedSiblingIds, computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance, floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel, subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer' } 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', () => { describe('computeAffectedSiblingIds', () => {
beforeEach(() => { beforeEach(() => {
nodeRegistry._reset() 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([])
})
})
@@ -33,6 +33,7 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -47,6 +48,16 @@ import {
snapDirectRotationDelta, snapDirectRotationDelta,
} from '../../../lib/direct-manipulation' } from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api' 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 { clientToPlan } from '../../../lib/floorplan/plan-coords'
import { import {
type ActiveInteractionScope, type ActiveInteractionScope,
@@ -60,7 +71,10 @@ import {
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useDrawingView from '../../../store/use-drawing-view'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useInteractionScope, { import useInteractionScope, {
useEndpointReshape, useEndpointReshape,
useMovingNode, useMovingNode,
@@ -74,6 +88,14 @@ import {
startFloorplanGroupRotate, startFloorplanGroupRotate,
} from '../floorplan-group-move' } from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context' 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 { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle' import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
@@ -276,6 +298,8 @@ type NodeDeps = {
node: AnyNode node: AnyNode
live: LiveTransform | undefined live: LiveTransform | undefined
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
selected: boolean selected: boolean
highlighted: boolean highlighted: boolean
hovered: boolean hovered: boolean
@@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId) const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit) 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 selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId) 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 // selectors freeze to `undefined` so drag publishes do not re-render the
// hidden floor-plan tree. // hidden floor-plan tree.
const floorplanVisible = useEditor((s) => s.viewMode !== '3d') 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 // Elevator builders read runtime state imperatively, so entries include this
// rare-changing ref in their cache deps. // rare-changing ref in their cache deps.
const interactiveElevators = useInteractive((s) => s.elevators) const interactiveElevators = useInteractive((s) => s.elevators)
@@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
if (!isNodeKindEnabled(node.type, installedPlugins)) return 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 (!def?.floorplan) return
if (node.type === 'measurement' && !showMeasurements) return
const dependsOnSiblingInputs = !!( const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings || def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides || def.floorplanSiblingOverrides ||
def.floorplanAffectedIds def.floorplanAffectedIds
) )
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
out.push(descriptor) out.push(descriptor)
} }
@@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
visit(levelId as AnyNodeId) 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 // Building-scoped kinds (`def.floorplanScope === 'building'`) live
// as siblings of the level, not under it — the `visit(levelId)` DFS // as siblings of the level, not under it — the `visit(levelId)` DFS
// above doesn't reach them. Walk every node of those kinds whose // 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 // builders that gate on the current floor — e.g. elevator service
// range — keep working). Pure registry-driven dispatch: no kind // range — keep working). Pure registry-driven dispatch: no kind
// name appears in this file. // name appears in this file.
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
const activeBuildingId = activeLevelNode const activeBuildingId = activeLevelNode
? resolveBuildingForLevel(levelId as AnyNodeId, nodes) ? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
: null : null
@@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
} }
return { entries: out, levelNodeIdsByType } return { entries: out, levelNodeIdsByType }
}, [installedPlugins, levelId, nodes, showMeasurements]) }, [drawingType, installedPlugins, levelId, nodes])
// ── Generic 2D affordance dispatch ───────────────────────────────── // ── Generic 2D affordance dispatch ─────────────────────────────────
// //
@@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry <FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)} activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null} activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible} floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef} geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId} hatchPatternId={renderCtx?.hatchPatternId}
@@ -1323,6 +1367,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin} setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit} unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides} ctxOverrides={entry.ctxOverrides}
@@ -1341,6 +1387,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry <FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)} activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null} activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible} floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef} geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId} hatchPatternId={renderCtx?.hatchPatternId}
@@ -1374,12 +1421,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin} setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit} unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides} ctxOverrides={entry.ctxOverrides}
/> />
))} ))}
</g> </g>
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
{/* Dashed group bbox — shows what a group drag carries along while a {/* Dashed group bbox — shows what a group drag carries along while a
multi-selection exists, rides the live delta mid-drag, and doubles multi-selection exists, rides the live delta mid-drag, and doubles
as the group's whole-area drag handle. */} 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<SVGGElement>(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<SVGGElement>('[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 <g pointerEvents="none" ref={markerRef} />
}
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 = { type FloorplanRegistryEntryProps = {
activeDragId: string | null activeDragId: string | null
activeRotateNodeId: AnyNodeId | null activeRotateNodeId: AnyNodeId | null
annotationVisibility: FloorplanAnnotationVisibility
ctxOverrides: FloorplanContextOverrides | undefined ctxOverrides: FloorplanContextOverrides | undefined
floorplanVisible: boolean floorplanVisible: boolean
geometryCacheRef: { current: Map<string, CacheEntry> } geometryCacheRef: { current: Map<string, CacheEntry> }
@@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = {
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin'] setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
siblingEpoch: number siblingEpoch: number
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
unitsPerPixel: number unitsPerPixel: number
visibilityRootId: AnyNodeId | undefined visibilityRootId: AnyNodeId | undefined
} }
@@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = {
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
activeDragId, activeDragId,
activeRotateNodeId, activeRotateNodeId,
annotationVisibility,
ctxOverrides, ctxOverrides,
floorplanVisible, floorplanVisible,
geometryCacheRef, geometryCacheRef,
@@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
setMovingNodeOrigin, setMovingNodeOrigin,
siblingEpoch, siblingEpoch,
unit, unit,
metricNotation,
wallDimensionReference,
unitsPerPixel, unitsPerPixel,
visibilityRootId, visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null { }: FloorplanRegistryEntryProps): React.ReactElement | null {
@@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
selected, selected,
siblingEpoch, siblingEpoch,
unit, unit,
metricNotation,
wallDimensionReference,
visibilityRootId, visibilityRootId,
}) })
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null 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 / // Multi-selection shows highlight only: strip this member's edit handles /
// dimension chrome (all of which live in the overlay pass) while keeping // dimension chrome (all of which live in the overlay pass) while keeping
// its highlighted body geometry. // its highlighted body geometry.
const geometry = const geometry =
rawGeometry && suppressHandles && pass === 'overlay' visibleGeometry && suppressHandles && pass === 'overlay'
? stripHandleChrome(rawGeometry) ? stripHandleChrome(visibleGeometry)
: rawGeometry : visibleGeometry
if (!geometry) return null if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = {
selected: boolean selected: boolean
siblingEpoch: number siblingEpoch: number
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
visibilityRootId: AnyNodeId | undefined visibilityRootId: AnyNodeId | undefined
} }
@@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({
selected, selected,
siblingEpoch, siblingEpoch,
unit, unit,
metricNotation,
wallDimensionReference,
visibilityRootId, visibilityRootId,
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null { }: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
@@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({
node, node,
live, live,
unit, unit,
metricNotation,
wallDimensionReference,
selected, selected,
highlighted, highlighted,
hovered, hovered,
@@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({
const viewState = { const viewState = {
selected, selected,
unit, unit,
metricNotation,
wallDimensionReference,
highlighted, highlighted,
hovered, hovered,
moving, moving,
@@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({
siblings: ctxOverrides.siblings, siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent, parent: ctxOverrides.parent,
levelData, levelData,
extensions: createFloorplanContextExtensions({
metricNotation,
purpose: 'edit',
wallDimensionReference,
}),
viewState: palette viewState: palette
? { ? {
selected, selected,
@@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = {
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
} }
const InteractiveGeometry = memo(function InteractiveGeometry({ export const InteractiveGeometry = memo(function InteractiveGeometry({
geometry, geometry,
unitsPerPixel, unitsPerPixel,
palette, palette,
@@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
case 'group': { case 'group': {
const transform = formatGroupTransform(g.transform) const transform = formatGroupTransform(g.transform)
return ( return (
<g key={keyHint} transform={transform}> <g
data-floorplan-annotation-obstacle={
floorplanAnnotationObstacleMode(g) ??
(isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
}
key={keyHint}
transform={transform}
>
{g.children.map((child, i) => renderInteractive(child, i))} {g.children.map((child, i) => renderInteractive(child, i))}
</g> </g>
) )
@@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
const textWidth = g.text.length * labelUnitsPerPixel * 6.2 const textWidth = g.text.length * labelUnitsPerPixel * 6.2
const plateW = textWidth + padX * 2 const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2 const plateH = fontSize + padY * 2
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
return ( return (
<g <g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint} key={keyHint}
pointerEvents="none" pointerEvents="none"
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`} transform={labelTransform}
> >
{outlined ? null : ( {outlined ? null : (
<rect <rect
@@ -2598,147 +2809,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
} }
case 'dimension': { case 'dimension': {
if (!palette) return <></> if (!palette) return <></>
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
// `<g>` 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 ( return (
<g key={keyHint} pointerEvents="none"> <FloorplanDimensionRenderer
{/* Extension lines (dashed). */} geometry={g}
<line key={keyHint}
stroke={stroke} sceneRotationDeg={sceneRotationDeg}
strokeDasharray="0.08 0.12" stroke={g.stroke ?? palette.measurementStroke}
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.start[0]}
x2={eStartTip[0]}
y1={g.start[1]}
y2={eStartTip[1]}
/> />
<line
stroke={stroke}
strokeDasharray="0.08 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.end[0]}
x2={eEndTip[0]}
y1={g.end[1]}
y2={eEndTip[1]}
/>
{/* Dimension line: two halves with the label in between. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0]}
x2={gapStart[0]}
y1={dStart[1]}
y2={gapStart[1]}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={gapEnd[0]}
x2={dEnd[0]}
y1={gapEnd[1]}
y2={dEnd[1]}
/>
{/* End ticks. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0] - perpX}
x2={dStart[0] + perpX}
y1={dStart[1] - perpY}
y2={dStart[1] + perpY}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dEnd[0] - perpX}
x2={dEnd[0] + perpX}
y1={dEnd[1] - perpY}
y2={dEnd[1] + perpY}
/>
{/* Rotated label centered in the gap. */}
<text
dominantBaseline="central"
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={600}
textAnchor="middle"
transform={`rotate(${labelDeg} ${midX} ${midY})`}
x={midX}
y={midY}
>
{g.text}
</text>
</g>
) )
} }
case 'text': { case 'text': {
@@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
// horizontally on screen even when the floor-plan view is // horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°). // rotated (default `sceneRotationDeg` is 90°).
return ( return (
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}> <g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text <text
dominantBaseline={g.dominantBaseline ?? 'middle'} dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'} fill={g.fill ?? '#171717'}
@@ -2775,6 +2856,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
geometry={g} geometry={g}
key={keyHint} key={keyHint}
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined} pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
sceneRotationDeg={sceneRotationDeg}
/> />
) )
} }
@@ -2853,6 +2935,9 @@ export function buildContext(
viewState: { viewState: {
selected: boolean selected: boolean
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
metricNotation?: 'meters' | 'millimeters'
purpose?: 'edit' | 'document'
wallDimensionReference?: FloorplanWallDimensionReference
highlighted: boolean highlighted: boolean
hovered: boolean hovered: boolean
moving: boolean moving: boolean
@@ -2892,6 +2977,11 @@ export function buildContext(
siblings, siblings,
parent, parent,
levelData, levelData,
extensions: createFloorplanContextExtensions({
metricNotation: viewState.metricNotation ?? 'meters',
purpose: viewState.purpose ?? 'edit',
wallDimensionReference: viewState.wallDimensionReference,
}),
viewState: viewState.palette viewState: viewState.palette
? { ? {
selected: viewState.selected, selected: viewState.selected,
@@ -2905,6 +2995,29 @@ export function buildContext(
} }
} }
export function collectFloorplanLinkedLevelNodes(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
excludedIds: ReadonlySet<AnyNodeId> = 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 * Stable id for a handle on a node, derived from the node id + opaque
* payload. Used to track hover / active visual state when multiple * payload. Used to track hover / active visual state when multiple
@@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'move-arrow', 'move-arrow',
'rotate-arrow', 'rotate-arrow',
'dimension', 'dimension',
'dimension-string',
'dimension-label', 'dimension-label',
'equal-spacing-badge', 'equal-spacing-badge',
]) ])
@@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
base: FloorplanGeometry | null base: FloorplanGeometry | null
overlay: FloorplanGeometry | null overlay: FloorplanGeometry | null
} { } {
if (isFloorplanAnnotationObstacleGeometry(g)) {
return { base: null, overlay: g }
}
if (OVERLAY_KINDS.has(g.kind)) { if (OVERLAY_KINDS.has(g.kind)) {
return { base: null, overlay: g } return { base: null, overlay: g }
} }
@@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'node', 'node',
'live', 'live',
'unit', 'unit',
'metricNotation',
'wallDimensionReference',
'selected', 'selected',
'highlighted', 'highlighted',
'hovered', 'hovered',
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
return Math.max(minimum, Math.round(stair.stepCount ?? 10)) 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) { function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') { if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
return 0 return 0
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
const dashedFromIndex = Math.floor(stepCount * 0.68) if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return ( return (
<line <line
key={`${stair.id}:spiral-step:${index}`} key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none" pointerEvents="none"
stroke={index === stepCount ? curvedAccent : curvedStroke} stroke={index === stepCount ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount ? '1.8' : '1.15'} strokeWidth={index === stepCount ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)} x1={toSvgX(innerPoint.x)}
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle) const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle) const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return ( return (
<line <line
@@ -397,7 +405,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
strokeWidth={isSelectionActive ? '2' : '1.35'} strokeWidth={isSelectionActive ? '2' : '1.35'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{treadBars.map((treadBar, treadIndex) => ( {treadBars
.slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
.map((treadBar, treadIndex) => (
<polygon <polygon
fill={straightTread} fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`} key={`${segment.id}:tread:${treadIndex}`}
@@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu' import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer' 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 { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import { import {
@@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
visible: true, visible: true,
metadata: {}, metadata: {},
children: [], children: [],
assemblyLayers: [],
start, start,
end, end,
frontSide: 'unknown', frontSide: 'unknown',
@@ -2719,9 +2721,10 @@ function formatMeasurement(
value: number, value: number,
unit: 'metric' | 'imperial', unit: 'metric' | 'imperial',
metersPerUnit: number | null = null, metersPerUnit: number | null = null,
metricNotation: 'meters' | 'millimeters' = 'meters',
) { ) {
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
return formatLinearMeasurement(measuredValue, unit) return formatLinearMeasurement(measuredValue, unit, metricNotation)
} }
function formatNumber(value: number, fractionDigits = 2) { function formatNumber(value: number, fractionDigits = 2) {
@@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({
unitsPerPixel: number unitsPerPixel: number
}) { }) {
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint) const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
const metricNotation = useViewer((state) => state.metricNotation)
if (!cursor) { if (!cursor) {
return null return null
} }
@@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({
label={`Ref ${formatMeasurement( label={`Ref ${formatMeasurement(
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]), Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
unit, unit,
null,
metricNotation,
)}`} )}`}
palette={palette} palette={palette}
start={start} start={start}
@@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
unitsPerPixel: number unitsPerPixel: number
}) { }) {
const metricNotation = useViewer((state) => state.metricNotation)
if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) { if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
return null return null
} }
@@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
labelAngleDeg += 180 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 width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
const height = fontSize + padY * 2 const height = fontSize + padY * 2
@@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({
unitsPerPixel: number unitsPerPixel: number
sceneRotationDeg: number sceneRotationDeg: number
}) { }) {
const metricNotation = useViewer((state) => state.metricNotation)
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
@@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({
} }
return { return {
lengthLabel: formatMeasurement(length, unit), lengthLabel: formatMeasurement(length, unit, null, metricNotation),
midpoint: [ midpoint: [
(wallDraftStart[0] + wallDraftEnd[0]) / 2, (wallDraftStart[0] + wallDraftEnd[0]) / 2,
(wallDraftStart[1] + wallDraftEnd[1]) / 2, (wallDraftStart[1] + wallDraftEnd[1]) / 2,
@@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({
direction: [dx / length, dy / length] as WallPlanPoint, direction: [dx / length, dy / length] as WallPlanPoint,
angleLabels, angleLabels,
} }
}, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls]) }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
return ( return (
<> <>
@@ -5244,6 +5252,7 @@ export function FloorplanPanel({
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const showGuides = useViewer((state) => state.showGuides) const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides) const setShowGuides = useViewer((state) => state.setShowGuides)
@@ -11169,7 +11178,12 @@ export function FloorplanPanel({
Drawn line Drawn line
</div> </div>
<div className="mt-1 font-medium text-sm"> <div className="mt-1 font-medium text-sm">
{formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} {formatMeasurement(
pendingReferenceScale.measuredLengthUnits,
unit,
null,
metricNotation,
)}
</div> </div>
</div> </div>
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({
<FloorplanWallMoveGhostLayer /> <FloorplanWallMoveGhostLayer />
</g> </g>
<FloorplanMeasurementToolLayer /> <FloorplanMeasurementToolLayer />
<FloorplanRegisteredToolLayer />
{floorplanSceneSlot} {floorplanSceneSlot}
</FloorplanRenderProvider> </FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the {/* Cursor-driven placement ghost for movingNode when the
@@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react'
// Canonical in-world dimension formatter — metric metres or imperial // Canonical in-world dimension formatter — metric metres or imperial
// feet/inches. Shared by every measurement readout so they read the same. // 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') { if (unit === 'imperial') {
const feet = value * 3.280_84 const feet = value * 3.280_84
const wholeFeet = Math.floor(feet) 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"` if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"` return `${wholeFeet}'${inches}"`
} }
if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
return `${Number.parseFloat(value.toFixed(2))}m` return `${Number.parseFloat(value.toFixed(2))}m`
} }
@@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react'
import { memo } from 'react' import { memo } from 'react'
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements' 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 === 'area') return formatAreaLabel(metric.value, unit, 2)
if (metric.quantity === 'volume') return formatVolumeLabel(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({ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
report, report,
unit, unit,
metricNotation,
lensState, lensState,
}: { }: {
report: QuickMeasurementReport report: QuickMeasurementReport
unit: 'metric' | 'imperial' unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
lensState: 'live' | 'pinned' lensState: 'live' | 'pinned'
}) { }) {
const pinned = lensState === 'pinned' const pinned = lensState === 'pinned'
@@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
<span className="truncate">{metric.label}</span> <span className="truncate">{metric.label}</span>
</div> </div>
<div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums"> <div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
{formatMetric(metric, unit)} {formatMetric(metric, unit, metricNotation)}
</div> </div>
</div> </div>
))} ))}
@@ -12,6 +12,7 @@ export function QuickMeasurementHud() {
const viewMode = useEditor((state) => state.viewMode) const viewMode = useEditor((state) => state.viewMode)
const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode)) const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
if (!entry) return null if (!entry) return null
@@ -21,7 +22,12 @@ export function QuickMeasurementHud() {
data-quick-measure-hud data-quick-measure-hud
> >
<div className="w-full max-w-[34rem]"> <div className="w-full max-w-[34rem]">
<QuickMeasurementCard lensState={entry.lensState} report={entry.report} unit={unit} /> <QuickMeasurementCard
lensState={entry.lensState}
metricNotation={metricNotation}
report={entry.report}
unit={unit}
/>
</div> </div>
</div> </div>
) )
@@ -1,12 +1,17 @@
'use client' 'use client'
import { useViewer } from '@pascal-app/viewer' import type {
ConstructionDimensionChainMode,
ConstructionDimensionMode,
} from '@pascal-app/core'
import { import {
Box, Box,
Check, Check,
ChevronDown, ChevronDown,
Eye, CircleIcon,
EyeOff, Crosshair,
Grid2X2,
Minus,
Ruler, Ruler,
ScanSearch, ScanSearch,
Square, Square,
@@ -37,27 +42,64 @@ const measurementMenuOptions = [
...measurementOptions, ...measurementOptions,
] as const ] 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() { export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const selectedKind = useEditor((state) => state.lastMeasurementKind) const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind) 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 setMode = useEditor((state) => state.setMode)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind) const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setTool = useEditor((state) => state.setTool) const setTool = useEditor((state) => state.setTool)
const setToolDefaults = useEditor((state) => state.setToolDefaults) const setToolDefaults = useEditor((state) => state.setToolDefaults)
const showMeasurements = useViewer((state) => state.showMeasurements) const setViewMode = useEditor((state) => state.setViewMode)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const selectedOption = const selectedOption =
measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0] measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
const isActive = mode === 'build' && tool === 'measurement' 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 isSmartActive = isActive && activeToolKind === 'smart'
const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon const SelectedIcon = isConstructionDimensionActive
const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label ? (activeConstructionDimensionOption?.icon ?? Ruler)
: isSmartActive
? ScanSearch
: selectedOption.icon
const selectedLabel = isConstructionDimensionActive
? (activeConstructionDimensionOption?.label ?? 'Linear dimension')
: isSmartActive
? 'Smart'
: selectedOption.label
const activateMeasurement = (kind: CreatableMeasurementKind) => { const activateMeasurement = (kind: CreatableMeasurementKind) => {
setPhase('structure') setPhase('structure')
@@ -69,7 +111,7 @@ export function MeasurementControl() {
} }
const handlePrimaryClick = () => { const handlePrimaryClick = () => {
if (isActive) { if (isControlActive) {
setMode('select') setMode('select')
return return
} }
@@ -84,15 +126,27 @@ export function MeasurementControl() {
setTool('measurement') 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 ( return (
<Popover onOpenChange={setIsOpen} open={isOpen}> <Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center"> <div className="flex items-center">
<ActionButton <ActionButton
aria-label={`Measure: ${selectedLabel}`} aria-label={`Measure: ${selectedLabel}`}
aria-pressed={isActive} aria-pressed={isControlActive}
className={cn( className={cn(
'rounded-r-none p-0 text-muted-foreground', 'rounded-r-none p-0 text-muted-foreground',
isActive isControlActive
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20' ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
: 'hover:bg-cyan-500/15 hover:text-cyan-400', : 'hover:bg-cyan-500/15 hover:text-cyan-400',
)} )}
@@ -128,7 +182,7 @@ export function MeasurementControl() {
<PopoverContent <PopoverContent
align="center" align="center"
className="w-56 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl" className="max-h-[70vh] w-64 overflow-y-auto rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side="top" side="top"
sideOffset={14} sideOffset={14}
> >
@@ -138,7 +192,7 @@ export function MeasurementControl() {
const isSmart = option.kind === 'smart' const isSmart = option.kind === 'smart'
const isSelected = isSmart const isSelected = isSmart
? isSmartActive ? isSmartActive
: !isSmartActive && option.kind === selectedKind : !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind
return ( return (
<button <button
aria-checked={isSelected} aria-checked={isSelected}
@@ -165,22 +219,37 @@ export function MeasurementControl() {
})} })}
<div className="my-1.5 h-px bg-border/60" /> <div className="my-1.5 h-px bg-border/60" />
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Floor plan
</div>
{constructionDimensionOptions.map((option) => {
const OptionIcon = option.icon
const isSelected =
isConstructionDimensionActive && activeConstructionDimensionOption === option
return (
<button <button
aria-checked={showMeasurements} aria-checked={isSelected}
className="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-muted-foreground text-sm transition-colors hover:bg-white/8 hover:text-foreground" className={cn(
onClick={() => setShowMeasurements(!showMeasurements)} 'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
role="menuitemcheckbox" isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={`${option.mode}-${option.chainMode}`}
onClick={() => {
activateConstructionDimension(option.mode, option.chainMode)
setIsOpen(false)
}}
role="menuitemradio"
type="button" type="button"
> >
{showMeasurements ? ( <OptionIcon aria-hidden="true" className="h-4 w-4" />
<Eye aria-hidden="true" className="h-4 w-4" /> <span>{option.label}</span>
) : ( {isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
<EyeOff aria-hidden="true" className="h-4 w-4" />
)}
<span>Show measurements</span>
<span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
</button> </button>
)
})}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -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 { useViewer } from '@pascal-app/viewer'
import { TreeView, VisualJson } from '@visual-json/react' import { TreeView, VisualJson } from '@visual-json/react'
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
@@ -394,14 +399,14 @@ export function SettingsPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">Floorplan</div> <div className="font-medium text-muted-foreground text-xs">Floor plan</div>
<Button <Button
className="w-full justify-start gap-2" className="w-full justify-start gap-2"
onClick={() => exportFloorplanPdf('full')} onClick={() => exportFloorplanPdf('full')}
variant="outline" variant="outline"
> >
<MapIcon className="size-4" /> <MapIcon className="size-4" />
Full floorplan Full floor plan
</Button> </Button>
<Button <Button
className="w-full justify-start gap-2" className="w-full justify-start gap-2"
+35
View File
@@ -81,6 +81,9 @@ export {
type SnapshotCameraData, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
} from './components/editor/thumbnail-generator' } from './components/editor/thumbnail-generator'
export { useFloorplanRender } from './components/editor-2d/floorplan-render-context'
export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer'
export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer'
export { export {
FloorplanNodePreview, FloorplanNodePreview,
type FloorplanNodePreviewProps, type FloorplanNodePreviewProps,
@@ -332,6 +335,28 @@ export {
type FloorplanStairSegmentEntry, type FloorplanStairSegmentEntry,
getFloorplanWallThickness, getFloorplanWallThickness,
} from './lib/floorplan' } from './lib/floorplan'
export type {
FloorplanAnnotationCategory,
FloorplanAnnotationVisibility,
} from './lib/floorplan/annotation-visibility'
export {
createFloorplanContextExtensions,
FLOORPLAN_CONTEXT_EXTENSION_KEY,
FLOORPLAN_GEOMETRY_METADATA_KEY,
FLOORPLAN_NODE_EXTENSION_KEY,
type FloorplanAnnotationRole,
type FloorplanMetricNotation,
type FloorplanNodeExtension,
type FloorplanRenderPurpose,
type FloorplanSchedule,
type FloorplanToolContext,
floorplanGeometryMetadata,
getFloorplanNodeExtension,
readFloorplanContext,
readFloorplanGeometryMetadata,
readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata,
} from './lib/floorplan/floorplan-extension'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export' export { exportSceneToGlb } from './lib/glb-export'
export { export {
@@ -462,6 +487,10 @@ export {
export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAlignmentGuides } from './store/use-alignment-guides'
export { default as useAudio } from './store/use-audio' export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export {
DRAWING_TYPE_OPTIONS,
default as useDrawingView,
} from './store/use-drawing-view'
export type { export type {
CaptureMode, CaptureMode,
FloorplanSelectionTool, FloorplanSelectionTool,
@@ -485,7 +514,13 @@ export {
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud' export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export {
default as useFloorplanPreflight,
type FloorplanPreflightIssue,
type FloorplanPreflightIssueKind,
} from './store/use-floorplan-preflight'
export { export {
default as useInteractionScope, default as useInteractionScope,
getEditingHole, getEditingHole,
@@ -0,0 +1,249 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import {
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
filterFloorplanAnnotationGeometry,
normalizeFloorplanAnnotationVisibility,
} from './annotation-visibility'
import { floorplanGeometryMetadata } from './floorplan-extension'
describe('floor-plan annotation visibility', () => {
test('fills missing persisted categories with visible defaults', () => {
expect(normalizeFloorplanAnnotationVisibility({ measurements: false })).toEqual({
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
measurements: false,
})
})
test('removes automatic dimension primitives without removing plan geometry', () => {
const line = { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0 } satisfies FloorplanGeometry
const geometry = {
kind: 'group',
children: [
line,
{
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [2, 0], text: '2.00m' }],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.1,
},
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
}),
).toEqual({ kind: 'group', children: [line] })
})
test('removes a complete curved automatic dimension group', () => {
const curvedDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 2 },
{ kind: 'dimension-label', cx: 1, cy: 1, text: 'R 2m', angle: 0 },
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(curvedDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
}),
).toBeNull()
})
test('removes only the opening mark from door geometry', () => {
const body = {
kind: 'polygon',
points: [
[0, 0],
[1, 0],
[1, 0.1],
],
} satisfies FloorplanGeometry
const mark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{ kind: 'line', x1: 0.5, y1: 0, x2: 0.5, y2: 0.5 },
{ kind: 'rect', x: 0.3, y: 0.5, width: 0.4, height: 0.3 },
{ kind: 'text', x: 0.5, y: 0.65, text: '101', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
const geometry = { kind: 'group', children: [body, mark] } satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
openingMarks: false,
}),
).toEqual({ kind: 'group', children: [body] })
})
test('hides manual dimensions and measurements independently', () => {
const manualDimension = {
kind: 'text',
x: 0,
y: 0,
text: 'Annotation',
fontSize: 0.15,
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
} satisfies FloorplanGeometry
const measurement = {
...manualDimension,
metadata: floorplanGeometryMetadata({ annotationRole: 'measurement' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(manualDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
manualDimensions: false,
}),
).toBeNull()
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [1, 0],
offsetNormal: [0, 1],
offsetDistance: 0.5,
extensionOvershoot: 0.1,
text: '1m',
},
],
},
{
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
},
),
).not.toBeNull()
expect(
filterFloorplanAnnotationGeometry(measurement, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
measurements: false,
}),
).toBeNull()
})
test('hides structural grids and only the center marks within column geometry', () => {
const centerMark = {
kind: 'line',
x1: -0.1,
y1: -0.1,
x2: 0.1,
y2: 0.1,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
const gridReference = {
kind: 'text',
x: 0,
y: 0.3,
text: 'B-2',
fontSize: 0.13,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
const footprint = {
kind: 'rect',
x: -0.2,
y: -0.2,
width: 0.4,
height: 0.4,
} satisfies FloorplanGeometry
const visibility = {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
structuralGrids: false,
}
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
children: [footprint, centerMark, gridReference],
},
visibility,
),
).toEqual({ kind: 'group', children: [footprint] })
expect(
filterFloorplanAnnotationGeometry(
{
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'structural-grid' }),
children: [footprint],
},
visibility,
),
).toBeNull()
})
test('hides room labels without removing the room footprint', () => {
const footprint = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 3],
],
} satisfies FloorplanGeometry
const roomName = {
kind: 'text',
x: 2,
y: 1.5,
text: 'Office',
fontSize: 0.2,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(
{ kind: 'group', children: [footprint, roomName] },
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, roomLabels: false },
),
).toEqual({ kind: 'group', children: [footprint] })
})
test('hides stair notes and break lines without removing stair geometry', () => {
const footprint = {
kind: 'polygon',
points: [
[0, 0],
[1, 0],
[1, 3],
[0, 3],
],
} satisfies FloorplanGeometry
const direction = {
kind: 'text',
x: 0.5,
y: 0.5,
text: 'UP',
fontSize: 0.16,
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
const breakLine = {
kind: 'polyline',
points: [
[0, 2],
[1, 2],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(
{ kind: 'group', children: [footprint, direction, breakLine] },
{ ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, stairAnnotations: false },
),
).toEqual({ kind: 'group', children: [footprint] })
})
})
@@ -0,0 +1,110 @@
import type { FloorplanGeometry } from '@pascal-app/core'
import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './floorplan-extension'
export type FloorplanAnnotationCategory =
| 'automaticDimensions'
| 'manualDimensions'
| 'measurements'
| 'openingMarks'
| 'structuralGrids'
| 'roomLabels'
| 'stairAnnotations'
export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory, boolean>
export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
automaticDimensions: true,
manualDimensions: true,
measurements: true,
openingMarks: true,
structuralGrids: true,
roomLabels: true,
stairAnnotations: true,
}
export function normalizeFloorplanAnnotationVisibility(
value: unknown,
): FloorplanAnnotationVisibility {
if (!value || typeof value !== 'object') return { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY }
const persisted = value as Partial<Record<FloorplanAnnotationCategory, unknown>>
return {
automaticDimensions:
typeof persisted.automaticDimensions === 'boolean'
? persisted.automaticDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
manualDimensions:
typeof persisted.manualDimensions === 'boolean'
? persisted.manualDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.manualDimensions,
measurements:
typeof persisted.measurements === 'boolean'
? persisted.measurements
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.measurements,
openingMarks:
typeof persisted.openingMarks === 'boolean'
? persisted.openingMarks
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.openingMarks,
structuralGrids:
typeof persisted.structuralGrids === 'boolean'
? persisted.structuralGrids
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.structuralGrids,
roomLabels:
typeof persisted.roomLabels === 'boolean'
? persisted.roomLabels
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.roomLabels,
stairAnnotations:
typeof persisted.stairAnnotations === 'boolean'
? persisted.stairAnnotations
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.stairAnnotations,
}
}
export function filterFloorplanAnnotationGeometry(
geometry: FloorplanGeometry,
visibility: FloorplanAnnotationVisibility,
inheritedRole?: FloorplanAnnotationRole,
): FloorplanGeometry | null {
const role = readFloorplanGeometryMetadata(geometry).annotationRole ?? inheritedRole
if (role && !isAnnotationRoleVisible(role, visibility)) return null
if (
!visibility.automaticDimensions &&
role !== 'manual-dimension' &&
(geometry.kind === 'dimension' ||
geometry.kind === 'dimension-string' ||
geometry.kind === 'dimension-label' ||
geometry.kind === 'equal-spacing-badge')
) {
return null
}
if (geometry.kind !== 'group') return geometry
const children = geometry.children
.map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
.filter((child): child is FloorplanGeometry => child !== null)
if (children.length === 0) return null
if (children.length === geometry.children.length) return geometry
return { ...geometry, children }
}
function isAnnotationRoleVisible(
role: FloorplanAnnotationRole,
visibility: FloorplanAnnotationVisibility,
): boolean {
switch (role) {
case 'automatic-dimension':
return visibility.automaticDimensions
case 'manual-dimension':
return visibility.manualDimensions
case 'measurement':
return visibility.measurements
case 'opening-mark':
return visibility.openingMarks
case 'structural-grid':
case 'column-center':
return visibility.structuralGrids
case 'room-label':
return visibility.roomLabels
case 'stair-annotation':
return visibility.stairAnnotations
}
}
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type ConstructionDrawingType,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { z } from 'zod'
import { resolveNodeForDrawingType } from './drawing-coordination'
import { FLOORPLAN_NODE_EXTENSION_KEY } from './floorplan-extension'
describe('resolveNodeForDrawingType', () => {
afterEach(() => nodeRegistry._reset())
test('dispatches drawing coordination through the registered extension', () => {
const node = {
id: 'drawing-test_main',
type: 'drawing-test',
object: 'node',
parentId: null,
visible: true,
metadata: {},
} as unknown as AnyNode
registerNode({
kind: 'drawing-test',
schemaVersion: 1,
schema: z.object({ type: z.literal('drawing-test') }) as never,
category: 'utility',
defaults: () => ({}) as never,
extensions: {
[FLOORPLAN_NODE_EXTENSION_KEY]: {
resolveForDrawing: ({ drawingType }: { drawingType: ConstructionDrawingType }) =>
drawingType === 'floor-plan' ? null : node,
},
},
} as never)
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBeNull()
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'foundation-plan')).toBe(node)
})
test('leaves nodes without a drawing extension unchanged', () => {
const node = { id: 'unknown', type: 'unknown' } as unknown as AnyNode
expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBe(node)
})
})
@@ -0,0 +1,13 @@
import { type AnyNode, type ConstructionDrawingType, nodeRegistry } from '@pascal-app/core'
import { getFloorplanNodeExtension } from './floorplan-extension'
export function resolveNodeForDrawingType(
node: AnyNode,
nodes: Record<string, AnyNode>,
drawingType: ConstructionDrawingType,
): AnyNode | null {
const extension = getFloorplanNodeExtension(nodeRegistry.get(node.type))
return extension?.resolveForDrawing
? extension.resolveForDrawing({ node, nodes, drawingType })
: node
}
@@ -0,0 +1,591 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import {
DrawingSheetNode,
type FloorplanGeometry,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
import {
filterFloorplanExportOverlay,
fitPlanToBox,
isFloorplanExportAnnotationGeometry,
partitionFloorplanExportOverlay,
pointsPerMeterForDrawingScale,
resolveDrawingSheetDocumentMarkers,
resolveDrawingSheetGeneralNotes,
resolveDrawingSheetKeyedNotes,
resolveFloorplanExportAnnotationVisibility,
resolveFloorplanExportNodeGeometry,
resolveFloorplanExportPlacement,
resolveFloorplanExportRotationDeg,
resolveFloorplanExportViewport,
resolveFloorplanExportViewState,
resolveFloorplanMeasurementSize,
resolveFloorplanPageLayout,
resolveFloorplanScreenUnitsPerPixel,
resolveGraphicScaleLength,
resolveSheetComposition,
resolveSheetExportLayout,
resolveSheetPageSetup,
rotateFloorplanExportBounds,
} from './floorplan-export'
import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension'
const drawingSheetExtension: FloorplanNodeExtension<DrawingSheetNode> = {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
}
beforeAll(() => {
if (nodeRegistry.has('drawing-sheet')) return
registerNode({
kind: 'drawing-sheet',
schemaVersion: 1,
schema: DrawingSheetNode,
category: 'analysis',
defaults: () => ({}) as never,
capabilities: {},
extensions: {
'pascal:editor/floorplan': drawingSheetExtension,
},
})
})
describe('filterFloorplanExportOverlay', () => {
test('preserves value labels and removes editing handles', () => {
const label = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 0,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const overlay = {
kind: 'group',
children: [
label,
{
kind: 'endpoint-handle',
point: [0, 0],
state: 'idle',
affordance: 'move-measurement-vertex',
payload: { vertexIndex: 0 },
},
],
} satisfies FloorplanGeometry
expect(filterFloorplanExportOverlay(overlay)).toEqual({
kind: 'group',
children: [label],
})
})
test('preserves wall, door, and window shapes used as annotation obstacles', () => {
const fixedGeometry = {
kind: 'group',
children: [
{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
stroke: '#1f2937',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
},
{
kind: 'path',
d: 'M 1 0 A 1 1 0 0 1 2 1',
fill: 'none',
stroke: '#64748b',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
{
kind: 'line',
x1: 2.5,
y1: 0,
x2: 3.5,
y2: 0,
stroke: '#1f2937',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
},
{ kind: 'move-handle', point: [2, 0.1] },
],
} satisfies FloorplanGeometry
const { overlay } = splitFloorplanOverlay(fixedGeometry)
expect(overlay).not.toBeNull()
expect(filterFloorplanExportOverlay(overlay!)).toEqual({
kind: 'group',
children: fixedGeometry.children.slice(0, 3),
transform: undefined,
})
})
test('keeps structural obstacles in model bounds while leaving marks as annotations', () => {
const wall = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
} satisfies FloorplanGeometry
const openingMark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{
kind: 'rect',
x: 1,
y: 1,
width: 0.4,
height: 0.2,
fill: '#ffffff',
stroke: '#334155',
},
{ kind: 'text', x: 1.2, y: 1.1, text: 'W01', fontSize: 0.1, upright: true },
],
} satisfies FloorplanGeometry
expect(
partitionFloorplanExportOverlay({ kind: 'group', children: [wall, openingMark] }),
).toEqual({
model: { kind: 'group', children: [wall], transform: undefined },
annotations: { kind: 'group', children: [openingMark], transform: undefined },
})
})
test('moves automatic dimensions embedded in base wall geometry into the PDF annotation layer', () => {
const wall = {
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
[0, 0.2],
],
fill: '#374151',
} satisfies FloorplanGeometry
const dimensions = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [4, 0], text: '4m' }],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.12,
} satisfies FloorplanGeometry
expect(
resolveFloorplanExportNodeGeometry(
{ kind: 'group', children: [wall, dimensions] },
null,
false,
),
).toEqual({
model: { kind: 'group', children: [wall], transform: undefined },
annotations: { kind: 'group', children: [dimensions], transform: undefined },
})
})
})
describe('fitPlanToBox', () => {
test('preserves aspect ratio and centers the plan', () => {
expect(fitPlanToBox(20, 10, 10, 20, 400, 300)).toEqual({
x: 10,
y: 70,
width: 400,
height: 200,
})
})
})
describe('floor plan export policy', () => {
test('uses the live floor-plan formatting profile for metric and imperial dimensions', () => {
expect(resolveFloorplanExportViewState('metric', 'millimeters')).toMatchObject({
purpose: 'edit',
unit: 'metric',
metricNotation: 'millimeters',
})
expect(resolveFloorplanExportViewState('imperial', 'meters')).toMatchObject({
purpose: 'edit',
unit: 'imperial',
metricNotation: 'meters',
})
})
test('fits an oversized plan inside the complete export viewport', () => {
const placement = resolveFloorplanExportPlacement(30, 20, 10, 20, 400, 300)
expect(placement.x).toBe(10)
expect(placement.y).toBeCloseTo(36.67, 2)
expect(placement.width).toBe(400)
expect(placement.height).toBeCloseTo(266.67, 2)
expect(placement.x).toBeGreaterThanOrEqual(10)
expect(placement.y).toBeGreaterThanOrEqual(20)
expect(placement.x + placement.width).toBeLessThanOrEqual(410)
expect(placement.y + placement.height).toBeLessThanOrEqual(320)
})
test('exports the same annotation categories that are visible in the live view', () => {
const liveVisibility = {
automaticDimensions: true,
manualDimensions: false,
measurements: true,
openingMarks: true,
structuralGrids: false,
roomLabels: false,
stairAnnotations: true,
}
expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility)
})
test('matches live screen sizing to the fitted export viewport', () => {
expect(resolveFloorplanScreenUnitsPerPixel(7, 4.5, 572, 463)).toBeCloseTo(0.012_237_762, 8)
})
test('keeps the export viewport anchored to the structural drawing bounds', () => {
expect(resolveFloorplanExportViewport({ x: -5, y: -6, width: 13, height: 13.5 })).toEqual({
x: -7.7,
y: -8.7,
width: 18.4,
height: 18.9,
})
})
test('fits the viewport around the rotated plan instead of clipping its corners', () => {
const bounds = rotateFloorplanExportBounds({ x: 0, y: 0, width: 10, height: 5 }, 90)
expect(bounds.x).toBeCloseTo(-5, 8)
expect(bounds.y).toBeCloseTo(0, 8)
expect(bounds.width).toBeCloseTo(5, 8)
expect(bounds.height).toBeCloseTo(10, 8)
})
test('keeps annotation-only nodes out of primary model bounds', () => {
expect(
isFloorplanExportAnnotationGeometry({
kind: 'group',
children: [],
metadata: { 'pascal:editor/floorplan': { annotationRole: 'measurement' } },
}),
).toBe(true)
expect(
isFloorplanExportAnnotationGeometry({
kind: 'group',
children: [],
metadata: { 'pascal:editor/floorplan': { annotationRole: 'manual-dimension' } },
}),
).toBe(true)
expect(isFloorplanExportAnnotationGeometry({ kind: 'polygon', points: [] })).toBe(false)
})
test('matches the current floor-plan rotation instead of forcing north-up', () => {
expect(resolveFloorplanExportRotationDeg(Math.PI / 6, Math.PI / 2)).toBeCloseTo(60, 8)
})
})
describe('pointsPerMeterForDrawingScale', () => {
test('converts metric ratios to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4)
})
test('converts imperial architectural scales to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4)
})
})
describe('resolveFloorplanMeasurementSize', () => {
test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
expect(
resolveFloorplanMeasurementSize({ x: -2, y: -3, width: 18.4, height: 18.9 }, 0.024),
).toEqual({ width: 18.4 / 0.024, height: 18.9 / 0.024 })
})
})
describe('resolveSheetExportLayout', () => {
test('reserves a plan viewport, side panel, and title block on one sheet page', () => {
expect(resolveSheetExportLayout(842, 595)).toEqual({
planBox: { x: 36, y: 36, width: 572, height: 463 },
sidePanel: { x: 626, y: 36, width: 180, height: 463 },
titleBlock: { x: 36, y: 517, width: 770, height: 42 },
})
})
})
describe('resolveFloorplanPageLayout', () => {
test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => {
expect(resolveFloorplanPageLayout(842, 595)).toEqual({
planBox: { x: 36, y: 64, width: 770, height: 495 },
})
})
})
describe('resolveGraphicScaleLength', () => {
test('chooses a model length that fits the available paper width', () => {
const scale = resolveGraphicScaleLength('1:50', 150)
expect(scale.modelMeters).toBe(2)
expect(scale.widthPt).toBeCloseTo(113.39, 2)
expect(scale.label).toBe('2 m')
})
})
describe('resolveSheetComposition', () => {
test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
placedViews: [
{
id: 'drawing-view_main',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
scale: '1:50',
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
})
expect(
resolveSheetComposition(
{ [sheet.id]: sheet },
'level_main',
'Main Level',
'floor-plan',
'Floor plan',
'1/4"=1\'-0"',
),
).toMatchObject({
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
paperSize: 'arch-b',
orientation: 'landscape',
drawingNumber: '2',
viewTitle: 'Main Floor Plan',
scale: '1:50',
generalNotes: [{ number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
keyedNoteInstances: [],
})
})
test('resolves reusable general note sets before sheet-local notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([
{ number: 1, text: 'Coordinate with structural.' },
{ number: 2, text: 'Verify dimensions.' },
])
})
test('reports duplicate reusable and sheet-local general notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([
{
severity: 'warning',
message:
'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.',
},
])
})
test('derives keyed-note legends from repeated stable instances', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' },
{ id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-1',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'keyed-note-instance_patch-2',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [4, 3],
},
],
keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }],
})
expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({
legend: [{ key: 'A', text: 'Patch existing slab.' }],
instances: [
{ id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 },
{ id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 },
],
warnings: [],
})
})
test('reports keyed-note instances with missing definitions', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
keyedNoteInstances: [
{
id: 'keyed-note-instance_missing',
definitionId: 'keyed-note_missing',
position: [2, 3],
},
],
})
expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([
{
severity: 'warning',
message:
'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.',
},
])
})
test('resolves scoped drawing sheet document markers', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
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],
],
},
{
id: 'sheet-marker_other-view',
kind: 'detail-reference',
label: '3',
placedViewId: 'drawing-view_other',
position: [5, 5],
},
],
})
expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: '',
x: 2,
y: 3,
endX: null,
endY: null,
points: [],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: 'A',
x: 0.5,
y: 0.5,
endX: null,
endY: null,
points: [
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 1, y: 2 },
],
},
])
})
})
describe('resolveSheetPageSetup', () => {
test('resolves supported paper sizes and orientation to page points', () => {
expect(
resolveSheetPageSetup({
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
}),
).toEqual({ width: 1296, height: 864, orientation: 'landscape' })
const a3 = resolveSheetPageSetup({
paperSize: 'a3',
orientation: 'portrait',
customPaperWidth: null,
customPaperHeight: null,
})
expect(a3.width).toBeCloseTo(841.89, 2)
expect(a3.height).toBeCloseTo(1190.55, 2)
})
test('uses custom paper dimensions in inches', () => {
expect(
resolveSheetPageSetup({
paperSize: 'custom',
orientation: 'portrait',
customPaperWidth: 24,
customPaperHeight: 36,
}),
).toEqual({ width: 1728, height: 2592, orientation: 'portrait' })
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import type { GeometryContext } from '@pascal-app/core'
import {
createFloorplanContextExtensions,
normalizeFloorplanWallDimensionReference,
readFloorplanContext,
} from './floorplan-extension'
function context(extensions?: Readonly<Record<string, unknown>>): GeometryContext {
return {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
extensions,
}
}
describe('floor-plan context extensions', () => {
test('defaults wall dimensions to finished faces', () => {
expect(readFloorplanContext(context()).wallDimensionReference).toBe('finished-faces')
})
test('carries the selected centerline or stud-face reference', () => {
for (const wallDimensionReference of ['centerline', 'stud-faces'] as const) {
const extensions = createFloorplanContextExtensions({ wallDimensionReference })
expect(readFloorplanContext(context(extensions)).wallDimensionReference).toBe(
wallDimensionReference,
)
}
})
test('normalizes stale persisted references to the finished-face default', () => {
expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
})
})
@@ -0,0 +1,177 @@
import type {
AnyNode,
AnyNodeId,
ConstructionDrawingType,
DrawingSheetNode,
FloorplanGeometry,
GeometryContext,
NodeDefinition,
SceneApi,
} from '@pascal-app/core'
import type { ComponentType } from 'react'
export const FLOORPLAN_NODE_EXTENSION_KEY = 'pascal:editor/floorplan'
export const FLOORPLAN_GEOMETRY_METADATA_KEY = 'pascal:editor/floorplan'
export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
export type FloorplanRenderPurpose = 'edit' | 'document'
export type FloorplanMetricNotation = 'meters' | 'millimeters'
export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
export type FloorplanAnnotationRole =
| 'automatic-dimension'
| 'manual-dimension'
| 'measurement'
| 'opening-mark'
| 'structural-grid'
| 'column-center'
| 'room-label'
| 'stair-annotation'
export type FloorplanSchedule = {
id: string
title: string
columns: ReadonlyArray<{
key: string
label: string
weight?: number
}>
rows: ReadonlyArray<{
id: string
cells: Readonly<Record<string, string>>
}>
issues?: readonly string[]
}
export type FloorplanToolContext = {
sceneApi: SceneApi
activeLevelId: AnyNodeId | null
unit: 'metric' | 'imperial'
metricNotation: FloorplanMetricNotation
gridSnapStep: number
toolDefaults: Readonly<Record<string, unknown>> | null
selectNode: (id: AnyNodeId) => void
finishTool: () => void
}
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
preferredView?: '2d' | '3d'
actionMenu?: {
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
}
resolveDrawingSheet?: (args: {
node: N
levelId: AnyNodeId
drawingType: ConstructionDrawingType
}) => DrawingSheetNode | null
schedule?: (args: {
siblings: ReadonlyArray<N>
nodes: Readonly<Record<string, AnyNode>>
levelId: AnyNodeId
unit: 'metric' | 'imperial'
}) => FloorplanSchedule | null
linkedLevelIds?: (node: N) => readonly AnyNodeId[]
resolveForDrawing?: (args: {
node: N
nodes: Record<string, AnyNode>
drawingType: ConstructionDrawingType
}) => AnyNode | null
}
type FloorplanGeometryMetadata = {
annotationRole?: FloorplanAnnotationRole
annotationObstacle?: 'bounds' | 'outline'
}
type FloorplanContextExtension = {
purpose: FloorplanRenderPurpose
metricNotation: FloorplanMetricNotation
wallDimensionReference: FloorplanWallDimensionReference
}
export function normalizeFloorplanWallDimensionReference(
value: unknown,
): FloorplanWallDimensionReference {
return value === 'centerline' || value === 'stud-faces'
? value
: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE
}
export function getFloorplanNodeExtension(
definition: NodeDefinition<any> | undefined,
): FloorplanNodeExtension | undefined {
return definition?.extensions?.[FLOORPLAN_NODE_EXTENSION_KEY] as
| FloorplanNodeExtension
| undefined
}
export function floorplanGeometryMetadata(
values: FloorplanGeometryMetadata,
): Readonly<Record<string, unknown>> {
return { [FLOORPLAN_GEOMETRY_METADATA_KEY]: values }
}
export function withFloorplanGeometryMetadata<T extends FloorplanGeometry | null>(
geometry: T,
values: FloorplanGeometryMetadata,
): T {
if (!geometry) return geometry
const existing = readFloorplanGeometryMetadata(geometry)
return {
...geometry,
metadata: floorplanGeometryMetadata({ ...existing, ...values }),
} as T
}
export function readFloorplanGeometryMetadata(geometry: unknown): FloorplanGeometryMetadata {
const metadata = (geometry as { metadata?: Readonly<Record<string, unknown>> } | null)?.metadata
const value = metadata?.[FLOORPLAN_GEOMETRY_METADATA_KEY]
return value && typeof value === 'object' && !Array.isArray(value)
? (value as FloorplanGeometryMetadata)
: {}
}
export function createFloorplanContextExtensions(
values: Partial<FloorplanContextExtension>,
): Readonly<Record<string, unknown>> {
return {
[FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
purpose: values.purpose === 'document' ? 'document' : 'edit',
metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
values.wallDimensionReference,
),
} satisfies FloorplanContextExtension,
}
}
export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExtension {
const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
if (value && typeof value === 'object' && !Array.isArray(value)) {
const extension = value as Partial<FloorplanContextExtension>
return {
purpose: extension.purpose === 'document' ? 'document' : 'edit',
metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
extension.wallDimensionReference,
),
}
}
return {
purpose: 'edit',
metricNotation: 'meters',
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
}
}
export function readFloorplanMetricNotationOverride(
ctx: GeometryContext,
): FloorplanMetricNotation | undefined {
const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY]
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const metricNotation = (value as { metricNotation?: unknown }).metricNotation
return metricNotation === 'meters' || metricNotation === 'millimeters'
? metricNotation
: undefined
}
@@ -0,0 +1,194 @@
import type PdfKitDocument from 'pdfkit'
type PdfKitDocumentInstance = InstanceType<typeof PdfKitDocument>
type PdfTextOptions = {
align?: 'left' | 'center' | 'right'
maxWidth?: number
}
type PdfShapeStyle = 'F' | 'S'
export class FloorplanPdfDocument {
readonly raw: PdfKitDocumentInstance
readonly internal: {
pageSize: {
getWidth: () => number
getHeight: () => number
}
}
private currentFontSize = 12
private readonly defaultPageSize: readonly [number, number]
constructor(raw: PdfKitDocumentInstance, defaultPageSize: readonly [number, number]) {
this.raw = raw
this.defaultPageSize = defaultPageSize
this.internal = {
pageSize: {
getWidth: () => this.raw.page?.width ?? this.defaultPageSize[0],
getHeight: () => this.raw.page?.height ?? this.defaultPageSize[1],
},
}
}
addPage(
size: readonly [number, number] = this.defaultPageSize,
_orientation?: 'portrait' | 'landscape',
): this {
this.raw.addPage({ size: [size[0], size[1]], margin: 0 })
return this
}
setTextColor(color: string): this {
this.raw.fillColor(color)
return this
}
setDrawColor(color: string): this {
this.raw.strokeColor(color)
return this
}
setFillColor(color: string): this {
this.raw.fillColor(color)
return this
}
setLineWidth(width: number): this {
this.raw.lineWidth(width)
return this
}
setFont(family: string, weight: string = 'normal'): this {
const normalizedFamily = family.toLocaleLowerCase()
const normalizedWeight = weight.toLocaleLowerCase()
const bold = normalizedWeight === 'bold' || Number.parseInt(normalizedWeight, 10) >= 500
const base = normalizedFamily.includes('courier') ? 'Courier' : 'Helvetica'
this.raw.font(bold ? `${base}-Bold` : base)
return this
}
setFontSize(size: number): this {
this.currentFontSize = size
this.raw.fontSize(size)
return this
}
getTextWidth(value: string): number {
return this.raw.widthOfString(value, { lineBreak: false })
}
splitTextToSize(value: string, maxWidth: number): string[] {
if (maxWidth <= 0 || this.getTextWidth(value) <= maxWidth) return [value]
const words = value.trim().split(/\s+/)
const lines: string[] = []
let line = ''
for (const word of words) {
const candidate = line ? `${line} ${word}` : word
if (!line || this.getTextWidth(candidate) <= maxWidth) {
line = candidate
continue
}
lines.push(line)
line = word
}
if (line) lines.push(line)
return lines.length > 0 ? lines : ['']
}
text(
value: string | readonly string[],
x: number,
baselineY: number,
options: PdfTextOptions = {},
) {
const lines = typeof value === 'string' ? value.split('\n') : value
const lineHeight = this.currentFontSize * 1.2
lines.forEach((line, index) => {
const width = this.getTextWidth(line)
const drawX =
options.align === 'center' ? x - width / 2 : options.align === 'right' ? x - width : x
this.raw.text(line, drawX, baselineY - this.currentFontSize * 0.78 + index * lineHeight, {
lineBreak: false,
width: options.maxWidth,
})
})
return this
}
line(x1: number, y1: number, x2: number, y2: number): this {
this.raw.moveTo(x1, y1).lineTo(x2, y2).stroke()
return this
}
rect(x: number, y: number, width: number, height: number, style: PdfShapeStyle = 'S'): this {
this.raw.rect(x, y, width, height)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
roundedRect(
x: number,
y: number,
width: number,
height: number,
radiusX: number,
_radiusY: number,
style: PdfShapeStyle = 'S',
): this {
this.raw.roundedRect(x, y, width, height, radiusX)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
circle(x: number, y: number, radius: number, style: PdfShapeStyle = 'S'): this {
this.raw.circle(x, y, radius)
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
triangle(
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
style: PdfShapeStyle = 'S',
): this {
this.raw.polygon([x1, y1], [x2, y2], [x3, y3])
if (style === 'F') this.raw.fill()
else this.raw.stroke()
return this
}
}
export async function createFloorplanPdfDocument(defaultPageSize: readonly [number, number]) {
const [{ default: PDFDocument }, { default: blobStream }] = await Promise.all([
import('pdfkit/js/pdfkit.standalone'),
import('blob-stream'),
])
const raw = new PDFDocument({ autoFirstPage: false, compress: true, margin: 0 })
const stream = raw.pipe(blobStream())
return {
doc: new FloorplanPdfDocument(raw, defaultPageSize),
save: async (filename: string) => {
const blob = await new Promise<Blob>((resolve, reject) => {
stream.on('finish', () => resolve(stream.toBlob('application/pdf')))
stream.on('error', reject)
raw.on('error', reject)
raw.end()
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
},
}
}
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import PDFDocument from 'pdfkit'
import { floorplanGeometryMetadata } from './floorplan-extension'
import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
describe('renderFloorplanGeometryToPdfKit', () => {
test('writes dimension values as native PDF text with fixed point line weights', async () => {
const geometry = {
kind: 'dimension',
start: [0, 0],
end: [13, 0],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.1,
text: '13m',
} satisfies FloorplanGeometry
const pdf = await renderTestPdf(geometry)
expect(pdf).toContain('BT')
expect(pdf).toContain(`[<${Buffer.from('13m').toString('hex')}> 0] TJ`)
expect(pdf).toMatch(/0\.1 w/)
expect(pdf).toMatch(/0\.15 w/)
expect(pdf).not.toMatch(/ c\n/)
})
test('writes rotated annotation labels through PDF text operators', async () => {
const geometry = {
kind: 'text',
x: 4,
y: 3,
text: 'ROOM 101',
fontSize: 0.15,
fontWeight: 600,
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
const pdf = await renderTestPdf(geometry, 45)
expect(pdf).toContain(`<${Buffer.from('OOM 101').toString('hex')}>`)
expect(pdf).toContain('BT')
})
test('uses one font face, weight, and point size for every dimension value path', async () => {
const geometries = [
{
kind: 'dimension',
start: [0, 0],
end: [4, 0],
offsetNormal: [0, -1],
offsetDistance: 1,
extensionOvershoot: 0.1,
text: '4m',
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 2,
cy: 2,
text: '2m',
angle: 0,
},
{
kind: 'text',
x: 1,
y: 3,
text: '1m',
fontSize: 0.22,
fontWeight: 700,
metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }),
},
] satisfies FloorplanGeometry[]
const pdfs = await Promise.all(geometries.map((geometry) => renderTestPdf(geometry)))
const baseFonts = pdfs.flatMap((pdf) =>
[...pdf.matchAll(/\/BaseFont \/([^\n]+)/g)].map((match) => match[1]),
)
const fontSizes = pdfs.flatMap((pdf) =>
[...pdf.matchAll(/\/F\d+ ([\d.]+) Tf/g)].map((match) => match[1]),
)
expect([...new Set(baseFonts)]).toEqual(['Courier'])
expect([...new Set(fontSizes)]).toEqual(['1.6'])
})
})
async function renderTestPdf(geometry: FloorplanGeometry, rotationDeg = 0): Promise<string> {
const raw = new PDFDocument({ autoFirstPage: false, compress: false })
const chunks: Buffer[] = []
raw.on('data', (chunk: Buffer) => chunks.push(chunk))
const completed = new Promise<string>((resolve) => {
raw.on('end', () => resolve(Buffer.concat(chunks).toString('latin1')))
})
const doc = new FloorplanPdfDocument(raw, [200, 200])
doc.addPage()
await renderFloorplanGeometryToPdfKit(doc, geometry, {
annotationLayer: true,
placement: { x: 20, y: 20, width: 100, height: 100 },
rotationDeg,
viewport: { x: 0, y: -2, width: 20, height: 20 },
})
raw.end()
return completed
}
@@ -0,0 +1,680 @@
import { type FloorplanGeometry, type FloorplanPoint, loadAssetUrl } from '@pascal-app/core'
import {
type ArchitecturalDimensionLayout,
computeArchitecturalDimensionLayout,
} from '../../components/editor-2d/renderers/floorplan-dimension-renderer'
import {
documentCircleGeometryAttrs,
documentRectGeometryAttrs,
resolveDocumentAnnotationGroupChildren,
} from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from '../../components/editor-2d/renderers/floorplan-label-angle'
import type { FloorplanExportBounds } from './floorplan-export'
import { readFloorplanGeometryMetadata } from './floorplan-extension'
import type { FloorplanPdfDocument } from './floorplan-pdfkit-document'
const DIMENSION_LINE_WIDTH_PT = 0.5
const DIMENSION_TICK_WIDTH_PT = 0.75
const DIMENSION_TEXT_FONT_FAMILY = 'Courier'
const DIMENSION_TEXT_FONT_SIZE_PT = 8
const DIMENSION_TEXT_FONT_WEIGHT = 400
const DIMENSION_BASELINE_OFFSET_PT = 5
const DEFAULT_ANNOTATION_FONT_SIZE_PT = 8
const ROOM_NUMBER_FONT_SIZE_PT = 7
const ROOM_DETAIL_FONT_SIZE_PT = 5.5
const MARK_FONT_SIZE_PT = 7
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type StyledGeometry = Extract<
FloorplanGeometry,
{ kind: 'path' | 'polygon' | 'polyline' | 'rect' | 'circle' | 'line' }
>
type RenderContext = {
annotationLabelShiftIndex: number
annotationLabelShifts: readonly FloorplanPoint[]
annotationLayer: boolean
sceneRotationDeg: number
unitsPerPoint: number
}
export type FloorplanPdfKitPlacement = {
x: number
y: number
width: number
height: number
}
export async function renderFloorplanGeometryToPdfKit(
doc: FloorplanPdfDocument,
geometry: FloorplanGeometry,
options: {
annotationLabelShifts?: readonly FloorplanPoint[]
annotationLayer: boolean
placement: FloorplanPdfKitPlacement
rotationDeg: number
viewport: FloorplanExportBounds
},
): Promise<void> {
const pointsPerUnit = options.placement.width / options.viewport.width
if (!Number.isFinite(pointsPerUnit) || pointsPerUnit <= 0) return
const raw = doc.raw
raw.save()
raw.translate(options.placement.x, options.placement.y)
raw.scale(pointsPerUnit)
raw.translate(-options.viewport.x, -options.viewport.y)
raw.rotate(options.rotationDeg, { origin: [0, 0] })
await renderGeometry(doc, geometry, {
annotationLabelShiftIndex: 0,
annotationLabelShifts: options.annotationLabelShifts ?? [],
annotationLayer: options.annotationLayer,
sceneRotationDeg: options.rotationDeg,
unitsPerPoint: 1 / pointsPerUnit,
})
raw.restore()
}
async function renderGeometry(
doc: FloorplanPdfDocument,
geometry: FloorplanGeometry,
context: RenderContext,
): Promise<void> {
const raw = doc.raw
switch (geometry.kind) {
case 'path':
raw.save().path(geometry.d)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'polygon':
if (geometry.points.length < 2) return
raw.save().polygon(...geometry.points.map(([x, y]) => [x, y] as [number, number]))
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'polyline':
if (geometry.points.length < 2) return
raw.save().moveTo(geometry.points[0]![0], geometry.points[0]![1])
for (const [x, y] of geometry.points.slice(1)) raw.lineTo(x, y)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'rect':
raw.save()
{
const attrs = documentRectGeometryAttrs(
geometry,
context.annotationLayer ? context.unitsPerPoint : undefined,
)
if ((attrs.rx ?? 0) > 0 || (attrs.ry ?? 0) > 0) {
raw.roundedRect(
attrs.x,
attrs.y,
attrs.width,
attrs.height,
Math.max(attrs.rx ?? 0, attrs.ry ?? 0),
)
} else {
raw.rect(attrs.x, attrs.y, attrs.width, attrs.height)
}
}
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'circle':
raw
.save()
.circle(
geometry.cx,
geometry.cy,
documentCircleGeometryAttrs(
geometry,
context.annotationLayer ? context.unitsPerPoint : undefined,
).r,
)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'line':
raw.save().moveTo(geometry.x1, geometry.y1).lineTo(geometry.x2, geometry.y2)
paintStyledGeometry(raw, geometry, context)
raw.restore()
return
case 'text':
drawGeometryText(doc, geometry, context)
return
case 'dimension':
drawDimension(doc, geometry, context)
return
case 'dimension-string':
drawDimensionString(doc, geometry, context)
return
case 'dimension-label':
drawDimensionLabel(doc, geometry, context)
return
case 'equal-spacing-badge':
drawEqualSpacingBadge(doc, geometry, context)
return
case 'image':
await drawImage(doc, geometry)
return
case 'group':
raw.save()
if (geometry.transform?.translate) {
raw.translate(geometry.transform.translate[0], geometry.transform.translate[1])
}
if (geometry.transform?.rotate !== undefined) {
raw.rotate((geometry.transform.rotate * 180) / Math.PI, { origin: [0, 0] })
}
for (const child of resolveDocumentAnnotationGroupChildren(
geometry.children,
context.annotationLayer ? context.unitsPerPoint : undefined,
)) {
await renderGeometry(doc, child, context)
}
raw.restore()
return
default:
return
}
}
function paintStyledGeometry(
raw: FloorplanPdfDocument['raw'],
geometry: StyledGeometry,
context: RenderContext,
): void {
const fill = geometry.fill && geometry.fill !== 'none' ? geometry.fill : null
const stroke = geometry.stroke && geometry.stroke !== 'none' ? geometry.stroke : null
const opacity = geometry.opacity ?? 1
const fillOpacity = (geometry.fillOpacity ?? 1) * opacity
const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity
if (fill) raw.fillColor(fill).fillOpacity(fillOpacity)
if (stroke) {
raw.strokeColor(stroke).strokeOpacity(strokeOpacity)
raw.lineWidth(resolveStrokeWidth(geometry, context))
if (geometry.strokeLinecap) raw.lineCap(geometry.strokeLinecap)
if (geometry.strokeLinejoin) raw.lineJoin(geometry.strokeLinejoin)
applyDash(
raw,
geometry.strokeDasharray,
geometry.vectorEffect === 'non-scaling-stroke',
context,
)
}
if (fill && stroke) raw.fillAndStroke(fill, stroke)
else if (fill) raw.fill(fill)
else if (stroke) raw.stroke(stroke)
}
function resolveStrokeWidth(geometry: StyledGeometry, context: RenderContext): number {
if (context.annotationLayer) return DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
if (geometry.vectorEffect === 'non-scaling-stroke') {
return (geometry.strokeWidth ?? 1) * context.unitsPerPoint
}
return geometry.strokeWidth ?? DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint
}
function applyDash(
raw: FloorplanPdfDocument['raw'],
dasharray: string | undefined,
nonScaling: boolean,
context: RenderContext,
): void {
if (!dasharray) {
raw.undash()
return
}
const values = dasharray
.split(/[\s,]+/)
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0)
if (values.length === 0) return
const scale = nonScaling ? context.unitsPerPoint : 1
raw.dash(values[0]! * scale, { space: (values[1] ?? values[0]!) * scale })
}
function drawGeometryText(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
context: RenderContext,
): void {
const dimensionValue =
readFloorplanGeometryMetadata(geometry).annotationRole === 'automatic-dimension'
const fontSize = context.annotationLayer
? (dimensionValue ? DIMENSION_TEXT_FONT_SIZE_PT : annotationTextSizePt(geometry)) *
context.unitsPerPoint
: geometry.fontSize
const outlinedForScreen = geometry.paintOrder === 'stroke' && !!geometry.stroke
const fill =
outlinedForScreen && geometry.fill?.toLocaleLowerCase() === '#ffffff'
? (geometry.stroke ?? '#111827')
: (geometry.fill ?? '#171717')
drawNativeText(doc, {
angleDeg: geometry.upright ? -context.sceneRotationDeg : 0,
anchor: geometry.textAnchor ?? 'start',
fill,
fontFamily: dimensionValue ? DIMENSION_TEXT_FONT_FAMILY : geometry.fontFamily,
fontSize,
fontWeight: dimensionValue ? DIMENSION_TEXT_FONT_WEIGHT : geometry.fontWeight,
opacity: geometry.opacity,
text: geometry.text,
x: geometry.x,
y: geometry.y,
})
}
function annotationTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
case 'room-label':
if (geometry.fontSize >= 0.18) return DEFAULT_ANNOTATION_FONT_SIZE_PT
if (geometry.fontSize >= 0.145) return ROOM_NUMBER_FONT_SIZE_PT
return ROOM_DETAIL_FONT_SIZE_PT
case 'column-center':
case 'stair-annotation':
return MARK_FONT_SIZE_PT
default:
return DEFAULT_ANNOTATION_FONT_SIZE_PT
}
}
function drawDimension(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
context: RenderContext,
): void {
const layout = computeArchitecturalDimensionLayout(
geometry,
context.sceneRotationDeg,
context.unitsPerPoint,
)
if (!layout) return
drawDimensionLayout(doc, geometry, layout, context)
}
function drawDimensionString(
doc: FloorplanPdfDocument,
geometry: DimensionStringGeometry,
context: RenderContext,
): void {
const entries = geometry.segments.flatMap((segment) => {
const dimension: 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(
dimension,
context.sceneRotationDeg,
context.unitsPerPoint,
)
return layout ? [{ dimension, layout }] : []
})
if (entries.length === 0) return
const extensionLines = new Map<string, readonly [FloorplanPoint, FloorplanPoint]>()
const terminators = new Map<
string,
{ point: FloorplanPoint; toward: FloorplanPoint; layout: ArchitecturalDimensionLayout }
>()
for (const { layout } of entries) {
extensionLines.set(pointKey(layout.dimensionStart), [
layout.extensionStart,
layout.extensionStartTip,
])
extensionLines.set(pointKey(layout.dimensionEnd), [layout.extensionEnd, layout.extensionEndTip])
terminators.set(pointKey(layout.dimensionStart), {
point: layout.dimensionStart,
toward: layout.dimensionEnd,
layout,
})
terminators.set(pointKey(layout.dimensionEnd), {
point: layout.dimensionEnd,
toward: layout.dimensionStart,
layout,
})
}
const stroke = geometry.stroke ?? '#334155'
for (const [start, end] of extensionLines.values())
drawDimensionLine(doc, start, end, stroke, context)
for (const { layout } of entries) {
drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
}
for (const terminator of terminators.values()) {
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
terminator.point,
terminator.toward,
terminator.layout,
stroke,
context,
)
}
for (const { dimension, layout } of entries)
drawDimensionText(doc, dimension, layout, stroke, context)
}
function drawDimensionLayout(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
layout: ArchitecturalDimensionLayout,
context: RenderContext,
): void {
const stroke = geometry.stroke ?? '#334155'
drawDimensionLine(doc, layout.extensionStart, layout.extensionStartTip, stroke, context)
drawDimensionLine(doc, layout.extensionEnd, layout.extensionEndTip, stroke, context)
drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context)
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
layout.dimensionStart,
layout.dimensionEnd,
layout,
stroke,
context,
)
drawDimensionTerminator(
doc,
geometry.terminator ?? 'architectural-tick',
layout.dimensionEnd,
layout.dimensionStart,
layout,
stroke,
context,
)
drawDimensionText(doc, geometry, layout, stroke, context)
}
function drawDimensionLine(
doc: FloorplanPdfDocument,
start: FloorplanPoint,
end: FloorplanPoint,
stroke: string,
context: RenderContext,
widthPt = DIMENSION_LINE_WIDTH_PT,
): void {
doc.raw
.save()
.strokeColor(stroke)
.strokeOpacity(1)
.lineCap('butt')
.lineWidth(widthPt * context.unitsPerPoint)
.moveTo(start[0], start[1])
.lineTo(end[0], end[1])
.stroke()
.restore()
}
function drawDimensionTerminator(
doc: FloorplanPdfDocument,
terminator: NonNullable<DimensionGeometry['terminator']>,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: ArchitecturalDimensionLayout,
stroke: string,
context: RenderContext,
): void {
const direction = normalized(point, toward)
if (!direction) return
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
if (terminator === 'dot') {
doc.raw
.save()
.fillColor(stroke)
.circle(point[0], point[1], tickHalfLength * 0.45)
.fill()
.restore()
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') {
doc.raw
.save()
.fillColor(stroke)
.polygon([point[0], point[1]], [left[0], left[1]], [right[0], right[1]])
.fill()
.restore()
return
}
drawDimensionLine(doc, point, left, stroke, context, DIMENSION_TICK_WIDTH_PT)
drawDimensionLine(doc, point, right, stroke, context, DIMENSION_TICK_WIDTH_PT)
return
}
const [tickX, tickY] = layout.tickHalfVector
drawDimensionLine(
doc,
[point[0] - tickX, point[1] - tickY],
[point[0] + tickX, point[1] + tickY],
stroke,
context,
DIMENSION_TICK_WIDTH_PT,
)
}
function drawDimensionText(
doc: FloorplanPdfDocument,
geometry: DimensionGeometry,
layout: ArchitecturalDimensionLayout,
stroke: string,
context: RenderContext,
): void {
const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * context.unitsPerPoint
const y =
geometry.textPosition === 'centered'
? fontSize * 0.35
: -DIMENSION_BASELINE_OFFSET_PT * context.unitsPerPoint
const raw = doc.raw
const shift = nextAnnotationLabelShift(context)
raw
.save()
.translate(layout.labelPoint[0], layout.labelPoint[1])
.rotate(layout.labelAngleDeg)
.translate(shift[0], shift[1])
drawNativeText(doc, {
anchor: 'middle',
fill: stroke,
fontFamily: DIMENSION_TEXT_FONT_FAMILY,
fontSize,
fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
text: geometry.text,
x: 0,
y,
})
raw.restore()
}
function drawDimensionLabel(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'dimension-label' }>,
context: RenderContext,
): void {
const unitsPerPoint = context.unitsPerPoint
const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * unitsPerPoint
const padX = 6 * unitsPerPoint
const padY = 3 * unitsPerPoint
const textWidth = geometry.text.length * 6.2 * unitsPerPoint
const plateWidth = textWidth + padX * 2
const plateHeight = fontSize + padY * 2
const angle = resolveFloorplanLabelAngle(
geometry.angle,
context.sceneRotationDeg,
geometry.screenUpright,
)
const offset = -(geometry.offsetPx ?? 0) * unitsPerPoint
const shift = nextAnnotationLabelShift(context)
const raw = doc.raw
raw
.save()
.translate(geometry.cx, geometry.cy)
.rotate(angle)
.translate(shift[0], shift[1] + offset)
raw
.fillColor('#ffffff')
.fillOpacity(0.92)
.roundedRect(-plateWidth / 2, -plateHeight / 2, plateWidth, plateHeight, 3 * unitsPerPoint)
.fill()
drawNativeText(doc, {
anchor: 'middle',
fill: '#111827',
fontFamily: DIMENSION_TEXT_FONT_FAMILY,
fontSize,
fontWeight: DIMENSION_TEXT_FONT_WEIGHT,
text: geometry.text,
x: 0,
y: 0,
})
raw.restore()
}
function drawEqualSpacingBadge(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'equal-spacing-badge' }>,
context: RenderContext,
): void {
const fontSize = 7 * context.unitsPerPoint
const width = Math.max(
14 * context.unitsPerPoint,
geometry.text.length * fontSize * 0.62 + 6 * context.unitsPerPoint,
)
const height = 12 * context.unitsPerPoint
const angle = resolveFloorplanLabelAngle(geometry.angle, context.sceneRotationDeg)
const raw = doc.raw
raw.save().translate(geometry.point[0], geometry.point[1]).rotate(angle)
raw
.fillColor('#ffffff')
.roundedRect(-width / 2, -height / 2, width, height, height / 2)
.fill()
drawNativeText(doc, {
anchor: 'middle',
fill: '#334155',
fontFamily: 'Courier',
fontSize,
fontWeight: 600,
text: geometry.text,
x: 0,
y: 0,
})
raw.restore()
}
function drawNativeText(
doc: FloorplanPdfDocument,
options: {
angleDeg?: number
anchor: 'start' | 'middle' | 'end'
fill: string
fontFamily?: string
fontSize: number
fontWeight?: number | string
opacity?: number
text: string
x: number
y: number
},
): void {
const raw = doc.raw
const normalizedFamily = options.fontFamily?.toLocaleLowerCase() ?? ''
const family =
normalizedFamily.includes('mono') || normalizedFamily.includes('courier')
? 'Courier'
: 'Helvetica'
const numericWeight = Number.parseInt(String(options.fontWeight ?? 400), 10)
const bold =
options.fontWeight === 'bold' || (Number.isFinite(numericWeight) && numericWeight >= 500)
raw.save().translate(options.x, options.y)
if (options.angleDeg) raw.rotate(options.angleDeg)
raw
.font(bold ? `${family}-Bold` : family)
.fontSize(options.fontSize)
.fillColor(options.fill)
raw.fillOpacity(options.opacity ?? 1)
const width = raw.widthOfString(options.text, { lineBreak: false })
const x = options.anchor === 'middle' ? -width / 2 : options.anchor === 'end' ? -width : 0
raw.text(options.text, x, -options.fontSize * 0.42, { lineBreak: false })
raw.restore()
}
async function drawImage(
doc: FloorplanPdfDocument,
geometry: Extract<FloorplanGeometry, { kind: 'image' }>,
): Promise<void> {
try {
const url = await loadAssetUrl(geometry.url)
if (!url) return
const response = await fetch(url)
if (!response.ok) return
const dataUrl = await blobToDataUrl(await response.blob())
const raw = doc.raw
raw.save().translate(geometry.center[0], geometry.center[1])
if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI)
raw.opacity(geometry.opacity ?? 1)
const options =
geometry.preserveAspectRatio === 'none'
? { width: geometry.width, height: geometry.height }
: {
fit: [geometry.width, geometry.height] as [number, number],
align: 'center' as const,
valign: 'center' as const,
}
raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options)
raw.restore()
} catch {
return
}
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(String(reader.result))
reader.readAsDataURL(blob)
})
}
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 addScaled(
point: FloorplanPoint,
direction: FloorplanPoint,
distance: number,
): FloorplanPoint {
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
}
function pointKey(point: FloorplanPoint): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
}
function nextAnnotationLabelShift(context: RenderContext): FloorplanPoint {
const shift = context.annotationLabelShifts[context.annotationLabelShiftIndex] ?? [0, 0]
context.annotationLabelShiftIndex += 1
return shift
}
@@ -3,9 +3,8 @@ import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
// Baseline rotation (deg) that orients the plan-local scene "north up" on // Baseline rotation (deg) that orients the plan-local scene "north up" on
// screen. The on-screen floor-plan scene `<g>` is rotated by // screen. The on-screen floor-plan scene `<g>` is rotated by
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF // `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export
// export mirrors the aligned-to-north case (user offset 0) so an export points // uses the same user rotation so its orientation matches the live plan.
// the same way as the app's north-aligned view.
// //
// North is world Z: with a 0 baseline a rotation-0 reference image (top = // North is world Z: with a 0 baseline a rotation-0 reference image (top =
// Z) reads upright in the north-aligned view, and "align north" maps to a // Z) reads upright in the north-aligned view, and "align north" maps to a
@@ -72,6 +72,11 @@ describe('linear measurements', () => {
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m') expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
}) })
test('formats metric measurements in whole millimeters', () => {
expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm')
expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm')
})
test('formats imperial measurements as feet and inches', () => { test('formats imperial measurements as feet and inches', () => {
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`) expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`) expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
+12 -1
View File
@@ -1,6 +1,7 @@
import type { MeasurementPoint } from '@pascal-app/core' import type { MeasurementPoint } from '@pascal-app/core'
export type LinearUnit = 'metric' | 'imperial' export type LinearUnit = 'metric' | 'imperial'
export type MetricNotation = 'meters' | 'millimeters'
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1' export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
export const MEASUREMENT_DANGLING_COLOR = '#dc2626' export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
@@ -173,7 +174,11 @@ export function formatVolumeLabel(
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}` return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
} }
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string { export function formatLinearMeasurement(
meters: number,
unit: LinearUnit,
metricNotation: MetricNotation = 'meters',
): string {
if (!Number.isFinite(meters)) return '--' if (!Number.isFinite(meters)) return '--'
const absoluteMeters = Math.abs(meters) const absoluteMeters = Math.abs(meters)
@@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin
return `${sign}${wholeFeet}'${inches}"` return `${sign}${wholeFeet}'${inches}"`
} }
if (metricNotation === 'millimeters') {
const roundedMillimeters = Math.round(absoluteMeters * 1000)
const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
return `${sign}${roundedMillimeters}mm`
}
const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2)) const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
const sign = meters < 0 && roundedMeters !== 0 ? '-' : '' const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import { normalizeAnnotationLayoutOverrides, normalizeDrawingType } from './use-drawing-view'
describe('normalizeDrawingType', () => {
test('restores every persistent construction drawing type', () => {
expect(normalizeDrawingType('floor-plan')).toBe('floor-plan')
expect(normalizeDrawingType('foundation-plan')).toBe('foundation-plan')
expect(normalizeDrawingType('reflected-ceiling-plan')).toBe('reflected-ceiling-plan')
expect(normalizeDrawingType('roof-plan')).toBe('roof-plan')
expect(normalizeDrawingType('site-plan')).toBe('site-plan')
})
test('falls back to the floor plan for stale persisted values', () => {
expect(normalizeDrawingType('unknown')).toBe('floor-plan')
expect(normalizeDrawingType(null)).toBe('floor-plan')
})
})
describe('normalizeAnnotationLayoutOverrides', () => {
test('keeps finite pinned drawing-view annotation offsets', () => {
expect(
normalizeAnnotationLayoutOverrides({
a: { dx: 1.25, dy: -0.5, pinned: true },
stale: { dx: Number.NaN, dy: 0, pinned: true },
unpinned: { dx: 1, dy: 2, pinned: false },
}),
).toEqual({
a: { dx: 1.25, dy: -0.5, pinned: true },
})
})
})
@@ -0,0 +1,130 @@
'use client'
import {
CONSTRUCTION_DRAWING_TYPES,
type ConstructionDrawingType,
type DrawingSheetScale,
} from '@pascal-app/core'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const DRAWING_TYPE_OPTIONS = [
{ id: 'floor-plan', label: 'Floor plan' },
{ id: 'foundation-plan', label: 'Foundation plan' },
{ id: 'reflected-ceiling-plan', label: 'Reflected ceiling plan' },
{ id: 'roof-plan', label: 'Roof plan' },
{ id: 'site-plan', label: 'Site plan' },
] as const satisfies readonly { id: ConstructionDrawingType; label: string }[]
export const DRAWING_SCALE_OPTIONS = [
{ id: '1:20', label: '1:20' },
{ id: '1:25', label: '1:25' },
{ id: '1:50', label: '1:50' },
{ id: '1:75', label: '1:75' },
{ id: '1:100', label: '1:100' },
{ id: '1/8"=1\'-0"', label: '1/8" = 1\'-0"' },
{ id: '1/4"=1\'-0"', label: '1/4" = 1\'-0"' },
{ id: '1/2"=1\'-0"', label: '1/2" = 1\'-0"' },
{ id: '1"=1\'-0"', label: '1" = 1\'-0"' },
] as const satisfies readonly { id: DrawingSheetScale; label: string }[]
export type DrawingAnnotationLayoutOverride = {
dx: number
dy: number
pinned: true
}
export type DrawingAnnotationLayoutOverrides = Record<string, DrawingAnnotationLayoutOverride>
type DrawingViewState = {
drawingType: ConstructionDrawingType
drawingScale: DrawingSheetScale
annotationLayoutOverrides: DrawingAnnotationLayoutOverrides
setDrawingType: (drawingType: ConstructionDrawingType) => void
setDrawingScale: (drawingScale: DrawingSheetScale) => void
setAnnotationLayoutOverride: (
id: string,
override: DrawingAnnotationLayoutOverride | null,
) => void
}
export function normalizeDrawingType(value: unknown): ConstructionDrawingType {
if (typeof value !== 'string') return 'floor-plan'
for (const drawingType of CONSTRUCTION_DRAWING_TYPES) {
if (drawingType === value) return drawingType
}
return 'floor-plan'
}
export function normalizeDrawingScale(value: unknown): DrawingSheetScale {
if (typeof value !== 'string') return '1/4"=1\'-0"'
for (const option of DRAWING_SCALE_OPTIONS) {
if (option.id === value) return option.id
}
return '1/4"=1\'-0"'
}
export function normalizeAnnotationLayoutOverrides(
value: unknown,
): DrawingAnnotationLayoutOverrides {
if (!value || typeof value !== 'object') return {}
const out: DrawingAnnotationLayoutOverrides = {}
for (const [id, raw] of Object.entries(value)) {
if (!id || !raw || typeof raw !== 'object') continue
const dx = (raw as { dx?: unknown }).dx
const dy = (raw as { dy?: unknown }).dy
const pinned = (raw as { pinned?: unknown }).pinned
if (
typeof dx === 'number' &&
Number.isFinite(dx) &&
typeof dy === 'number' &&
Number.isFinite(dy) &&
pinned === true
) {
out[id] = { dx, dy, pinned: true }
}
}
return out
}
const useDrawingView = create<DrawingViewState>()(
persist(
(set) => ({
drawingType: 'floor-plan',
drawingScale: '1/4"=1\'-0"',
annotationLayoutOverrides: {},
setDrawingType: (drawingType) => set({ drawingType }),
setDrawingScale: (drawingScale) => set({ drawingScale }),
setAnnotationLayoutOverride: (id, override) =>
set((state) => {
const next = { ...state.annotationLayoutOverrides }
if (override) next[id] = override
else delete next[id]
return { annotationLayoutOverrides: next }
}),
}),
{
name: 'pascal-floorplan-drawing-view',
merge: (persistedState, currentState) => ({
...currentState,
drawingType: normalizeDrawingType(
(persistedState as { drawingType?: unknown } | undefined)?.drawingType,
),
drawingScale: normalizeDrawingScale(
(persistedState as { drawingScale?: unknown } | undefined)?.drawingScale,
),
annotationLayoutOverrides: normalizeAnnotationLayoutOverrides(
(persistedState as { annotationLayoutOverrides?: unknown } | undefined)
?.annotationLayoutOverrides,
),
}),
partialize: (state) => ({
drawingType: state.drawingType,
drawingScale: state.drawingScale,
annotationLayoutOverrides: state.annotationLayoutOverrides,
}),
},
),
)
export default useDrawingView
+5 -1
View File
@@ -140,7 +140,7 @@ export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
// Structure mode tools (building elements) // Structure mode tools (building elements)
export type StructureTool = type BuiltInStructureTool =
| 'wall' | 'wall'
| 'fence' | 'fence'
| 'room' | 'room'
@@ -149,6 +149,7 @@ export type StructureTool =
| 'ceiling' | 'ceiling'
| 'roof' | 'roof'
| 'column' | 'column'
| 'structural-grid'
| 'elevator' | 'elevator'
| 'stair' | 'stair'
| 'item' | 'item'
@@ -178,6 +179,9 @@ export type StructureTool =
| 'pipe-fitting' | 'pipe-fitting'
| 'pipe-trap' | 'pipe-trap'
/** Registry node kinds are valid build tools without central union edits. */
export type StructureTool = BuiltInStructureTool | (string & {})
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = 'item' | 'cabinet' export type FurnishTool = 'item' | 'cabinet'
@@ -0,0 +1,60 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
type FloorplanAnnotationCategory,
type FloorplanAnnotationVisibility,
normalizeFloorplanAnnotationVisibility,
} from '../lib/floorplan/annotation-visibility'
import {
DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
type FloorplanWallDimensionReference,
normalizeFloorplanWallDimensionReference,
} from '../lib/floorplan/floorplan-extension'
type FloorplanAnnotationVisibilityState = {
visibility: FloorplanAnnotationVisibility
wallDimensionReference: FloorplanWallDimensionReference
setCategory: (category: FloorplanAnnotationCategory, visible: boolean) => void
setWallDimensionReference: (reference: FloorplanWallDimensionReference) => void
reset: () => void
}
const useFloorplanAnnotationVisibility = create<FloorplanAnnotationVisibilityState>()(
persist(
(set) => ({
visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY },
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
setCategory: (category, visible) =>
set((state) => ({ visibility: { ...state.visibility, [category]: visible } })),
setWallDimensionReference: (wallDimensionReference) => set({ wallDimensionReference }),
reset: () =>
set({
visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY },
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
}),
}),
{
name: 'pascal-floorplan-annotation-visibility',
merge: (persistedState, currentState) => ({
...currentState,
visibility: normalizeFloorplanAnnotationVisibility(
(persistedState as { visibility?: unknown } | undefined)?.visibility,
),
wallDimensionReference: normalizeFloorplanWallDimensionReference(
(persistedState as { wallDimensionReference?: unknown } | undefined)
?.wallDimensionReference,
),
}),
partialize: (state) =>
({
visibility: state.visibility,
wallDimensionReference: state.wallDimensionReference,
}) as FloorplanAnnotationVisibilityState,
},
),
)
export default useFloorplanAnnotationVisibility
@@ -0,0 +1,54 @@
'use client'
import { create } from 'zustand'
export type FloorplanPreflightIssueKind =
| 'unresolved-collision'
| 'short-unreadable-segment'
| 'plan-geometry-conflict'
| 'dimension-completeness'
| 'clearance-advisory'
| 'module-advisory'
| 'sheet-content'
export type FloorplanPreflightIssue = {
id: string
kind: FloorplanPreflightIssueKind
severity: 'info' | 'warning'
message: string
}
type FloorplanPreflightState = {
issues: FloorplanPreflightIssue[]
layoutIssues: FloorplanPreflightIssue[]
auditIssues: FloorplanPreflightIssue[]
clearanceChecksEnabled: boolean
moduleChecksEnabled: boolean
setIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setAuditIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setClearanceChecksEnabled: (enabled: boolean) => void
setModuleChecksEnabled: (enabled: boolean) => void
reset: () => void
}
export const useFloorplanPreflight = create<FloorplanPreflightState>((set) => ({
issues: [],
layoutIssues: [],
auditIssues: [],
clearanceChecksEnabled: false,
moduleChecksEnabled: false,
setIssues: (issues) =>
set((state) => ({ layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] })),
setAuditIssues: (issues) =>
set((state) => ({ auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] })),
setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }),
setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }),
reset: () =>
set((state) =>
state.layoutIssues.length === 0
? state
: { layoutIssues: [], issues: [...state.auditIssues] },
),
}))
export default useFloorplanPreflight
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { buildingDefinition } from './definition'
describe('buildingDefinition', () => {
test('tracks drawing-sheet child support in the schema version', () => {
expect(buildingDefinition.kind).toBe('building')
expect(buildingDefinition.schemaVersion).toBe(2)
expect(
buildingDefinition.schema.safeParse({
id: 'building_default',
type: 'building',
...buildingDefinition.defaults(),
children: ['level_main', 'drawing-sheet_a101'],
}).success,
).toBe(true)
})
})
+1 -1
View File
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
*/ */
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = { export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building', kind: 'building',
schemaVersion: 1, schemaVersion: 2,
schema: BuildingNode, schema: BuildingNode,
category: 'site', category: 'site',
+25 -2
View File
@@ -1,10 +1,15 @@
import { import {
ColumnNode as ColumnNodeSchema, ColumnNode as ColumnNodeSchema,
type ColumnNode as ColumnNodeType, type ColumnNode as ColumnNodeType,
type GroupMoveSnapArgs,
type HandleDescriptor, type HandleDescriptor,
type NodeDefinition, type NodeDefinition,
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildColumnFloorplan } from './floorplan' import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances' import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
import { columnFloorplanMoveTarget } from './floorplan-move' import { columnFloorplanMoveTarget } from './floorplan-move'
import { columnPaint } from './paint' import { columnPaint } from './paint'
@@ -295,6 +300,18 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
return handles return handles
} }
function resolveColumnStructuralGridMoveSnap({
candidatePosition,
nodes,
levelId,
}: GroupMoveSnapArgs): [number, number, number] | null {
const snap = resolveStructuralGridSnap(
[candidatePosition[0], candidatePosition[2]],
collectStructuralGridAxes(nodes, levelId),
)
return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null
}
/** /**
* Column — Stage A registration. Wrap-export of the legacy * Column — Stage A registration. Wrap-export of the legacy
* `ColumnRenderer` (no system — column geometry is computed inline in * `ColumnRenderer` (no system — column geometry is computed inline in
@@ -334,7 +351,11 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the // Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
// mode-driven snapping the overhaul standardised). 2D move keeps using // mode-driven snapping the overhaul standardised). 2D move keeps using
// `floorplanMoveTarget`, which wins the 2D move dispatch. // `floorplanMoveTarget`, which wins the 2D move dispatch.
movable: { axes: ['x', 'z'], gridSnap: true }, movable: {
axes: ['x', 'z'],
gridSnap: true,
groupMoveSnap: resolveColumnStructuralGridMoveSnap,
},
slots: (node) => columnSlots(node as ColumnNodeType), slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint, paint: columnPaint,
// Slab elevation lift via the generic `<FloorElevationSystem>` + the // Slab elevation lift via the generic `<FloorElevationSystem>` + the
@@ -374,6 +395,8 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
{ key: 'Left click', label: 'Place column' }, { key: 'Left click', label: 'Place column' },
{ key: 'Esc', label: 'Cancel' }, { key: 'Esc', label: 'Cancel' },
], ],
computeFloorplanLevelData: computeColumnFloorplanLevelData,
floorplanDependsOnSiblings: true,
floorplan: buildColumnFloorplan, floorplan: buildColumnFloorplan,
// 2D body move routes through this kind-specific target so the column // 2D body move routes through this kind-specific target so the column
// aligns by its footprint *edges* (and snaps flush to wall faces) instead // aligns by its footprint *edges* (and snaps flush to wall faces) instead
+11 -2
View File
@@ -20,6 +20,10 @@ import {
type WallPlanPoint, type WallPlanPoint,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
/** /**
* 2D floor-plan move handler for column. Columns need the same footprint-edge * 2D floor-plan move handler for column. Columns need the same footprint-edge
@@ -65,10 +69,15 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
candidates, candidates,
{ applySnap: isMagneticSnapActive() }, { applySnap: isMagneticSnapActive() },
) )
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(snapped, collectStructuralGridAxes(nodes, node.parentId))
: null
const coordinated = structuralSnap?.point ?? snapped
const next: [number, number, number] = [coordinated[0], originalPosition[1], coordinated[1]]
lastPosition = next lastPosition = next
const snapKey = `${snapped[0]},${snapped[1]}` const snapKey = `${coordinated[0]},${coordinated[1]}`
if (snapKey !== lastSnapKey) { if (snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey lastSnapKey = snapKey
@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import { ColumnNode, type GeometryContext, StructuralGridNode } from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
const context = {
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
} satisfies GeometryContext
describe('buildColumnFloorplan', () => {
test('marks the structural center of the column footprint', () => {
const column = ColumnNode.parse({
id: 'column_main',
parentId: 'level_main',
position: [2, 0, 3],
crossSection: 'square',
width: 0.4,
depth: 0.4,
})
const geometry = buildColumnFloorplan(column, context)
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
expect(geometry.children[0]?.kind).toBe('polygon')
expect(readFloorplanGeometryMetadata(geometry.children[0]!)).toMatchObject({
annotationObstacle: 'bounds',
})
expect(geometry.children.filter((child) => child.kind === 'line')).toEqual([
expect.objectContaining({
x1: 1.91,
y1: 2.91,
x2: 2.09,
y2: 3.09,
pointerEvents: 'none',
}),
expect.objectContaining({
x1: 1.91,
y1: 3.09,
x2: 2.09,
y2: 2.91,
pointerEvents: 'none',
}),
])
expect(
geometry.children
.filter((child) => child.kind === 'line')
.every((child) => readFloorplanGeometryMetadata(child).annotationRole === 'column-center'),
).toBe(true)
})
test('labels a column with its associative structural-grid reference', () => {
const column = ColumnNode.parse({
id: 'column_main',
parentId: 'level_main',
position: [2, 0, 3],
crossSection: 'square',
width: 0.4,
depth: 0.4,
})
const vertical = StructuralGridNode.parse({
id: 'structural-grid_2',
parentId: 'level_main',
start: [2, 0],
end: [2, 6],
label: '2',
})
const horizontal = StructuralGridNode.parse({
id: 'structural-grid_b',
parentId: 'level_main',
start: [0, 3],
end: [6, 3],
label: 'B',
})
const levelData = computeColumnFloorplanLevelData({
siblings: [column],
nodes: {
[column.id]: column,
[vertical.id]: vertical,
[horizontal.id]: horizontal,
},
})
const geometry = buildColumnFloorplan(column, { ...context, levelData })
expect(geometry?.kind).toBe('group')
if (geometry?.kind !== 'group') return
const label = geometry.children.find((child) => child.kind === 'text' && child.text === 'B-2')
expect(label).toMatchObject({ kind: 'text', text: 'B-2', upright: true })
expect(label && readFloorplanGeometryMetadata(label).annotationRole).toBe('column-center')
})
})
+83 -5
View File
@@ -1,9 +1,16 @@
import type { import type {
AnyNode,
ColumnNode, ColumnNode,
FloorplanGeometry, FloorplanGeometry,
FloorplanPoint, FloorplanPoint,
GeometryContext, GeometryContext,
StructuralGridNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { floorplanGeometryMetadata } from '@pascal-app/editor'
import {
collectStructuralGridAxes,
resolveStructuralGridReference,
} from '../structural-grid/coordination'
import type { ColumnResizePayload } from './floorplan-affordances' import type { ColumnResizePayload } from './floorplan-affordances'
// Offsets for the floor-plan selection arrows. Resize chevrons hug the // Offsets for the floor-plan selection arrows. Resize chevrons hug the
@@ -11,6 +18,8 @@ import type { ColumnResizePayload } from './floorplan-affordances'
// further out so it doesn't crowd the resize arrows. // further out so it doesn't crowd the resize arrows.
const RESIZE_ARROW_OFFSET = 0.12 const RESIZE_ARROW_OFFSET = 0.12
const ROTATE_ARROW_CORNER_OFFSET = 0.22 const ROTATE_ARROW_CORNER_OFFSET = 0.22
const GRID_REFERENCE_OFFSET = 0.16
const GRID_REFERENCE_FONT_SIZE = 0.13
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([ const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
'round', 'round',
@@ -18,6 +27,22 @@ const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
'sixteen-sided', 'sixteen-sided',
]) ])
export type ColumnFloorplanLevelData = {
structuralGrids: StructuralGridNode[]
}
export function computeColumnFloorplanLevelData({
siblings,
nodes,
}: {
siblings: readonly ColumnNode[]
nodes: Record<string, AnyNode>
}): ColumnFloorplanLevelData {
return {
structuralGrids: collectStructuralGridAxes(nodes, siblings[0]?.parentId),
}
}
/** /**
* Stage C floor-plan builder for column. Inlined from the legacy * Stage C floor-plan builder for column. Inlined from the legacy
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The * `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
@@ -34,8 +59,8 @@ export function buildColumnFloorplan(
node: ColumnNode, node: ColumnNode,
ctx: GeometryContext, ctx: GeometryContext,
): FloorplanGeometry | null { ): FloorplanGeometry | null {
const polygon = getColumnPlanFootprint(node) const points = getColumnFloorplanFootprint(node)
if (polygon.length < 3) return null if (points.length < 3) return null
const view = ctx.viewState const view = ctx.viewState
const palette = view?.palette const palette = view?.palette
@@ -46,8 +71,6 @@ export function buildColumnFloorplan(
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151' const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af' const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
const children: FloorplanGeometry[] = [ const children: FloorplanGeometry[] = [
{ {
kind: 'polygon', kind: 'polygon',
@@ -56,8 +79,60 @@ export function buildColumnFloorplan(
stroke, stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.02, strokeWidth: showSelectedChrome ? 0.03 : 0.02,
opacity: 0.92, opacity: 0.92,
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
}, },
] ]
const { halfX, halfZ } = columnPlanHalfExtents(node)
const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45))
const centerX = node.position[0]
const centerZ = node.position[2]
children.push(
{
kind: 'line',
x1: centerX - centerMarkHalf,
y1: centerZ - centerMarkHalf,
x2: centerX + centerMarkHalf,
y2: centerZ + centerMarkHalf,
stroke,
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
},
{
kind: 'line',
x1: centerX - centerMarkHalf,
y1: centerZ + centerMarkHalf,
x2: centerX + centerMarkHalf,
y2: centerZ - centerMarkHalf,
stroke,
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
pointerEvents: 'none',
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
},
)
const levelData = ctx.levelData as ColumnFloorplanLevelData | undefined
const gridReference = resolveStructuralGridReference(
[centerX, centerZ],
levelData?.structuralGrids ?? [],
)
if (gridReference) {
children.push({
kind: 'text',
x: centerX,
y: centerZ + halfZ + GRID_REFERENCE_OFFSET,
text: gridReference,
fontSize: GRID_REFERENCE_FONT_SIZE,
fill: stroke,
fontWeight: 700,
textAnchor: 'middle',
dominantBaseline: 'middle',
upright: true,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
})
}
// Hatch overlay on selected — same `<defs>` pattern as the wall. // Hatch overlay on selected — same `<defs>` pattern as the wall.
if (isSelected && palette) { if (isSelected && palette) {
@@ -146,7 +221,6 @@ export function buildColumnFloorplan(
// Rotate-arrow at the +X / +Z corner — matches the 3D // Rotate-arrow at the +X / +Z corner — matches the 3D
// `columnRotateHandle` corner placement so users see the rotation // `columnRotateHandle` corner placement so users see the rotation
// affordance in the same quadrant across views. // affordance in the same quadrant across views.
const { halfX, halfZ } = columnPlanHalfExtents(node)
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot) const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
@@ -163,6 +237,10 @@ export function buildColumnFloorplan(
return { kind: 'group', children } return { kind: 'group', children }
} }
export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] {
return getColumnPlanFootprint(node).map((point) => [point.x, point.y])
}
// ── Inlined helpers from legacy floorplan-panel.tsx ─────────────────── // ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
type PlanPoint = { x: number; y: number } type PlanPoint = { x: number; y: number }
+28 -3
View File
@@ -32,6 +32,10 @@ import {
stopPlacementCommitPropagation, stopPlacementCommitPropagation,
subscribeFloorPlacementClicks, subscribeFloorPlacementClicks,
} from '../shared/floor-placement' } from '../shared/floor-placement'
import {
collectStructuralGridAxes,
resolveStructuralGridSnap,
} from '../structural-grid/coordination'
import { ColumnPreview } from './renderer' import { ColumnPreview } from './renderer'
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
@@ -87,7 +91,7 @@ const ColumnTool = () => {
setCursorVisible(true) setCursorVisible(true)
} }
const { position, guides } = resolveAlignedFloorPlacement({ const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({
node: previewNode, node: previewNode,
rawX: event.localPosition[0], rawX: event.localPosition[0],
rawZ: event.localPosition[2], rawZ: event.localPosition[2],
@@ -97,7 +101,18 @@ const ColumnTool = () => {
applyAlignmentSnap: isMagneticSnapActive(), applyAlignmentSnap: isMagneticSnapActive(),
bypassGrid: !isGridSnapActive(), bypassGrid: !isGridSnapActive(),
}) })
useAlignmentGuides.getState().set(guides) const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(
[alignedPosition[0], alignedPosition[2]],
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
)
: null
const position: [number, number, number] = structuralSnap
? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]]
: alignedPosition
if (structuralSnap) useAlignmentGuides.getState().clear()
else useAlignmentGuides.getState().set(guides)
const visualPosition = getFloorStackPreviewPosition({ const visualPosition = getFloorStackPreviewPosition({
node: previewNode, node: previewNode,
@@ -134,7 +149,7 @@ const ColumnTool = () => {
} }
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
const position = const fallbackPosition =
lastCursorRef.current ?? lastCursorRef.current ??
getLevelLocalSnappedPosition( getLevelLocalSnappedPosition(
activeLevelId, activeLevelId,
@@ -142,6 +157,16 @@ const ColumnTool = () => {
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
!isGridSnapActive(), !isGridSnapActive(),
) )
const structuralSnap =
isGridSnapActive() || isMagneticSnapActive()
? resolveStructuralGridSnap(
[fallbackPosition[0], fallbackPosition[2]],
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
)
: null
const position: [number, number, number] = structuralSnap
? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]]
: fallbackPosition
const column = ColumnNode.parse({ const column = ColumnNode.parse({
...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position),
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { constructionDimensionDefinition } from './definition'
describe('constructionDimensionDefinition', () => {
test('registers a selectable floor-plan construction annotation', () => {
expect(constructionDimensionDefinition.kind).toBe('construction-dimension')
expect(constructionDimensionDefinition.category).toBe('analysis')
expect(constructionDimensionDefinition.bake).toBe('strip')
expect(constructionDimensionDefinition.schemaVersion).toBe(7)
expect(constructionDimensionDefinition.dirtyTracking).toBe(false)
expect(constructionDimensionDefinition.capabilities).toMatchObject({
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
})
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
'move-construction-dimension-baseline',
)
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
'move-construction-dimension-witness',
)
})
test('produces schema-valid defaults', () => {
expect(
constructionDimensionDefinition.schema.safeParse({
id: 'construction-dimension_default',
type: 'construction-dimension',
...constructionDimensionDefinition.defaults(),
}).success,
).toBe(true)
})
})
@@ -0,0 +1,91 @@
import { measurementAnchorReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
import { buildConstructionDimensionFloorplan } from './floorplan'
import {
moveConstructionDimensionBaselineAffordance,
moveConstructionDimensionWitnessAffordance,
} from './floorplan-affordances'
import { constructionDimensionParametrics } from './parametrics'
import { ConstructionDimensionNode } from './schema'
export const constructionDimensionDefinition: NodeDefinition<typeof ConstructionDimensionNode> = {
kind: 'construction-dimension',
bake: 'strip',
schemaVersion: 7,
schema: ConstructionDimensionNode,
category: 'analysis',
extensions: {
'pascal:editor/floorplan': {
tool: () => import('./floorplan-tool'),
resolveForDrawing: resolveConstructionDimensionForDrawing,
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
},
snapProfile: 'item',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 0.6], direction: [1, 0] },
chainMode: 'point-to-point',
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
},
dirtyTracking: false,
parametrics: constructionDimensionParametrics,
floorplan: buildConstructionDimensionFloorplan,
floorplanDependencies: (node) => [
...measurementAnchorReferenceNodeIds(node.anchors),
...(node.controllingDimensionId ? [node.controllingDimensionId] : []),
],
floorplanAffordances: {
'move-construction-dimension-baseline': moveConstructionDimensionBaselineAffordance,
'move-construction-dimension-witness': moveConstructionDimensionWitnessAffordance,
},
toolHints: [
{ key: 'Left click', label: 'Pick witness point' },
{ key: 'Enter', label: 'Finish multi-point witnesses' },
{ key: 'Left click', label: 'Place dimension line when needed' },
{ key: 'Backspace', label: 'Remove last witness' },
{ key: 'Esc', label: 'Step back or cancel' },
],
presentation: {
label: 'Construction Dimension',
description: 'Associative linear, curved, circular, angular, or coordinate plan dimension.',
icon: { kind: 'iconify', name: 'lucide:ruler-dimension-line' },
hidden: true,
actionMenu: false,
},
mcp: {
description:
'An associative construction dimension with linear, curved, circular, angular, and coordinate modes, semantic witness anchors, document notation overrides, and coordinated plan-view presentation.',
},
}
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, ConstructionDimensionNode } from '@pascal-app/core'
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
const foundation = ConstructionDimensionNode.parse({
id: 'construction-dimension_foundation',
drawingType: 'foundation-plan',
anchors: [
[0, 0, 0],
[6, 0, 0],
],
baseline: { origin: [0, 2], direction: [1, 0] },
})
const resolve = (
node: ConstructionDimensionNode,
nodes: Record<string, AnyNode>,
drawingType: 'floor-plan' | 'foundation-plan',
) => resolveConstructionDimensionForDrawing({ node, nodes, drawingType })
describe('resolveConstructionDimensionForDrawing', () => {
test('omits a dimension outside its primary drawing by default', () => {
expect(resolve(foundation, { [foundation.id]: foundation }, 'floor-plan')).toBeNull()
expect(resolve(foundation, { [foundation.id]: foundation }, 'foundation-plan')).toBe(foundation)
})
test('applies view-specific suppressed segments without changing physical anchors', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
drawingOverrides: [
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1],
},
],
})
const resolved = resolve(node, { [node.id]: node }, 'floor-plan')
expect(resolved).toMatchObject({
id: node.id,
anchors: node.anchors,
metadata: { suppressedDimensionSegmentIndexes: [1] },
})
expect(node.metadata).toEqual({})
})
test('derives linked floor-plan geometry from a controlling foundation dimension', () => {
const floor = ConstructionDimensionNode.parse({
id: 'construction-dimension_floor',
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
controllingDimensionId: foundation.id,
anchors: [
[1, 0, 1],
[2, 0, 1],
],
})
const nodes = { [floor.id]: floor, [foundation.id]: foundation } as Record<string, AnyNode>
const resolved = resolve(floor, nodes, 'floor-plan')
expect(resolved).toMatchObject({
id: floor.id,
anchors: foundation.anchors,
baseline: foundation.baseline,
metadata: { drawingCoordinationLocked: true },
})
})
test('marks a missing foundation controller as unlinked', () => {
const floor = ConstructionDimensionNode.parse({
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
controllingDimensionId: 'construction-dimension_missing',
prefix: 'TYP · ',
})
expect(resolve(floor, { [floor.id]: floor }, 'floor-plan')).toMatchObject({
prefix: 'UNLINKED CONTROL · TYP · ',
})
})
})
@@ -0,0 +1,76 @@
import {
type AnyNode,
type ConstructionDimensionNode,
type ConstructionDrawingType,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
} from '@pascal-app/core'
export function resolveConstructionDimensionForDrawing(args: {
node: ConstructionDimensionNode
nodes: Record<string, AnyNode>
drawingType: ConstructionDrawingType
}): ConstructionDimensionNode | null {
const { node, nodes, drawingType } = args
const presentation = resolveConstructionDimensionDrawingPresentation(node, drawingType)
if (presentation === 'omit') return null
if (presentation === 'shown') return applyDrawingOverride(node, drawingType)
const controller = node.controllingDimensionId ? nodes[node.controllingDimensionId] : undefined
if (
controller?.type !== 'construction-dimension' ||
controller.id === node.id ||
controller.drawingType !== 'foundation-plan'
) {
return {
...node,
metadata: lockedMetadata(node),
prefix: `UNLINKED CONTROL · ${node.prefix}`,
}
}
return resolveControlledDimension(node, controller)
}
function resolveControlledDimension(
node: ConstructionDimensionNode,
controller: ConstructionDimensionNode,
): ConstructionDimensionNode {
const overridden = applyDrawingOverride(node, 'floor-plan')
return {
...overridden,
metadata: lockedMetadata(overridden),
anchors: controller.anchors,
baseline: controller.baseline,
chainMode: controller.chainMode,
mode: controller.mode,
showCenterMark: controller.showCenterMark,
}
}
function applyDrawingOverride(
node: ConstructionDimensionNode,
drawingType: ConstructionDrawingType,
): ConstructionDimensionNode {
const override = resolveConstructionDimensionDrawingOverride(node, drawingType)
if (!override || override.suppressedSegmentIndexes.length === 0) return node
return {
...node,
metadata: {
...(typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata)
? node.metadata
: {}),
suppressedDimensionSegmentIndexes: override.suppressedSegmentIndexes,
},
}
}
function lockedMetadata(node: ConstructionDimensionNode): ConstructionDimensionNode['metadata'] {
const metadata =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? node.metadata
: {}
return { ...metadata, drawingCoordinationLocked: true }
}
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
ConstructionDimensionNode,
nodeRegistry,
registerNode,
useLiveNodeOverrides,
useScene,
WallNode,
} from '@pascal-app/core'
import { wallDefinition } from '../wall/definition'
import { moveConstructionDimensionWitnessAffordance } from './floorplan-affordances'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
function seedScene() {
const levelId = 'level_construction-dimension-affordance' as AnyNodeId
const wall = WallNode.parse({
id: 'wall_dimension-target',
start: [0, 0],
end: [4, 0],
parentId: levelId,
})
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_drag-witness',
parentId: levelId,
anchors: [
{
kind: 'feature',
reference: {
nodeId: wall.id,
featureId: 'wall:centerline',
parameters: { t: 0.25 },
},
fallback: [1, 0, 0],
},
[4, 0, 0],
],
})
const level = {
id: levelId,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
level: 0,
parentId: null,
children: [wall.id, dimension.id],
} as unknown as AnyNode
const nodes = { [levelId]: level, [wall.id]: wall, [dimension.id]: dimension } as Record<
AnyNodeId,
AnyNode
>
useScene.setState({ nodes: nodes as never })
return { dimension, nodes, wall }
}
describe('moveConstructionDimensionWitnessAffordance', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(wallDefinition)
useLiveNodeOverrides.getState().clearAll()
})
afterEach(() => {
useLiveNodeOverrides.getState().clearAll()
nodeRegistry._reset()
})
test('reassociates a dragged witness to a nearby semantic wall feature', () => {
const { dimension, nodes, wall } = seedScene()
const session = moveConstructionDimensionWitnessAffordance.start({
node: dimension,
payload: { witnessIndex: 0 },
nodes,
initialPlanPoint: [1, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [3, 0.04], modifiers: MODIFIERS })
expect(session.canCommit()).toBe(true)
session.commit?.()
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
const anchor = updated.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(wall.id)
expect(anchor.reference.featureId).toMatch(/^wall:/)
expect(anchor.fallback[0]).toBeCloseTo(3)
}
})
test('detaches a dragged witness as an explicit free point when Alt bypasses association', () => {
const { dimension, nodes } = seedScene()
const session = moveConstructionDimensionWitnessAffordance.start({
node: dimension,
payload: { witnessIndex: 0 },
nodes,
initialPlanPoint: [1, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [3, 2], modifiers: { ...MODIFIERS, altKey: true } })
expect(session.canCommit()).toBe(true)
session.commit?.()
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
expect(updated.anchors[0]).toEqual([3, 0, 2])
})
})
@@ -0,0 +1,154 @@
import {
ConstructionDimensionNode,
type ConstructionDimensionNode as ConstructionDimensionNodeType,
type FloorplanAffordance,
type FloorplanAffordanceSession,
type MeasurementAnchor,
type MeasurementPoint,
resolveLevelId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
isGridSnapActive,
isMagneticSnapActive,
resolveSurfacePlanPointSnap,
useEditor,
} from '@pascal-app/editor'
import { matchMeasurementFeatureForNode, resolveMeasurementAnchor } from '../measurement/resolve'
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
function semanticWitnessAnchor(
point: MeasurementPoint,
wallIds: readonly string[],
nodes: Parameters<typeof resolveLevelId>[1],
maxDistance: number,
): MeasurementAnchor {
const matches = wallIds.flatMap((id) => {
const node = nodes[id]
if (!node) return []
const match = matchMeasurementFeatureForNode(
node,
(nodeId) => nodes[nodeId],
point,
maxDistance,
)
return match ? [{ match, node }] : []
})
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
if (!closest) return point
return {
kind: 'feature',
reference: {
nodeId: closest.node.id,
featureId: closest.match.feature.id,
parameters: closest.match.parameters,
},
fallback: closest.match.point,
}
}
function withRefreshedFallbacks(
node: ConstructionDimensionNodeType,
nodes: Parameters<typeof resolveLevelId>[1],
): ConstructionDimensionNodeType['anchors'] {
return node.anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const resolved = resolveMeasurementAnchor(anchor, (id) => nodes[id])
return { ...anchor, fallback: resolved.point }
})
}
export const moveConstructionDimensionWitnessAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
{
start({ node, nodes, payload }): FloorplanAffordanceSession {
const witnessIndex = (payload as { witnessIndex?: unknown }).witnessIndex
const originalAnchors = withRefreshedFallbacks(node, nodes)
const levelId = resolveLevelId(node, nodes)
let latest: ConstructionDimensionNodeType['anchors'] | null = null
if (!Number.isInteger(witnessIndex)) {
return {
affectedIds: [node.id],
apply() {},
canCommit: () => false,
}
}
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const forceFree = modifiers.altKey === true
const gridStep = !forceFree && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const fallbackPoint: [number, number] =
gridStep > 0
? [
Math.round(planPoint[0] / gridStep) * gridStep,
Math.round(planPoint[1] / gridStep) * gridStep,
]
: [planPoint[0], planPoint[1]]
const magnetic = !forceFree && isMagneticSnapActive()
const snapped = resolveSurfacePlanPointSnap({
rawPoint: [planPoint[0], planPoint[1]],
fallbackPoint,
excludeId: node.id,
levelId,
movingId: node.id,
nodes,
magnetic,
})
const point: MeasurementPoint = [snapped.point[0], 0, snapped.point[1]]
const nextAnchor = semanticWitnessAnchor(
point,
snapped.wallIds,
nodes,
magnetic ? SEMANTIC_FEATURE_SNAP_DISTANCE : SEMANTIC_FEATURE_BYPASS_DISTANCE,
)
const anchors = originalAnchors.map((anchor, index) =>
index === witnessIndex ? nextAnchor : anchor,
)
if (!ConstructionDimensionNode.safeParse({ ...node, anchors }).success) {
latest = null
useLiveNodeOverrides.getState().clear(node.id)
return
}
latest = anchors
useLiveNodeOverrides.getState().set(node.id, { anchors })
},
canCommit: () => latest !== null,
commit() {
const anchors = latest
useLiveNodeOverrides.getState().clear(node.id)
if (anchors) useScene.getState().updateNode(node.id, { anchors })
},
}
},
}
export const moveConstructionDimensionBaselineAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
{
start({ node }) {
let latest: [number, number] | null = null
return {
affectedIds: [node.id],
apply({ planPoint }) {
const origin: [number, number] = [planPoint[0], planPoint[1]]
const baseline = { ...node.baseline, origin }
if (!ConstructionDimensionNode.safeParse({ ...node, baseline }).success) return
latest = origin
useLiveNodeOverrides.getState().set(node.id, { baseline })
},
canCommit: () => latest !== null,
commit() {
useLiveNodeOverrides.getState().clear(node.id)
if (latest) {
useScene.getState().updateNode(node.id, {
baseline: { ...node.baseline, origin: latest },
})
}
},
}
},
}
@@ -0,0 +1,180 @@
import { describe, expect, test } from 'bun:test'
import { type MeasurementPoint, WallNode } from '@pascal-app/core'
import {
buildConstructionDimensionPreviewGeometries,
buildCurvedWallConstructionDimensionDraft,
constructionDimensionUsesBaseline,
normalizeConstructionDimensionChainMode,
normalizeConstructionDimensionMode,
resolveConstructionDimensionDraftDirection,
} from './floorplan-tool'
describe('continuous construction-dimension drafting', () => {
test('derives a stable baseline direction from the first witness pair', () => {
expect(
resolveConstructionDimensionDraftDirection([
[1, 0, 2],
[4, 0, 6],
[8, 0, 7],
]),
).toEqual([0.6, 0.8])
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
})
test('previews one adjacent dimension for every witness interval', () => {
const geometry = buildConstructionDimensionPreviewGeometries(
[
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
[0, 0, 2],
'metric',
)
expect(geometry).toHaveLength(3)
expect(geometry.map((segment) => segment.text)).toEqual(['2m', '3m', '4m'])
expect(geometry[1]).toMatchObject({
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 2],
dimensionEnd: [5, 2],
})
})
test('normalizes unknown tool defaults to the point-to-point workflow', () => {
expect(normalizeConstructionDimensionChainMode('continuous')).toBe('continuous')
expect(normalizeConstructionDimensionChainMode('unknown')).toBe('point-to-point')
})
test('normalizes curved and circular construction-dimension modes', () => {
expect(normalizeConstructionDimensionMode('radius')).toBe('radius')
expect(normalizeConstructionDimensionMode('arc-length')).toBe('arc-length')
expect(normalizeConstructionDimensionMode('unknown')).toBe('linear')
})
test('previews radius and diameter notation before commit', () => {
const points: MeasurementPoint[] = [
[0, 0, 0],
[2, 0, 0],
]
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
).toMatchObject({ text: 'R 2m' })
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
).toMatchObject({ text: 'Ø 2m' })
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'angular'),
).toEqual([])
})
test('previews the arc value leader while placing the fourth point', () => {
const preview = buildConstructionDimensionPreviewGeometries(
[
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
[3, 0, 3],
'metric',
'arc-length',
)
expect(preview).toHaveLength(1)
expect(preview[0]).toMatchObject({
kind: 'group',
children: expect.arrayContaining([
expect.objectContaining({ kind: 'path' }),
expect.objectContaining({ kind: 'line', x2: 3, y2: 3 }),
expect.objectContaining({ kind: 'dimension-label', text: 'ARC 3.14m' }),
]),
})
})
test('previews the angular arc and value while placing the fourth point', () => {
const preview = buildConstructionDimensionPreviewGeometries(
[
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
[1.5, 0, 0.5],
'metric',
'angular',
)
expect(preview).toHaveLength(1)
expect(preview[0]).toMatchObject({
kind: 'group',
children: expect.arrayContaining([
expect.objectContaining({ kind: 'path' }),
expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }),
expect.objectContaining({
kind: 'dimension-label',
cx: 1.5,
cy: 0.5,
text: '∠ 90°',
}),
]),
})
})
test('only requests a label baseline for modes that use one', () => {
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
})
test('derives associative radius, chord, and center drafts from one curved wall', () => {
const wall = WallNode.parse({
id: 'wall_curve',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
anchors: [
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
],
points: [
[2, 0, 1.5],
[2, 0, -1],
],
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
{ reference: { featureId: 'wall:start' } },
{ reference: { featureId: 'wall:end' } },
])
expect(buildCurvedWallConstructionDimensionDraft(wall, 'center-mark')?.anchors).toHaveLength(2)
})
test('keeps arc length in the manual start-center-end and baseline workflow', () => {
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(curved, 'arc-length')).toBeNull()
expect(constructionDimensionUsesBaseline('arc-length')).toBe(true)
})
test('keeps angular dimensions in the manual ray-center-ray and baseline workflow', () => {
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(curved, 'angular')).toBeNull()
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
})
test('keeps manual point drafting for straight walls and unsupported modes', () => {
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
expect(buildCurvedWallConstructionDimensionDraft(straight, 'radius')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
})
})
@@ -0,0 +1,726 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ConstructionDimensionChainMode,
type ConstructionDimensionMode,
ConstructionDimensionNode,
closestMeasurementFeatureBinding,
constructionDimensionRequiredAnchorCount,
type FloorplanGeometry,
type GeometryContext,
getWallArcData,
getWallCurveFrameAt,
type MeasurementAnchor,
type MeasurementFeatureAnchor,
type MeasurementPoint,
nodeRegistry,
type WallNode,
} from '@pascal-app/core'
import {
buildSvgArcPath,
clearSurfacePlanSnapFeedback,
FloorplanGeometryRenderer,
type FloorplanToolContext,
formatLinearMeasurement,
getArcPlanPoint,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
resolveSurfacePlanPointSnap,
triggerSFX,
useDrawingView,
useFloorplanRender,
useInteractionScope,
} from '@pascal-app/editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { resolveCircularConstructionDimensionLayout } from './geometry'
const SEMANTIC_SNAP_DISTANCE = 0.2
const SEMANTIC_BYPASS_DISTANCE = 0.012
const MIN_DIMENSION_LENGTH = 0.001
const MIN_ARC_SWEEP = 1e-6
type Draft = {
anchors: MeasurementAnchor[]
points: MeasurementPoint[]
stage: 'witnesses' | 'baseline'
}
type AssociatedPoint = {
anchor: MeasurementAnchor
point: MeasurementPoint
semantic: boolean
targetNodeId: string | null
}
const emptyDraft = (): Draft => ({ anchors: [], points: [], stage: 'witnesses' })
function geometryContext(node: AnyNode, nodes: Record<AnyNodeId, AnyNode>): GeometryContext {
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
nodes[id] as N | undefined
const childIds =
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
const children = childIds
.map((id) => nodes[id])
.filter((child): child is AnyNode => child !== undefined)
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
const siblings =
parent && 'children' in parent && Array.isArray(parent.children)
? (parent.children as AnyNodeId[])
.map((id) => nodes[id])
.filter(
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
)
: []
return { resolve, children, parent, siblings }
}
function associatePoint(
point: MeasurementPoint,
targetNodeId: string | null,
maxDistance: number,
nodes: Record<AnyNodeId, AnyNode>,
): AssociatedPoint {
if (!targetNodeId) return { anchor: point, point, semantic: false, targetNodeId: null }
const node = nodes[targetNodeId as AnyNodeId]
const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
if (!(node && contribution)) return { anchor: point, point, semantic: false, targetNodeId }
const context = geometryContext(node, nodes)
const features = contribution.features(node, context)
const match =
contribution.match?.(node, context, point, maxDistance) ??
closestMeasurementFeatureBinding(features, point, maxDistance)
if (!match) return { anchor: point, point, semantic: false, targetNodeId }
const reference = {
nodeId: node.id,
featureId: match.featureId,
parameters: match.parameters,
}
const anchor: MeasurementFeatureAnchor = {
kind: 'feature',
reference,
fallback: match.point,
}
return { anchor, point: match.point, semantic: true, targetNodeId }
}
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number) {
const matrix = group.getScreenCTM()
if (!matrix) return null
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
return [local.x, 0, local.y] satisfies MeasurementPoint
}
function registryTargetNodeId(target: EventTarget | null): string | null {
if (!(target instanceof Element)) return null
return (
target.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')?.dataset.nodeId ?? null
)
}
export function resolveConstructionDimensionDraftDirection(
points: readonly MeasurementPoint[],
): [number, number] | null {
if (points.length < 2) return null
const dx = points[1]![0] - points[0]![0]
const dz = points[1]![2] - points[0]![2]
const magnitude = Math.hypot(dx, dz)
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
}
export function buildConstructionDimensionPreviewGeometries(
points: readonly MeasurementPoint[],
baselinePoint: MeasurementPoint,
unit: 'metric' | 'imperial',
mode: ConstructionDimensionMode = 'linear',
metricNotation: 'meters' | 'millimeters' = 'meters',
): FloorplanGeometry[] {
if (mode === 'arc-length' || mode === 'angular') {
const layout = resolveCircularConstructionDimensionLayout(mode, points)
if (!(layout?.end && Math.abs(layout.sweep) > MIN_ARC_SWEEP)) return []
const center = { x: layout.center[0], y: layout.center[1] }
const end = getArcPlanPoint(center, layout.radius, layout.endAngle)
const arcMid = getArcPlanPoint(center, layout.radius, layout.startAngle + layout.sweep / 2)
const stroke = '#06b6d4'
const lineStyle = {
fill: 'none',
pointerEvents: 'none' as const,
stroke,
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke' as const,
}
if (mode === 'angular') {
const endRadius = Math.hypot(layout.end[0] - center.x, layout.end[1] - center.y)
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
const requestedRadius = Math.hypot(baselinePoint[0] - center.x, baselinePoint[2] - center.y)
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = getArcPlanPoint(center, arcRadius, midAngle)
const startRayEnd = getArcPlanPoint(
center,
Math.max(layout.radius, arcRadius + 0.12),
layout.startAngle,
)
const endRayEnd = getArcPlanPoint(
center,
Math.max(endRadius, arcRadius + 0.12),
layout.endAngle,
)
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
const formattedDegrees = Number.parseFloat(degrees.toFixed(degrees < 10 ? 1 : 0))
return [
{
kind: 'group',
children: [
{
kind: 'line',
x1: center.x,
y1: center.y,
x2: startRayEnd.x,
y2: startRayEnd.y,
...lineStyle,
},
{
kind: 'line',
x1: center.x,
y1: center.y,
x2: endRayEnd.x,
y2: endRayEnd.y,
...lineStyle,
},
{
kind: 'path',
d: buildSvgArcPath(
center,
arcRadius,
layout.startAngle,
layout.startAngle + layout.sweep,
),
...lineStyle,
},
{
kind: 'line',
x1: arcMid.x,
y1: arcMid.y,
x2: baselinePoint[0],
y2: baselinePoint[2],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'dimension-label',
cx: baselinePoint[0],
cy: baselinePoint[2],
text: `${formattedDegrees}°`,
angle: 0,
screenUpright: true,
appearance: 'outlined',
},
],
},
]
}
return [
{
kind: 'group',
children: [
{
kind: 'path',
d: buildSvgArcPath(
center,
layout.radius,
layout.startAngle,
layout.startAngle + layout.sweep,
),
...lineStyle,
},
{
kind: 'line',
x1: layout.center[0],
y1: layout.center[1],
x2: layout.start[0],
y2: layout.start[1],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'line',
x1: layout.center[0],
y1: layout.center[1],
x2: end.x,
y2: end.y,
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'line',
x1: arcMid.x,
y1: arcMid.y,
x2: baselinePoint[0],
y2: baselinePoint[2],
strokeDasharray: '6 5',
...lineStyle,
},
{
kind: 'dimension-label',
cx: baselinePoint[0],
cy: baselinePoint[2],
text: `ARC ${formatLinearMeasurement(layout.arcLength, unit, metricNotation)}`,
angle: 0,
screenUpright: true,
appearance: 'outlined',
},
],
},
]
}
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
const direction = resolveConstructionDimensionDraftDirection(points)
if (!direction) return []
const normal: [number, number] = [-direction[1], direction[0]]
const project = (point: MeasurementPoint): [number, number] => {
const along =
(point[0] - baselinePoint[0]) * direction[0] + (point[2] - baselinePoint[2]) * direction[1]
return [baselinePoint[0] + along * direction[0], baselinePoint[2] + along * direction[1]]
}
const dimensionPoints = points.map(project)
return points.slice(0, -1).map((start, index) => {
const end = points[index + 1]!
const dx = end[0] - start[0]
const dz = end[2] - start[2]
const value = Math.abs(dx * direction[0] + dz * direction[1])
const rawText = formatLinearMeasurement(value, unit, metricNotation)
const text =
mode === 'radius'
? `R ${rawText}`
: mode === 'diameter'
? `Ø ${rawText}`
: mode === 'chord'
? `CH ${rawText}`
: rawText
return {
kind: 'dimension',
start: [start[0], start[2]],
end: [end[0], end[2]],
dimensionStart: dimensionPoints[index]!,
dimensionEnd: dimensionPoints[index + 1]!,
offsetNormal: normal,
offsetDistance: 0,
extensionOvershoot: 0.12,
text,
stroke: '#06b6d4',
}
})
}
export function normalizeConstructionDimensionChainMode(
value: unknown,
): ConstructionDimensionChainMode {
return value === 'continuous' ? 'continuous' : 'point-to-point'
}
export function normalizeConstructionDimensionMode(value: unknown): ConstructionDimensionMode {
return [
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
].includes(value as string)
? (value as ConstructionDimensionMode)
: 'linear'
}
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
}
function wallFeatureAnchor(
wall: WallNode,
featureId: string,
fallback: MeasurementPoint,
): MeasurementFeatureAnchor {
return {
kind: 'feature',
reference: { nodeId: wall.id, featureId },
fallback,
}
}
export function buildCurvedWallConstructionDimensionDraft(
wall: WallNode,
mode: ConstructionDimensionMode,
): Pick<Draft, 'anchors' | 'points'> | null {
const arc = getWallArcData(wall)
if (!arc) return null
const center: MeasurementPoint = [arc.center.x, 0, arc.center.y]
const start: MeasurementPoint = [wall.start[0], 0, wall.start[1]]
const end: MeasurementPoint = [wall.end[0], 0, wall.end[1]]
const midpointFrame = getWallCurveFrameAt(wall, 0.5)
const midpoint: MeasurementPoint = [midpointFrame.point.x, 0, midpointFrame.point.y]
const feature = (featureId: string, fallback: MeasurementPoint) =>
wallFeatureAnchor(wall, featureId, fallback)
switch (mode) {
case 'radius':
case 'center-mark':
return {
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
points: [center, midpoint],
}
case 'chord':
return {
anchors: [feature('wall:start', start), feature('wall:end', end)],
points: [start, end],
}
case 'arc-length':
case 'angular':
return null
default:
return null
}
}
export function FloorplanConstructionDimensionToolLayer({
activeLevelId,
finishTool,
gridSnapStep,
metricNotation,
sceneApi,
selectNode,
toolDefaults,
unit,
}: FloorplanToolContext) {
const groupRef = useRef<SVGGElement>(null)
const draftRef = useRef<Draft>(emptyDraft())
const [draft, setDraft] = useState<Draft>(draftRef.current)
const [hover, setHover] = useState<AssociatedPoint | null>(null)
const chainMode = normalizeConstructionDimensionChainMode(toolDefaults?.chainMode)
const dimensionMode = normalizeConstructionDimensionMode(toolDefaults?.mode)
const collectsMany =
dimensionMode === 'coordinate' || (dimensionMode === 'linear' && chainMode === 'continuous')
const usesBaseline = constructionDimensionUsesBaseline(dimensionMode)
const renderContext = useFloorplanRender()
const drawingType = useDrawingView((state) => state.drawingType)
useEffect(() => {
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'construction-dimension' })
return () =>
useInteractionScope
.getState()
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'construction-dimension')
}, [])
const updateDraft = useCallback((next: Draft) => {
draftRef.current = next
setDraft(next)
}, [])
useEffect(() => {
updateDraft(emptyDraft())
setHover(null)
const group = groupRef.current
const svg = group?.ownerSVGElement
if (!(activeLevelId && group && svg)) return
const consume = (event: Event) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
const resolveEvent = (event: MouseEvent | PointerEvent): AssociatedPoint | null => {
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
if (!raw) return null
const forceFree = event.altKey
const gridStep = !forceFree && isGridSnapActive() ? gridSnapStep : 0
const fallbackPoint: [number, number] =
gridStep > 0
? [Math.round(raw[0] / gridStep) * gridStep, Math.round(raw[2] / gridStep) * gridStep]
: [raw[0], raw[2]]
const magnetic = !forceFree && isMagneticSnapActive()
const surface = resolveSurfacePlanPointSnap({
rawPoint: [raw[0], raw[2]],
fallbackPoint,
levelId: activeLevelId,
align: false,
magnetic,
})
const point: MeasurementPoint = [surface.point[0], 0, surface.point[1]]
const targetNodeId = surface.wallIds[0] ?? registryTargetNodeId(event.target)
return associatePoint(
point,
targetNodeId,
magnetic ? SEMANTIC_SNAP_DISTANCE : SEMANTIC_BYPASS_DISTANCE,
sceneApi.nodes(),
)
}
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
const direction = resolveConstructionDimensionDraftDirection(current.points)
const originPoint = baselinePoint ?? current.points.at(-1)
if (!(direction && originPoint)) return false
const node = ConstructionDimensionNode.parse({
name:
dimensionMode === 'linear' && chainMode === 'continuous'
? 'Continuous Dimension'
: `${dimensionMode.replaceAll('-', ' ')} Dimension`,
anchors: current.anchors,
baseline: {
origin: [originPoint[0], originPoint[2]],
direction,
},
chainMode,
mode: dimensionMode,
drawingType,
})
sceneApi.upsert(node, activeLevelId)
selectNode(node.id)
triggerSFX('sfx:structure-build')
finishTool()
updateDraft(emptyDraft())
setHover(null)
return true
}
const finishWitnesses = () => {
const current = draftRef.current
const required = constructionDimensionRequiredAnchorCount(dimensionMode)
if (current.stage !== 'witnesses' || current.points.length < required) return false
if (!usesBaseline) return commitDraft(current)
updateDraft({ ...current, stage: 'baseline' })
triggerSFX('sfx:grid-snap')
return true
}
const removeLastWitness = () => {
const current = draftRef.current
if (current.points.length === 0) return false
updateDraft({
anchors: current.anchors.slice(0, -1),
points: current.points.slice(0, -1),
stage: 'witnesses',
})
return true
}
const commitAt = (associated: AssociatedPoint) => {
const current = draftRef.current
if (current.stage === 'baseline') commitDraft(current, associated.point)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button === 0) consume(event)
}
const onPointerMove = (event: PointerEvent) => {
consume(event)
setHover(resolveEvent(event))
}
const onPointerLeave = () => {
clearSurfacePlanSnapFeedback()
setHover(null)
}
const onClick = (event: MouseEvent) => {
if (event.button !== 0) return
consume(event)
if (event.detail > 1) return
const associated = resolveEvent(event)
if (!associated) return
const current = draftRef.current
if (current.stage === 'baseline') {
commitAt(associated)
return
}
const targetNode = associated.targetNodeId
? sceneApi.get(associated.targetNodeId as AnyNodeId)
: undefined
const curvedWallDraft =
current.points.length === 0 && targetNode?.type === 'wall'
? buildCurvedWallConstructionDimensionDraft(targetNode, dimensionMode)
: null
if (curvedWallDraft) {
const next: Draft = { ...curvedWallDraft, stage: 'witnesses' }
updateDraft(next)
triggerSFX('sfx:grid-snap')
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
else commitDraft(next)
return
}
const previous = current.points.at(-1)
if (
previous &&
Math.hypot(associated.point[0] - previous[0], associated.point[2] - previous[2]) <=
MIN_DIMENSION_LENGTH
) {
return
}
const next: Draft = {
anchors: [...current.anchors, associated.anchor],
points: [...current.points, associated.point],
stage: 'witnesses',
}
updateDraft(next)
triggerSFX('sfx:grid-snap')
if (
!collectsMany &&
next.points.length === constructionDimensionRequiredAnchorCount(dimensionMode)
) {
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
else commitDraft(next)
}
}
const onDoubleClick = (event: MouseEvent) => {
if (event.button !== 0 || !collectsMany) return
consume(event)
finishWitnesses()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && collectsMany) {
if (!finishWitnesses()) return
event.preventDefault()
event.stopImmediatePropagation()
return
}
if (event.key === 'Backspace') {
if (!removeLastWitness()) return
event.preventDefault()
event.stopImmediatePropagation()
markToolCancelConsumed()
return
}
if (event.key !== 'Escape') return
event.preventDefault()
event.stopImmediatePropagation()
markToolCancelConsumed()
const current = draftRef.current
if (current.stage === 'baseline') {
updateDraft({ ...current, stage: 'witnesses' })
return
}
if (removeLastWitness()) return
finishTool()
}
const onBlur = () => clearSurfacePlanSnapFeedback()
svg.addEventListener('pointerdown', onPointerDown, true)
svg.addEventListener('pointermove', onPointerMove, true)
svg.addEventListener('pointerleave', onPointerLeave, true)
svg.addEventListener('click', onClick, true)
svg.addEventListener('dblclick', onDoubleClick, true)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('blur', onBlur)
return () => {
clearSurfacePlanSnapFeedback()
svg.removeEventListener('pointerdown', onPointerDown, true)
svg.removeEventListener('pointermove', onPointerMove, true)
svg.removeEventListener('pointerleave', onPointerLeave, true)
svg.removeEventListener('click', onClick, true)
svg.removeEventListener('dblclick', onDoubleClick, true)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('blur', onBlur)
}
}, [
activeLevelId,
chainMode,
collectsMany,
dimensionMode,
drawingType,
finishTool,
gridSnapStep,
sceneApi,
selectNode,
updateDraft,
usesBaseline,
])
const preview = useMemo(
() =>
draft.stage === 'baseline' && hover
? buildConstructionDimensionPreviewGeometries(
draft.points,
hover.point,
unit,
dimensionMode,
metricNotation,
)
: [],
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
)
const witnessDraftPoints =
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
if (!activeLevelId) return null
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
const reticleRadius = 10 * unitsPerPixel
const hoverColor = hover?.semantic ? '#22c55e' : '#06b6d4'
return (
<g ref={groupRef}>
{witnessDraftPoints.length >= 2 && (draft.stage === 'witnesses' || preview.length === 0) ? (
<polyline
fill="none"
pointerEvents="none"
points={witnessDraftPoints.map((point) => `${point[0]},${point[2]}`).join(' ')}
stroke="#06b6d4"
strokeDasharray="6 5"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
) : null}
{preview.map((geometry, index) => (
<FloorplanGeometryRenderer
annotationUnitsPerPoint={unitsPerPixel}
geometry={geometry}
key={`${index}-${geometry.kind}`}
sceneRotationDeg={renderContext?.sceneRotationDeg ?? 0}
/>
))}
{draft.points.map((point, index) => (
<circle
fill="#06b6d4"
key={`${index}-${point.join('-')}`}
pointerEvents="none"
r={4.5 * unitsPerPixel}
stroke="#ffffff"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[2]}
/>
))}
{hover ? (
<g pointerEvents="none">
<circle
cx={hover.point[0]}
cy={hover.point[2]}
fill="none"
r={reticleRadius}
stroke={hoverColor}
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={hoverColor}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover.point[0] - reticleRadius * 1.4}
x2={hover.point[0] + reticleRadius * 1.4}
y1={hover.point[2]}
y2={hover.point[2]}
/>
<line
stroke={hoverColor}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
x1={hover.point[0]}
x2={hover.point[0]}
y1={hover.point[2] - reticleRadius * 1.4}
y2={hover.point[2] + reticleRadius * 1.4}
/>
</g>
) : null}
</g>
)
}
export default FloorplanConstructionDimensionToolLayer
@@ -0,0 +1,518 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
ConstructionDimensionNode,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry,
registerNode,
WallNode,
} from '@pascal-app/core'
import { createFloorplanContextExtensions } from '@pascal-app/editor'
import { wallDefinition } from '../wall/definition'
import { buildConstructionDimensionFloorplan } from './floorplan'
const palette = {
selectedStroke: '#2563eb',
selectedFill: '#dbeafe',
selectedHatch: '#93c5fd',
wallHoverStroke: '#60a5fa',
endpointHandleFill: '#f97316',
endpointHandleStroke: '#ffffff',
endpointHandleHoverStroke: '#fdba74',
endpointHandleActiveFill: '#ea580c',
endpointHandleActiveStroke: '#ffffff',
curveHandleFill: '#14b8a6',
curveHandleStroke: '#ffffff',
curveHandleHoverStroke: '#5eead4',
measurementStroke: '#334155',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#0f172a',
}
function context(
nodes: Record<string, AnyNode> = {},
selected = false,
purpose: 'edit' | 'document' = 'edit',
metricNotation?: 'meters' | 'millimeters',
): GeometryContext {
return {
resolve: (id) => nodes[id],
children: [],
siblings: [],
parent: null,
viewState: {
selected,
unit: 'metric',
highlighted: false,
hovered: false,
moving: false,
palette,
},
extensions: createFloorplanContextExtensions({ metricNotation, purpose }),
}
}
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
}
function dimensionSegments(geometry: FloorplanGeometry | null): Array<{
start: readonly [number, number]
end: readonly [number, number]
dimensionStart?: readonly [number, number]
dimensionEnd?: readonly [number, number]
text: string
stroke?: string
}> {
if (!geometry) return []
return flatten(geometry).flatMap((entry) => {
if (entry.kind === 'dimension') return [entry]
if (entry.kind === 'dimension-string')
return entry.segments.map((segment) => ({ ...segment, stroke: entry.stroke }))
return []
})
}
describe('buildConstructionDimensionFloorplan', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(wallDefinition)
})
afterEach(() => nodeRegistry._reset())
test('projects witness origins onto the placed baseline', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[1, 0, 1],
[4, 0, 2],
],
baseline: { origin: [0, 5], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimension = dimensionSegments(geometry)[0]
expect(dimension).toMatchObject({
start: [1, 1],
end: [4, 2],
dimensionStart: [1, 5],
dimensionEnd: [4, 5],
text: '3m',
})
})
test('follows semantic anchors and reports dangling references', () => {
const wall = WallNode.parse({ id: 'wall_target', start: [0, 0], end: [4, 0] })
const node = ConstructionDimensionNode.parse({
anchors: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const linked = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
const movedWall = WallNode.parse({ ...wall, start: [2, 0], end: [6, 0] })
const moved = buildConstructionDimensionFloorplan(node, context({ [wall.id]: movedWall }))
const dangling = buildConstructionDimensionFloorplan(node, context())
const linkedDimension = dimensionSegments(linked)[0]
const movedDimension = dimensionSegments(moved)[0]
const danglingDimension = dimensionSegments(dangling)[0]
expect(linkedDimension).toMatchObject({ start: [1, 0], text: '3m' })
expect(movedDimension).toMatchObject({ start: [3, 0], text: '1m' })
expect(danglingDimension).toMatchObject({
start: [1, 0],
text: 'UNLINKED · 3m',
stroke: '#dc2626',
})
})
test('resolves wall anchors against the selected assembly datum', () => {
const wall = WallNode.parse({
id: 'wall_assembly',
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.1,
datumEligible: ['structural-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.03,
datumEligible: ['finish-face'],
},
],
})
const anchor = {
kind: 'feature' as const,
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0] as [number, number, number],
}
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
buildConstructionDimensionFloorplan(
ConstructionDimensionNode.parse({
anchors: [anchor, [3, 0, 0]],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy,
}),
context({ [wall.id]: wall }),
)
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
})
test('uses millimetre notation in document output', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[3, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
expect(
dimensionSegments(
buildConstructionDimensionFloorplan(node, context({}, false, 'document')),
)[0]?.text,
).toBe('3000')
})
test('renders a continuous string as adjacent associative segments', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(dimensions).toHaveLength(3)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '3m', '4m'])
expect(dimensions[1]).toMatchObject({
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
})
})
test('renders point-to-point strings as independent witness pairs', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'point-to-point',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(dimensions).toHaveLength(2)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
expect(dimensions[1]).toMatchObject({
start: [5, 0],
end: [9, 0],
dimensionStart: [5, 1],
dimensionEnd: [9, 1],
})
})
test('suppresses view-specific string segments without mutating physical anchors', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
[9, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
metadata: { suppressedDimensionSegmentIndexes: [1] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const dimensions = dimensionSegments(geometry)
expect(node.anchors).toHaveLength(4)
expect(dimensions).toHaveLength(2)
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
})
test('passes persistent dimension standards to linear dimension strings', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy: 'finish-face',
terminator: 'dot',
textPosition: 'centered',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
const geometry = buildConstructionDimensionFloorplan(node, context({}, false, 'document'))
const string = geometry
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
: null
expect(string).toMatchObject({
terminator: 'dot',
textPosition: 'centered',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
})
test('uses the live metric notation for manual dimensions in edit mode', () => {
const node = ConstructionDimensionNode.parse({
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(
node,
context({}, false, 'edit', 'millimeters'),
)
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
})
test('shows witness and baseline handles only while selected', () => {
const node = ConstructionDimensionNode.parse({})
const idle = buildConstructionDimensionFloorplan(node, context())
const selected = buildConstructionDimensionFloorplan(node, context({}, true))
expect(idle && flatten(idle).filter((entry) => entry.kind === 'endpoint-handle')).toHaveLength(
0,
)
const handles = selected
? flatten(selected).filter((entry) => entry.kind === 'endpoint-handle')
: []
expect(handles).toHaveLength(3)
expect(handles).toContainEqual(
expect.objectContaining({
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex: 0 },
}),
)
expect(handles).toContainEqual(
expect.objectContaining({
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex: 1 },
}),
)
expect(handles).toContainEqual(
expect.objectContaining({ affordance: 'move-construction-dimension-baseline' }),
)
})
test('keeps linked geometry read-only in a dependent drawing', () => {
const node = ConstructionDimensionNode.parse({
metadata: { drawingCoordinationLocked: true },
})
const geometry = buildConstructionDimensionFloorplan(node, context({}, true))
expect(
geometry && flatten(geometry).filter((entry) => entry.kind === 'endpoint-handle'),
).toHaveLength(0)
})
test('renders radius notation with a leader and center mark', () => {
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [3, 1], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
text: 'R 2m',
cx: 3,
cy: 1,
})
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
})
test('updates an associative curved-wall radius when the host curve changes', () => {
const wall = WallNode.parse({
id: 'wall_curve',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
})
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
fallback: [2, 0, 1.5],
},
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
fallback: [2, 0, -1],
},
],
baseline: { origin: [2, -1.5], direction: [0, -1] },
})
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
const originalLabel =
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
const reshapedLabel =
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
})
test('renders diameter and repeated-feature notation', () => {
const node = ConstructionDimensionNode.parse({
mode: 'diameter',
anchors: [
[-1, 0, 0],
[1, 0, 0],
],
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(dimensionSegments(geometry)[0]).toMatchObject({
text: 'TYP · 6 x Ø 2m CLR',
start: [-1, 0],
end: [1, 0],
})
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
})
test('renders a standalone center mark from a center and radius point', () => {
const node = ConstructionDimensionNode.parse({
mode: 'center-mark',
anchors: [
[3, 0, 4],
[5, 0, 4],
],
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
expect(dimensionSegments(geometry).length).toBe(0)
})
test('renders chord and arc-length dimensions', () => {
const chord = ConstructionDimensionNode.parse({
mode: 'chord',
anchors: [
[-1, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
})
const arc = ConstructionDimensionNode.parse({
mode: 'arc-length',
anchors: [
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
baseline: { origin: [2, 2], direction: [1, 0] },
})
const chordGeometry = buildConstructionDimensionFloorplan(chord, context())
const arcGeometry = buildConstructionDimensionFloorplan(arc, context())
const chordEntries = chordGeometry ? flatten(chordGeometry) : []
const arcEntries = arcGeometry ? flatten(arcGeometry) : []
expect(dimensionSegments(chordGeometry)[0]).toMatchObject({
text: 'CH 2m',
})
expect(arcEntries.some((entry) => entry.kind === 'path')).toBe(true)
expect(arcEntries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
text: 'ARC 3.14m',
})
})
test('renders angular dimensions with an architectural angle label', () => {
const node = ConstructionDimensionNode.parse({
mode: 'angular',
anchors: [
[2, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
baseline: { origin: [1.5, 0.5], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.some((entry) => entry.kind === 'path')).toBe(true)
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
cx: 1.5,
cy: 0.5,
text: '∠ 90°',
screenUpright: true,
})
expect(entries).toContainEqual(expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }))
})
test('renders signed coordinate labels for repeated circular features', () => {
const node = ConstructionDimensionNode.parse({
mode: 'coordinate',
anchors: [
[0, 0, 0],
[2, 0, 3],
[-1, 0, 4],
],
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const labels = geometry
? flatten(geometry)
.filter((entry) => entry.kind === 'dimension-label')
.map((entry) => entry.text)
: []
expect(labels).toEqual(['P1 · X 2m · Y 3m', 'P2 · X -1m · Y 4m'])
})
})
@@ -0,0 +1,660 @@
import type {
AnyNodeId,
ConstructionDimensionNode,
FloorplanGeometry,
FloorplanPoint,
FloorplanStyle,
GeometryContext,
MeasurementAnchor,
MeasurementPoint,
WallNode,
} from '@pascal-app/core'
import {
constructionDimensionRequiredAnchorCount,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallCurveFrameAt,
resolveWallAssemblyDatumReferences,
} from '@pascal-app/core'
import {
readFloorplanContext,
readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import { resolveMeasurementAnchor } from '../measurement/resolve'
import {
type ConstructionLengthFormatOptions,
type ConstructionLengthProfile,
formatConstructionLength,
} from '../shared/construction-length'
import { buildDimensionStringGeometry } from '../shared/dimension-string'
import {
resolveCircularConstructionDimensionLayout,
resolveConstructionDimensionLayout,
} from './geometry'
const DEFAULT_STROKE = '#334155'
const DANGLING_STROKE = '#dc2626'
const EPSILON = 1e-6
export function buildConstructionDimensionFloorplan(
node: ConstructionDimensionNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.visible === false) return null
const resolved = node.anchors.map((anchor) => resolveDimensionAnchor(node, anchor, ctx))
const points = resolved.map((anchor) => anchor.point) as MeasurementPoint[]
if (points.length < constructionDimensionRequiredAnchorCount(node.mode)) return null
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
const baseStroke = selected
? (ctx.viewState?.palette.selectedStroke ?? '#2563eb')
: (ctx.viewState?.palette.measurementStroke ?? DEFAULT_STROKE)
const dangling = resolved.some((anchor) => anchor.dangling)
const stroke = dangling ? DANGLING_STROKE : baseStroke
const unit = ctx.viewState?.unit ?? 'metric'
const floorplanContext = readFloorplanContext(ctx)
const profile: ConstructionLengthProfile =
floorplanContext.purpose === 'document' ? 'document' : 'editor'
const metricNotationOverride = readFloorplanMetricNotationOverride(ctx)
const displayNode =
profile === 'editor' && metricNotationOverride
? { ...node, metricNotation: metricNotationOverride }
: node
const editable =
ctx.viewState?.selected === true &&
!(
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
node.metadata.drawingCoordinationLocked === true
)
switch (node.mode) {
case 'linear':
case 'chord':
return withFloorplanGeometryMetadata(
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'radius':
return withFloorplanGeometryMetadata(
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'diameter':
return withFloorplanGeometryMetadata(
buildDiameter(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'center-mark':
return withFloorplanGeometryMetadata(
buildCenterMarkOnly(displayNode, points, stroke, editable),
{ annotationRole: 'manual-dimension' },
)
case 'arc-length':
return withFloorplanGeometryMetadata(
buildArcLength(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
case 'angular':
return withFloorplanGeometryMetadata(
buildAngular(displayNode, points, stroke, dangling, editable),
{ annotationRole: 'manual-dimension' },
)
case 'coordinate':
return withFloorplanGeometryMetadata(
buildCoordinate(displayNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
}
}
function resolveDimensionAnchor(
node: ConstructionDimensionNode,
anchor: MeasurementAnchor,
ctx: GeometryContext,
): ReturnType<typeof resolveMeasurementAnchor> {
const resolved = resolveMeasurementAnchor(anchor, (id) => ctx.resolve(id))
if (Array.isArray(anchor) || resolved.dangling) return resolved
if (!supportsWallDatum(anchor.reference.featureId)) return resolved
const referenced = ctx.resolve<WallNode>(anchor.reference.nodeId as AnyNodeId)
if (referenced?.type !== 'wall') return resolved
const t = wallFeatureParameter(anchor.reference.featureId, anchor.reference.parameters?.t)
const frame = getWallCurveFrameAt(referenced, t)
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
return {
...resolved,
point: [
frame.point.x + frame.normal.x * offset,
resolved.point[1],
frame.point.y + frame.normal.y * offset,
],
}
}
function supportsWallDatum(featureId: string): boolean {
return (
featureId === 'wall:start' ||
featureId === 'wall:end' ||
featureId === 'wall:centerline' ||
featureId === 'wall:midpoint' ||
featureId === 'wall:face:left' ||
featureId === 'wall:face:right' ||
featureId === 'wall:top-centerline'
)
}
function wallFeatureParameter(featureId: string, parameter: unknown): number {
if (featureId === 'wall:start') return 0
if (featureId === 'wall:end') return 1
return typeof parameter === 'number' ? Math.max(0, Math.min(1, parameter)) : 0.5
}
function wallDatumSide(
node: ConstructionDimensionNode,
featureId: string,
resolved: ReturnType<typeof resolveMeasurementAnchor>,
frame: ReturnType<typeof getWallCurveFrameAt>,
): 1 | -1 {
if (featureId === 'wall:face:left') return 1
if (featureId === 'wall:face:right') return -1
const baselineProjection =
(node.baseline.origin[0] - frame.point.x) * frame.normal.x +
(node.baseline.origin[1] - frame.point.y) * frame.normal.y
if (Math.abs(baselineProjection) > EPSILON) return baselineProjection > 0 ? 1 : -1
const resolvedNormal = resolved.normal
if (resolvedNormal) {
const normalProjection = resolvedNormal[0] * frame.normal.x + resolvedNormal[2] * frame.normal.y
if (Math.abs(normalProjection) > EPSILON) return normalProjection > 0 ? 1 : -1
}
return 1
}
function wallDatumOffset(
wall: WallNode,
policy: ConstructionDimensionNode['datumPolicy'],
side: 1 | -1,
): number {
if (policy === 'centerline') return 0
if (policy === 'wall-face') {
const faces = getWallAssemblyFaceOffsets(wall)
return side > 0 ? faces.exterior : faces.interior
}
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
const candidates = resolveWallAssemblyDatumReferences(wall)
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
.map((reference) => reference.offset)
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
}
function buildLinearOrChord(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry {
const layout = resolveConstructionDimensionLayout(node, points)
const children: FloorplanGeometry[] = []
const suppressedSegments = suppressedDimensionSegmentIndexes(node)
const visibleSegments = layout.segments.filter((_, index) => !suppressedSegments.has(index))
const dimensionSegments = visibleSegments.map((segment) => {
const baseText = `${node.mode === 'chord' ? 'CH ' : ''}${formatConstructionLength(segment.value, unit, profile, lengthFormatOptions(node))}`
return {
witnessStart: segment.witnessStart,
witnessEnd: segment.witnessEnd,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
text: notation(node, baseText, dangling),
}
})
children.push(
...(dimensionSegments.length > 0
? [
buildDimensionStringGeometry({
segments: dimensionSegments,
offsetNormal: layout.normal,
offsetDistance: 0,
extensionStartGap: node.extensionStartGap,
extensionOvershoot: node.extensionOvershoot,
terminator: node.terminator,
textPosition: node.textPosition,
stroke,
}),
]
: []),
...visibleSegments.map((segment) => hitLine(segment.dimensionStart, segment.dimensionEnd)),
)
if (editable)
children.push(...witnessHandles(layout.witnessPoints), baselineHandle(layout.midpoint))
return dimensionGroup(children)
}
function buildRadius(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('radius', points)
if (!layout) return null
const labelPoint: FloorplanPoint = node.baseline.origin
const children: FloorplanGeometry[] = [
styledPolyline([layout.center, layout.start, labelPoint], stroke),
...openArrow(layout.start, layout.center, stroke),
labelGeometry(
labelPoint,
notation(
node,
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
angle(layout.start, labelPoint),
),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
return dimensionGroup(children)
}
function buildDiameter(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('diameter', points)
if (!layout?.end) return null
const direction = normalized(layout.start, layout.end)
if (!direction) return null
const normal: FloorplanPoint = [-direction[1], direction[0]]
const children: FloorplanGeometry[] = [
dimensionGeometry(
node,
layout.start,
layout.end,
layout.start,
layout.end,
normal,
notation(
node,
`Ø ${formatConstructionLength(layout.radius * 2, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
stroke,
),
hitLine(layout.start, layout.end),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function buildCenterMarkOnly(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('center-mark', points)
if (!layout) return null
const children: FloorplanGeometry[] = centerMark(layout.center, layout.radius, stroke, true)
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function buildArcLength(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('arc-length', points)
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
const projectedEnd = arcPoint(layout.center, layout.radius, layout.endAngle)
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = arcPoint(layout.center, layout.radius, midAngle)
const labelPoint: FloorplanPoint = node.baseline.origin
const children: FloorplanGeometry[] = [
arcGeometry(layout.center, layout.radius, layout.startAngle, layout.sweep, stroke),
styledLine(layout.center, layout.start, stroke, '0.08 0.08'),
styledLine(layout.center, projectedEnd, stroke, '0.08 0.08'),
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
...openArrow(
layout.start,
arcPoint(layout.center, layout.radius, layout.startAngle + layout.sweep * 0.08),
stroke,
),
...openArrow(
projectedEnd,
arcPoint(layout.center, layout.radius, layout.endAngle - layout.sweep * 0.08),
stroke,
),
labelGeometry(
labelPoint,
notation(
node,
`ARC ${formatConstructionLength(layout.arcLength, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
0,
true,
),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
return dimensionGroup(children)
}
function buildAngular(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('angular', points)
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
const endRadius = distance(layout.center, layout.end)
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
const requestedRadius = distance(layout.center, node.baseline.origin)
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
const midAngle = layout.startAngle + layout.sweep / 2
const arcMid = arcPoint(layout.center, arcRadius, midAngle)
const labelPoint: FloorplanPoint = node.baseline.origin
const startRayEnd = arcPoint(
layout.center,
Math.max(layout.radius, arcRadius + 0.12),
layout.startAngle,
)
const endRayEnd = arcPoint(layout.center, Math.max(endRadius, arcRadius + 0.12), layout.endAngle)
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
const children: FloorplanGeometry[] = [
styledLine(layout.center, startRayEnd, stroke),
styledLine(layout.center, endRayEnd, stroke),
arcGeometry(layout.center, arcRadius, layout.startAngle, layout.sweep, stroke),
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
labelGeometry(labelPoint, notation(node, `${formatDegrees(degrees)}`, dangling), 0, true),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, arcRadius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(node.baseline.origin))
return dimensionGroup(children)
}
function buildCoordinate(
node: ConstructionDimensionNode,
points: MeasurementPoint[],
stroke: string,
dangling: boolean,
unit: 'metric' | 'imperial',
profile: ConstructionLengthProfile,
editable: boolean,
): FloorplanGeometry | null {
const datum: FloorplanPoint = [points[0]![0], points[0]![2]]
const features = points.slice(1).map((point): FloorplanPoint => [point[0], point[2]])
if (features.length === 0) return null
const children: FloorplanGeometry[] = [...centerMark(datum, 0.4, stroke, true)]
features.forEach((feature, index) => {
const dx = feature[0] - datum[0]
const dy = feature[1] - datum[1]
const label = notation(
node,
`P${index + 1} · X ${formatConstructionLength(dx, unit, profile, lengthFormatOptions(node))} · Y ${formatConstructionLength(dy, unit, profile, lengthFormatOptions(node))}`,
dangling,
false,
)
children.push(
styledLine(datum, feature, stroke, '0.08 0.08'),
labelGeometry(feature, label, 0, true, 10),
...centerMark(feature, 0.3, stroke, true),
)
})
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
function suppressedDimensionSegmentIndexes(node: ConstructionDimensionNode): ReadonlySet<number> {
const metadata = node.metadata
if (!(typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata))) {
return new Set()
}
const value = metadata.suppressedDimensionSegmentIndexes
if (!Array.isArray(value)) return new Set()
return new Set(
value.filter(
(entry): entry is number =>
typeof entry === 'number' && Number.isInteger(entry) && entry >= 0,
),
)
}
function dimensionGroup(children: FloorplanGeometry[]): FloorplanGeometry {
return { kind: 'group', children }
}
function lengthFormatOptions(node: ConstructionDimensionNode): ConstructionLengthFormatOptions {
return {
imperialPrecision: node.imperialPrecision,
metricNotation: node.metricNotation,
}
}
function notation(
node: ConstructionDimensionNode,
base: string,
dangling: boolean,
includeFeatureCount = true,
): string {
const repeated = includeFeatureCount && node.featureCount > 1 ? `${node.featureCount} x ` : ''
const content = node.textOverride ?? `${repeated}${base}`
const decorated = `${node.prefix}${content}${node.suffix}`
return dangling ? `UNLINKED · ${decorated}` : decorated
}
function dimensionGeometry(
node: ConstructionDimensionNode,
start: FloorplanPoint,
end: FloorplanPoint,
dimensionStart: FloorplanPoint,
dimensionEnd: FloorplanPoint,
offsetNormal: FloorplanPoint,
text: string,
stroke: string,
): FloorplanGeometry {
return {
kind: 'dimension',
start,
end,
dimensionStart,
dimensionEnd,
offsetNormal,
offsetDistance: 0,
extensionStartGap: node.extensionStartGap,
extensionOvershoot: node.extensionOvershoot,
terminator: node.terminator,
textPosition: node.textPosition,
text,
stroke,
}
}
function arcGeometry(
center: FloorplanPoint,
radius: number,
startAngle: number,
sweep: number,
stroke: string,
): FloorplanGeometry {
const start = arcPoint(center, radius, startAngle)
const end = arcPoint(center, radius, startAngle + sweep)
return {
kind: 'path',
d: `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep >= 0 ? 1 : 0} ${end[0]} ${end[1]}`,
...lineStyle(stroke),
}
}
function styledLine(
start: FloorplanPoint,
end: FloorplanPoint,
stroke: string,
strokeDasharray?: string,
): FloorplanGeometry {
return {
kind: 'line',
x1: start[0],
y1: start[1],
x2: end[0],
y2: end[1],
...lineStyle(stroke, strokeDasharray),
}
}
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
}
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
return {
fill: 'none',
stroke,
strokeWidth: 0.9,
strokeDasharray,
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'butt',
strokeLinejoin: 'miter',
}
}
function labelGeometry(
point: FloorplanPoint,
text: string,
labelAngle: number,
screenUpright = false,
offsetPx = 0,
): FloorplanGeometry {
return {
kind: 'dimension-label',
cx: point[0],
cy: point[1],
text,
angle: labelAngle,
screenUpright,
offsetPx,
appearance: 'outlined',
}
}
function centerMark(
center: FloorplanPoint,
radius: number,
stroke: string,
force = false,
): FloorplanGeometry[] {
if (!force && radius <= EPSILON) return []
const half = Math.min(0.22, Math.max(0.1, radius * 0.18))
const gap = Math.min(0.045, half * 0.3)
return [
styledLine([center[0] - half, center[1]], [center[0] - gap, center[1]], stroke),
styledLine([center[0] + gap, center[1]], [center[0] + half, center[1]], stroke),
styledLine([center[0], center[1] - half], [center[0], center[1] - gap], stroke),
styledLine([center[0], center[1] + gap], [center[0], center[1] + half], stroke),
]
}
function openArrow(
tip: FloorplanPoint,
toward: FloorplanPoint,
stroke: string,
): FloorplanGeometry[] {
const direction = normalized(tip, toward)
if (!direction) return []
const length = 0.15
const halfWidth = 0.055
const base: FloorplanPoint = [tip[0] + direction[0] * length, tip[1] + direction[1] * length]
const normal: FloorplanPoint = [-direction[1], direction[0]]
return [
styledLine(tip, [base[0] + normal[0] * halfWidth, base[1] + normal[1] * halfWidth], stroke),
styledLine(tip, [base[0] - normal[0] * halfWidth, base[1] - normal[1] * halfWidth], stroke),
]
}
function baselineHandle(point: FloorplanPoint): FloorplanGeometry {
return {
kind: 'endpoint-handle',
point,
state: 'idle',
variant: 'curve',
affordance: 'move-construction-dimension-baseline',
payload: null,
}
}
function anchorHandles(points: readonly MeasurementPoint[]): FloorplanGeometry[] {
return witnessHandles(points.map((point): FloorplanPoint => [point[0], point[2]]))
}
function witnessHandles(points: readonly FloorplanPoint[]): FloorplanGeometry[] {
return points.map((point, witnessIndex) => ({
kind: 'endpoint-handle',
point,
state: 'idle',
affordance: 'move-construction-dimension-witness',
payload: { witnessIndex },
}))
}
function hitLine(start: FloorplanPoint, end: FloorplanPoint): FloorplanGeometry {
return {
kind: 'hit-line',
x1: start[0],
y1: start[1],
x2: end[0],
y2: end[1],
strokeWidthPx: 12,
}
}
function arcPoint(center: FloorplanPoint, radius: number, pointAngle: number): FloorplanPoint {
return [center[0] + Math.cos(pointAngle) * radius, center[1] + Math.sin(pointAngle) * radius]
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= EPSILON ? null : [dx / magnitude, dy / magnitude]
}
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.atan2(second[1] - first[1], second[0] - first[0])
}
function formatDegrees(value: number): string {
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
}
@@ -0,0 +1,154 @@
import type {
ConstructionDimensionMode,
ConstructionDimensionNode,
FloorplanPoint,
MeasurementPoint,
} from '@pascal-app/core'
export type ConstructionDimensionSegmentLayout = {
dimensionStart: FloorplanPoint
dimensionEnd: FloorplanPoint
value: number
witnessStart: FloorplanPoint
witnessEnd: FloorplanPoint
}
export type ConstructionDimensionLayout = {
dimensionPoints: FloorplanPoint[]
direction: FloorplanPoint
midpoint: FloorplanPoint
normal: FloorplanPoint
segments: ConstructionDimensionSegmentLayout[]
witnessPoints: FloorplanPoint[]
}
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
export type CircularConstructionDimensionLayout = {
center: FloorplanPoint
start: FloorplanPoint
end: FloorplanPoint | null
radius: number
startAngle: number
endAngle: number
sweep: number
chordLength: number
arcLength: number
}
export function resolveCircularConstructionDimensionLayout(
mode: ConstructionDimensionMode,
anchors: readonly MeasurementPoint[],
): CircularConstructionDimensionLayout | null {
if (anchors.length < 2) return null
const first = project(anchors[0]!)
const second = project(anchors[1]!)
if (mode === 'diameter') {
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
const radius = distance(first, second) / 2
if (radius <= 1e-9) return null
return {
center,
start: first,
end: second,
radius,
startAngle: Math.atan2(first[1] - center[1], first[0] - center[0]),
endAngle: Math.atan2(second[1] - center[1], second[0] - center[0]),
sweep: Math.PI,
chordLength: radius * 2,
arcLength: Math.PI * radius,
}
}
const usesMiddleCenter = mode === 'arc-length' || mode === 'angular'
const center = usesMiddleCenter ? second : first
const start = usesMiddleCenter ? first : second
const radius = distance(center, start)
if (radius <= 1e-9) return null
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0])
const endAnchor = anchors[2]
const end = endAnchor ? project(endAnchor) : null
const endAngle = end ? Math.atan2(end[1] - center[1], end[0] - center[0]) : startAngle
const sweep = end ? normalizedSignedSweep(startAngle, endAngle) : 0
return {
center,
start,
end,
radius,
startAngle,
endAngle,
sweep,
chordLength: end ? distance(start, end) : radius,
arcLength: Math.abs(sweep) * radius,
}
}
function normalizedSignedSweep(startAngle: number, endAngle: number): number {
let sweep = endAngle - startAngle
while (sweep > Math.PI) sweep -= Math.PI * 2
while (sweep <= -Math.PI) sweep += Math.PI * 2
return sweep
}
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
export function resolveConstructionDimensionLayout(
node: Pick<ConstructionDimensionNode, 'baseline' | 'chainMode'>,
anchors: readonly MeasurementPoint[],
): ConstructionDimensionLayout {
if (anchors.length < 2) {
throw new Error('Construction dimension layout requires at least two anchors')
}
const magnitude = Math.hypot(node.baseline.direction[0], node.baseline.direction[1])
const direction: FloorplanPoint = [
node.baseline.direction[0] / magnitude,
node.baseline.direction[1] / magnitude,
]
const normal: FloorplanPoint = [-direction[1], direction[0]]
const witnessPoints = anchors.map(project)
const dimensionPoints = witnessPoints.map((point): FloorplanPoint => {
const deltaX = point[0] - node.baseline.origin[0]
const deltaY = point[1] - node.baseline.origin[1]
const distance = deltaX * direction[0] + deltaY * direction[1]
return [
node.baseline.origin[0] + distance * direction[0],
node.baseline.origin[1] + distance * direction[1],
]
})
const segmentIndexes =
node.chainMode === 'continuous'
? witnessPoints.slice(0, -1).map((_, index) => [index, index + 1] as const)
: Array.from(
{ length: Math.floor(witnessPoints.length / 2) },
(_, index) => [index * 2, index * 2 + 1] as const,
)
const segments = segmentIndexes.map(([startIndex, endIndex]) => {
const witnessStart = witnessPoints[startIndex]!
const witnessEnd = witnessPoints[endIndex]!
const dimensionStart = dimensionPoints[startIndex]!
const dimensionEnd = dimensionPoints[endIndex]!
return {
dimensionStart,
dimensionEnd,
value: Math.abs(
(witnessEnd[0] - witnessStart[0]) * direction[0] +
(witnessEnd[1] - witnessStart[1]) * direction[1],
),
witnessStart,
witnessEnd,
}
})
const first = dimensionPoints[0]!
const last = dimensionPoints.at(-1)!
return {
dimensionPoints,
direction,
midpoint: [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2],
normal,
segments,
witnessPoints,
}
}
@@ -0,0 +1,6 @@
export { constructionDimensionDefinition } from './definition'
export { buildConstructionDimensionFloorplan } from './floorplan'
export {
resolveCircularConstructionDimensionLayout,
resolveConstructionDimensionLayout,
} from './geometry'
@@ -0,0 +1,416 @@
'use client'
import {
type AnyNodeId,
type ConstructionDimensionDatumPolicy,
type ConstructionDimensionDrawingPresentation,
type ConstructionDimensionImperialPrecision,
type ConstructionDimensionMetricNotation,
type ConstructionDimensionNode,
type ConstructionDimensionTerminator,
type ConstructionDimensionTextPosition,
type ConstructionDrawingType,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
useScene,
} from '@pascal-app/core'
import {
ActionButton,
ActionGroup,
DRAWING_TYPE_OPTIONS,
PanelSection,
PanelWrapper,
SliderControl,
triggerSFX,
useDrawingView,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Trash2 } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
const MODE_LABELS: Record<ConstructionDimensionNode['mode'], string> = {
linear: 'Linear',
radius: 'Radius',
diameter: 'Diameter',
'center-mark': 'Center mark',
chord: 'Chord',
'arc-length': 'Arc length',
angular: 'Angular',
coordinate: 'Coordinate',
}
const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [
{ label: 'Centerline', value: 'centerline' },
{ label: 'Wall face', value: 'wall-face' },
{ label: 'Structural face', value: 'structural-face' },
{ label: 'Finish face', value: 'finish-face' },
]
const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [
{ label: 'Architectural tick', value: 'architectural-tick' },
{ label: 'Filled arrow', value: 'filled-arrow' },
{ label: 'Open arrow', value: 'open-arrow' },
{ label: 'Dot', value: 'dot' },
]
const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [
{ label: 'Above line', value: 'above' },
{ label: 'Centered on line', value: 'centered' },
]
const IMPERIAL_PRECISION_OPTIONS: Array<{
label: string
value: ConstructionDimensionImperialPrecision
}> = [
{ label: 'Nearest inch', value: '1' },
{ label: 'Nearest 1/2 inch', value: '1/2' },
{ label: 'Nearest 1/4 inch', value: '1/4' },
{ label: 'Nearest 1/8 inch', value: '1/8' },
{ label: 'Nearest 1/16 inch', value: '1/16' },
]
const METRIC_NOTATION_OPTIONS: Array<{
label: string
value: ConstructionDimensionMetricNotation
}> = [
{ label: 'Meters', value: 'meters' },
{ label: 'Millimeters', value: 'millimeters' },
]
export default function ConstructionDimensionPanel() {
const selectedId = useViewer((state) => state.selection.selectedIds[0])
const setSelection = useViewer((state) => state.setSelection)
const dimension = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined
return node?.type === 'construction-dimension' ? node : null
})
const foundationControllers = useScene(
useShallow((state) =>
Object.values(state.nodes).filter(
(candidate): candidate is ConstructionDimensionNode =>
candidate.type === 'construction-dimension' &&
candidate.id !== dimension?.id &&
candidate.drawingType === 'foundation-plan',
),
),
)
const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const activeDrawingType = useDrawingView((state) => state.drawingType)
if (!(dimension && selectedId)) return null
const update = (patch: Partial<ConstructionDimensionNode>) => updateNode(dimension.id, patch)
const supportsCenterMark = ['radius', 'diameter', 'arc-length', 'angular'].includes(
dimension.mode,
)
const activeDrawingLabel =
DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan'
const activePresentation = resolveConstructionDimensionDrawingPresentation(
dimension,
activeDrawingType,
)
const activeDrawingOverride = resolveConstructionDimensionDrawingOverride(
dimension,
activeDrawingType,
)
const suppressedSegmentsText = formatSuppressedSegments(
activeDrawingOverride?.suppressedSegmentIndexes ?? [],
)
const updateDrawingPresentation = (
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
) => {
const drawingOverrides = setConstructionDimensionDrawingPresentation(
dimension,
drawingType,
presentation,
)
update({
drawingOverrides,
...(presentation === 'controlled' && !dimension.controllingDimensionId
? { controllingDimensionId: foundationControllers[0]?.id ?? null }
: {}),
})
}
const updateSuppressedSegments = (value: string) => {
update({
drawingOverrides: setConstructionDimensionDrawingSuppressedSegments(
dimension,
activeDrawingType,
parseSuppressedSegments(value),
),
})
}
return (
<PanelWrapper
icon="/icons/blueprint.webp"
onClose={() => setSelection({ selectedIds: [] })}
title="Construction Dimension"
width={320}
>
<PanelSection title="Dimension">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Mode</span>
<span className="font-medium text-foreground">{MODE_LABELS[dimension.mode]}</span>
</div>
<SliderControl
label="Feature count"
max={999}
min={1}
onChange={(featureCount) => update({ featureCount })}
precision={0}
step={1}
value={dimension.featureCount}
/>
{supportsCenterMark ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Center mark</span>
<input
checked={dimension.showCenterMark}
onChange={(event) => update({ showCenterMark: event.target.checked })}
type="checkbox"
/>
</label>
) : null}
</PanelSection>
<PanelSection title="Drawing coordination">
<SelectField
label="Primary drawing"
onChange={(drawingType) =>
update({ drawingType: drawingType as ConstructionDrawingType })
}
options={DRAWING_TYPE_OPTIONS.map((option) => ({
label: option.label,
value: option.id,
}))}
value={dimension.drawingType}
/>
<SelectField
label={`${activeDrawingLabel} presentation`}
onChange={(presentation) =>
updateDrawingPresentation(
activeDrawingType,
presentation as ConstructionDimensionDrawingPresentation,
)
}
options={[
{ label: 'Shown', value: 'shown' },
{ label: 'Omitted', value: 'omit' },
...(activeDrawingType === 'floor-plan'
? [{ label: 'Controlled by foundation', value: 'controlled' }]
: []),
]}
value={activePresentation}
/>
{activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? (
<SelectField
disabled={foundationControllers.length === 0}
label="Foundation controller"
onChange={(controllingDimensionId) =>
update({
controllingDimensionId: controllingDimensionId as NonNullable<
ConstructionDimensionNode['controllingDimensionId']
>,
})
}
options={foundationControllers.map((controller) => ({
label: controller.name || 'Foundation dimension',
value: controller.id,
}))}
placeholder="No foundation dimensions"
value={dimension.controllingDimensionId ?? ''}
/>
) : null}
<p className="text-muted-foreground text-xs">
Linked dimensions reuse the controller's associative anchors and update with it.
</p>
<TextField
label={`${activeDrawingLabel} suppressed segments`}
onCommit={updateSuppressedSegments}
placeholder="e.g. 2, 4"
value={suppressedSegmentsText}
/>
<p className="text-muted-foreground text-xs">
Segment numbers are one-based and apply only in this drawing view.
</p>
</PanelSection>
<PanelSection title="Notation">
<TextField
label="Prefix"
onCommit={(prefix) => update({ prefix })}
value={dimension.prefix}
/>
<TextField
label="Suffix"
onCommit={(suffix) => update({ suffix })}
value={dimension.suffix}
/>
<TextField
label="Text override"
onCommit={(textOverride) => update({ textOverride: textOverride || null })}
placeholder="Use measured value"
value={dimension.textOverride ?? ''}
/>
</PanelSection>
<PanelSection title="Standards">
<SelectField
label="Datum policy"
onChange={(datumPolicy) =>
update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy })
}
options={DATUM_POLICY_OPTIONS}
value={dimension.datumPolicy}
/>
<SelectField
label="Terminator"
onChange={(terminator) =>
update({ terminator: terminator as ConstructionDimensionTerminator })
}
options={TERMINATOR_OPTIONS}
value={dimension.terminator}
/>
<SelectField
label="Text position"
onChange={(textPosition) =>
update({ textPosition: textPosition as ConstructionDimensionTextPosition })
}
options={TEXT_POSITION_OPTIONS}
value={dimension.textPosition}
/>
<SelectField
label="Imperial precision"
onChange={(imperialPrecision) =>
update({
imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision,
})
}
options={IMPERIAL_PRECISION_OPTIONS}
value={dimension.imperialPrecision}
/>
<SelectField
label="Metric notation"
onChange={(metricNotation) =>
update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation })
}
options={METRIC_NOTATION_OPTIONS}
value={dimension.metricNotation}
/>
<SliderControl
label="Extension gap"
max={0.5}
min={0}
onChange={(extensionStartGap) => update({ extensionStartGap })}
precision={3}
step={0.005}
value={dimension.extensionStartGap}
/>
<SliderControl
label="Extension overshoot"
max={0.5}
min={0}
onChange={(extensionOvershoot) => update({ extensionOvershoot })}
precision={3}
step={0.005}
value={dimension.extensionOvershoot}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
icon={<Trash2 className="h-4 w-4" />}
label="Delete"
onClick={() => {
triggerSFX('sfx:structure-delete')
deleteNode(dimension.id)
setSelection({ selectedIds: [] })
}}
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
function parseSuppressedSegments(value: string): number[] {
return [
...new Set(
value
.split(/[,\s]+/)
.map((part) => Number.parseInt(part, 10))
.filter((index) => Number.isInteger(index) && index > 0)
.map((index) => index - 1),
),
].sort((left, right) => left - right)
}
function formatSuppressedSegments(indexes: readonly number[]): string {
return indexes.map((index) => index + 1).join(', ')
}
function SelectField({
label,
value,
options,
placeholder,
disabled,
onChange,
}: {
label: string
value: string
options: Array<{ label: string; value: string }>
placeholder?: string
disabled?: boolean
onChange: (value: string) => void
}) {
return (
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<select
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground disabled:opacity-50"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
value={value}
>
{placeholder && options.length === 0 ? <option value="">{placeholder}</option> : null}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)
}
function TextField({
label,
value,
placeholder,
onCommit,
}: {
label: string
value: string
placeholder?: string
onCommit: (value: string) => void
}) {
return (
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{label}</span>
<input
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground"
defaultValue={value}
key={value}
onBlur={(event) => onCommit(event.target.value)}
placeholder={placeholder}
/>
</label>
)
}
@@ -0,0 +1,6 @@
import type { ConstructionDimensionNode, ParametricDescriptor } from '@pascal-app/core'
export const constructionDimensionParametrics: ParametricDescriptor<ConstructionDimensionNode> = {
groups: [],
customPanel: () => import('./panel'),
}

Some files were not shown because too many files have changed in this diff Show More