editor: complete floorplan construction documentation (#531)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(floorplan): add construction dimension strings * feat(floorplan): coordinate opening dimensions * feat(floorplan): add opening documentation * feat(floorplan): add construction dimensions and notes * feat(floorplan): add interior dimensions and curved note leaders * feat(floorplan): improve construction dimensions and document plan * feat(floorplan): harden construction document output * feat(floorplan): add annotation collision diagnostics * feat(floorplan): size export annotations in paper space * feat(floorplan): automatically separate overlapping labels * fix(floorplan): resolve dense label overlaps * fix(floorplan): remove stale collision warning overlays * fix(floorplan): treat mark pills as collision obstacles * feat(floorplan): place short dimension values outside * fix(floorplan): preserve dimension string order * fix(floorplan): avoid architectural geometry in label layout * feat(floorplan): add dimension side fallback leaders * docs(floorplan): update chapter 17 implementation status * fix(floorplan): dimension subdivided interior walls * feat: add associative floor plan dimensions * feat: add continuous construction dimension strings * feat: add structural floor plan grids * feat: coordinate columns with structural grids Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer. * feat: add architectural room documentation Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior. * feat: generate architectural room schedules Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings. * feat: add reliable room clear dimensions Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums. * feat: add architectural stair documentation Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility. * feat: add typed specialty construction notes Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls. * feat: add curved and circular dimensions Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls. * feat: coordinate floor plan drawing types Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output. * feat: add associative curved wall dimensions Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes. * fix: render automatic curved wall dimensions The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility. * fix: use radius callout for curved walls Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth. * Implement construction dimension string editing * Add construction dimension standards controls * Apply drawing standards to automatic dimensions * Add floorplan overhead and reference visibility controls * Add view-specific dimension segment suppression * Add persistent drawing sheet model * Plot floorplan exports at fixed scale * Apply paper-space annotation profiles * Compose floorplan PDF sheets * Support sheet paper sizes and preflight * Persist pinned annotation layout overrides * Expand annotation collision obstacles * Add floorplan annotation preflight surface * Add reusable drawing sheet general notes * Add drawing sheet keyed note instances * Add drawing sheet document markers * Expand construction note leader terminators * Add wall assembly layer model * Resolve wall assembly datum references * Add wall assembly floorplan graphics * Add opening documentation dimension policies * Add finish-face room clear dimensions * Extend room clear dimensions to rectilinear rooms * Add construction module advisories * Add clearance advisory profiles * Add dimension completeness audit * Expand dimension completeness audit * Include preflight issues in completeness audit * feat: complete floorplan construction documentation * refactor: remove construction note node * feat: refine floorplan documentation and unit display * fix(editor): improve floorplan PDF dimensions * fix(floorplan): refresh annotation collision layout * fix(floorplan): keep annotations clear and restore registry boundaries * feat(floorplan): refine construction dimension references * fix(floorplan): align documentation tools with architecture --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2adb50a340
commit
77442861d9
@@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { BuildTab } from '@/components/build-tab'
|
||||
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
|
||||
import {
|
||||
CommunityViewerToolbarLeft,
|
||||
CommunityViewerToolbarRight,
|
||||
@@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor'
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen">
|
||||
<FloorplanConstructionPreflight />
|
||||
{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-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">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { nodeRegistry } from '@pascal-app/core'
|
||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
getFloorplanNodeExtension,
|
||||
MaterialPaintPanel,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -13,24 +18,6 @@ import {
|
||||
} from '@/components/toolbar-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
|
||||
* editor's `StructureTool` ids.
|
||||
*/
|
||||
type BuildToolKind =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'stair'
|
||||
| 'elevator'
|
||||
| 'door'
|
||||
| 'window'
|
||||
| 'column'
|
||||
| 'shelf'
|
||||
| 'spawn'
|
||||
|
||||
/**
|
||||
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
|
||||
* group tile — its own sub-grid, like Roof's "Features".
|
||||
@@ -53,7 +40,8 @@ type BuildType = {
|
||||
/** Raster asset tile (legacy Build sidebar artwork). */
|
||||
iconSrc: string
|
||||
/** Present for structure-tool types (absent for paint mode and the MEP group). */
|
||||
kind?: BuildToolKind
|
||||
kind?: string
|
||||
paletteOrder?: number
|
||||
/** Non-placement special mode. */
|
||||
mode?: 'material-paint'
|
||||
}
|
||||
@@ -67,7 +55,7 @@ type MepItem = {
|
||||
}
|
||||
|
||||
// Same icons + ordering as the community Build sidebar, minus presets.
|
||||
const BUILD_TYPES: BuildType[] = [
|
||||
const BASE_BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
|
||||
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
|
||||
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' },
|
||||
@@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
|
||||
]
|
||||
|
||||
function collectBuildTypes(): BuildType[] {
|
||||
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
|
||||
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
|
||||
...type,
|
||||
paletteOrder:
|
||||
nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10,
|
||||
}))
|
||||
for (const [kind, definition] of nodeRegistry.entries()) {
|
||||
const presentation = definition.presentation
|
||||
const extension = getFloorplanNodeExtension(definition)
|
||||
if (
|
||||
baseKinds.has(kind) ||
|
||||
!extension?.tool ||
|
||||
!presentation ||
|
||||
presentation.hidden ||
|
||||
presentation.paletteSection !== 'structure'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
tools.push({
|
||||
id: kind,
|
||||
kind,
|
||||
label: presentation.label,
|
||||
iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp',
|
||||
paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
}
|
||||
tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0))
|
||||
return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)]
|
||||
}
|
||||
|
||||
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
|
||||
// tools had in the community Build sidebar.
|
||||
const MEP_ITEMS: MepItem[] = [
|
||||
@@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [
|
||||
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
|
||||
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
|
||||
*/
|
||||
function activateBuildTool(kind: BuildToolKind | MepToolKind): void {
|
||||
function activateBuildTool(kind: string): void {
|
||||
const ed = useEditor.getState()
|
||||
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
|
||||
if (preferredView) ed.setViewMode(preferredView)
|
||||
ed.setPhase('structure')
|
||||
ed.setStructureLayer('elements')
|
||||
ed.setCatalogCategory(null)
|
||||
@@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void {
|
||||
ed.setStructureLayer('elements')
|
||||
ed.setCatalogCategory(null)
|
||||
ed.setMode('build')
|
||||
ed.setTool(kind as Parameters<typeof ed.setTool>[0])
|
||||
ed.setTool(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +186,7 @@ export function BuildTab() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||
const buildTypes = useMemo(collectBuildTypes, [])
|
||||
|
||||
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||
@@ -245,9 +267,9 @@ export function BuildTab() {
|
||||
didInitRef.current = true
|
||||
const ed = useEditor.getState()
|
||||
if (ed.mode === 'build' && ed.tool) return
|
||||
const firstType = BUILD_TYPES.find((t) => t.kind)
|
||||
const firstType = buildTypes.find((t) => t.kind)
|
||||
if (firstType) handleTypeClick(firstType)
|
||||
}, [handleTypeClick])
|
||||
}, [buildTypes, handleTypeClick])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
@@ -256,7 +278,7 @@ export function BuildTab() {
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
|
||||
>
|
||||
{BUILD_TYPES.map((type) => {
|
||||
{buildTypes.map((type) => {
|
||||
const active = isTypeActive(type)
|
||||
return (
|
||||
<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
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
import {
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useDrawingView,
|
||||
useEditor,
|
||||
useFloorplanAnnotationVisibility,
|
||||
useSidebarStore,
|
||||
type ViewMode,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -32,12 +35,16 @@ import {
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Grid2X2,
|
||||
Layers3,
|
||||
Magnet,
|
||||
PenLine,
|
||||
Ruler,
|
||||
ScanLine,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
SquareUserRound,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { type ReactNode, useCallback } from 'react'
|
||||
@@ -133,6 +140,22 @@ const SHADING_OPTIONS = [
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_ANNOTATION_OPTIONS = [
|
||||
{ id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler },
|
||||
{ id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler },
|
||||
{ id: 'measurements', name: 'Measurements', icon: ScanLine },
|
||||
{ id: 'openingMarks', name: 'Door/window marks', icon: Tag },
|
||||
{ id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 },
|
||||
{ id: 'roomLabels', name: 'Room labels', icon: SquareUserRound },
|
||||
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
|
||||
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
|
||||
{ id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
|
||||
{ id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' },
|
||||
] as const
|
||||
|
||||
function ViewModeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const setViewMode = useEditor((state) => state.setViewMode)
|
||||
@@ -165,6 +188,49 @@ function ViewModeControl() {
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingTypeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const drawingType = useDrawingView((state) => state.drawingType)
|
||||
const setDrawingType = useDrawingView((state) => state.setDrawingType)
|
||||
if (viewMode === '3d') return null
|
||||
|
||||
const active =
|
||||
DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0]
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
@@ -278,12 +344,15 @@ const EDGE_OPTIONS = [
|
||||
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
|
||||
|
||||
function DisplayMenu() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const setShowGrid = useViewer((state) => state.setShowGrid)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
const setMetricNotation = useViewer((state) => state.setMetricNotation)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||
const shading = useViewer((state) => state.shading)
|
||||
@@ -296,6 +365,14 @@ function DisplayMenu() {
|
||||
const setShadows = useViewer((state) => state.setShadows)
|
||||
const magneticSnap = useEditor((state) => state.magneticSnap)
|
||||
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
|
||||
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
|
||||
const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory)
|
||||
const wallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.wallDimensionReference,
|
||||
)
|
||||
const setWallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.setWallDimensionReference,
|
||||
)
|
||||
|
||||
const activeShading =
|
||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||
@@ -337,17 +414,82 @@ function DisplayMenu() {
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{viewMode !== '2d' ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
|
||||
>
|
||||
<Ruler className="h-4 w-4" />
|
||||
<span>Measurements</span>
|
||||
<span>{viewMode === 'split' ? '3D measurements' : 'Measurements'}</span>
|
||||
{showMeasurements ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</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))}>
|
||||
<Magnet className="h-4 w-4" />
|
||||
<span>Magnetic snap</span>
|
||||
@@ -377,17 +519,48 @@ function DisplayMenu() {
|
||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
|
||||
>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<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>Units</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{unit === 'metric' ? 'Metric' : 'Imperial'}
|
||||
{unit === 'imperial'
|
||||
? 'Feet & inches'
|
||||
: metricNotation === 'millimeters'
|
||||
? 'Millimeters'
|
||||
: 'Meters'}
|
||||
</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 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 />
|
||||
|
||||
@@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() {
|
||||
<>
|
||||
<CollapseSidebarButton />
|
||||
<ViewModeControl />
|
||||
<DrawingTypeControl />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"dedent": "^1.7.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
"packages/editor": {
|
||||
"name": "@pascal-app/editor",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -145,35 +145,37 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@visual-json/react": "^0.4.0",
|
||||
"blob-stream": "^0.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "~0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"next": ">=15",
|
||||
@@ -201,7 +203,7 @@
|
||||
},
|
||||
"packages/ifc-converter": {
|
||||
"name": "@pascal-app/ifc-converter",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -215,7 +217,7 @@
|
||||
},
|
||||
"packages/mcp": {
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"bin": {
|
||||
"pascal-mcp": "./dist/bin/pascal-mcp.js",
|
||||
},
|
||||
@@ -225,22 +227,22 @@
|
||||
"zod": "^4.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^22.19.20",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
},
|
||||
},
|
||||
"packages/nodes": {
|
||||
"name": "@pascal-app/nodes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.0",
|
||||
"@pascal-app/editor": "^0.9.0",
|
||||
"@pascal-app/viewer": "^0.9.0",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/editor": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/node": "^22.19.12",
|
||||
@@ -249,9 +251,9 @@
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.0",
|
||||
"@pascal-app/editor": "^0.9.0",
|
||||
"@pascal-app/viewer": "^0.9.0",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/editor": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"lucide-react": "^1",
|
||||
@@ -283,7 +285,7 @@
|
||||
},
|
||||
"packages/viewer": {
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"three-bvh-csg": "^0.0.18",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
@@ -297,7 +299,7 @@
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"react": "^18 || ^19",
|
||||
@@ -538,6 +540,10 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -852,6 +858,8 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
|
||||
@@ -868,9 +876,7 @@
|
||||
|
||||
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
"@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
@@ -882,8 +888,6 @@
|
||||
|
||||
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
@@ -978,8 +982,6 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="],
|
||||
@@ -988,12 +990,20 @@
|
||||
|
||||
"bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
|
||||
|
||||
"blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="],
|
||||
|
||||
"blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
@@ -1014,8 +1024,6 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
@@ -1030,6 +1038,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
@@ -1056,18 +1066,12 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
@@ -1100,9 +1104,9 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||
|
||||
@@ -1202,8 +1206,6 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
@@ -1232,7 +1234,7 @@
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="],
|
||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
@@ -1302,8 +1304,6 @@
|
||||
|
||||
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -1330,8 +1330,6 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -1404,6 +1402,8 @@
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
@@ -1422,8 +1422,6 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
@@ -1460,6 +1458,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
@@ -1580,7 +1580,7 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
|
||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
@@ -1598,7 +1598,7 @@
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
"pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
@@ -1606,6 +1606,8 @@
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
@@ -1632,8 +1634,6 @@
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -1660,8 +1660,6 @@
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
@@ -1674,9 +1672,9 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
@@ -1726,10 +1724,6 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
||||
|
||||
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
|
||||
@@ -1768,12 +1762,6 @@
|
||||
|
||||
"suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="],
|
||||
|
||||
"svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
@@ -1782,8 +1770,6 @@
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
|
||||
|
||||
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
|
||||
@@ -1792,6 +1778,8 @@
|
||||
|
||||
"three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
@@ -1844,6 +1832,10 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
|
||||
|
||||
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
|
||||
@@ -1860,8 +1852,6 @@
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -1962,6 +1952,8 @@
|
||||
|
||||
"agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
|
||||
|
||||
"deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -1978,6 +1970,8 @@
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
@@ -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,10 +9,12 @@ import type {
|
||||
CeilingNode,
|
||||
ChimneyNode,
|
||||
ColumnNode,
|
||||
ConstructionDimensionNode,
|
||||
CupolaNode,
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
DuctTerminalNode,
|
||||
@@ -42,6 +44,7 @@ import type {
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
StructuralGridNode,
|
||||
TurbineVentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
|
||||
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||
export type ColumnEvent = NodeEvent<ColumnNode>
|
||||
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
|
||||
export type RoofEvent = NodeEvent<RoofNode>
|
||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||
export type StairEvent = NodeEvent<StairNode>
|
||||
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
|
||||
export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
|
||||
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'spawn', SpawnEvent> &
|
||||
NodeEvents<'ceiling', CeilingEvent> &
|
||||
NodeEvents<'column', ColumnEvent> &
|
||||
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
|
||||
NodeEvents<'roof', RoofEvent> &
|
||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||
NodeEvents<'stair', StairEvent> &
|
||||
NodeEvents<'stair-segment', StairSegmentEvent> &
|
||||
NodeEvents<'structural-grid', StructuralGridEvent> &
|
||||
NodeEvents<'window', WindowEvent> &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
NodeEvents<'scan', ScanEvent> &
|
||||
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
NodeEvents<'downspout', DownspoutEvent> &
|
||||
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
|
||||
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||
|
||||
@@ -9,8 +9,10 @@ export type {
|
||||
CeilingEvent,
|
||||
ChimneyEvent,
|
||||
ColumnEvent,
|
||||
ConstructionDimensionEvent,
|
||||
DoorEvent,
|
||||
DormerEvent,
|
||||
DrawingSheetEvent,
|
||||
ElevatorEvent,
|
||||
EventSuffix,
|
||||
FenceEvent,
|
||||
@@ -34,6 +36,7 @@ export type {
|
||||
SpawnEvent,
|
||||
StairEvent,
|
||||
StairSegmentEvent,
|
||||
StructuralGridEvent,
|
||||
WallEvent,
|
||||
WindowEvent,
|
||||
ZoneEvent,
|
||||
@@ -88,6 +91,7 @@ export {
|
||||
closestMeasurementFeatureBinding,
|
||||
MEASUREMENT_PLANAR_TOLERANCE,
|
||||
measurementAnchorFallback,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementAreaVector,
|
||||
@@ -98,6 +102,7 @@ export {
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
measurementReferenceNodeIds,
|
||||
remapMeasurementAnchors,
|
||||
remapMeasurementReferences,
|
||||
} from './lib/measurement-geometry'
|
||||
export {
|
||||
@@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise'
|
||||
export {
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallArcData,
|
||||
getWallChordFrame,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { MeasurementFeature } from '../registry/types'
|
||||
import type { MeasurementPoint } from '../schema/nodes/measurement'
|
||||
import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
|
||||
import {
|
||||
areMeasurementPointsCoplanar,
|
||||
closestMeasurementFeatureBinding,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementAreaVector,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
measurementNormal,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
remapMeasurementAnchors,
|
||||
} from './measurement-geometry'
|
||||
|
||||
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
|
||||
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
|
||||
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
|
||||
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
|
||||
})
|
||||
|
||||
test('remaps and collects references for arbitrary anchor strings', () => {
|
||||
const anchors: MeasurementAnchor[] = [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
|
||||
fallback: [4, 0, 0],
|
||||
},
|
||||
]
|
||||
|
||||
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
|
||||
const remapped = remapMeasurementAnchors(
|
||||
anchors,
|
||||
new Map([
|
||||
['wall_a', 'wall_a_copy'],
|
||||
['wall_b', 'wall_b_copy'],
|
||||
]),
|
||||
)
|
||||
const first = remapped[0]!
|
||||
const last = remapped[2]!
|
||||
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
|
||||
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
|
||||
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
|
||||
import type {
|
||||
MeasurementAnchor,
|
||||
MeasurementPayload,
|
||||
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
|
||||
measurement: MeasurementPayload,
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): MeasurementPayload {
|
||||
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const nodeId = idMap.get(anchor.reference.nodeId)
|
||||
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
|
||||
}
|
||||
const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
|
||||
remapMeasurementAnchors([anchor], idMap)[0]!
|
||||
|
||||
switch (measurement.kind) {
|
||||
case 'distance':
|
||||
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
|
||||
}
|
||||
}
|
||||
|
||||
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
|
||||
const anchors =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points
|
||||
: measurement.base
|
||||
export function remapMeasurementAnchors(
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): MeasurementAnchor[] {
|
||||
return anchors.map((anchor) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const nodeId = idMap.get(anchor.reference.nodeId)
|
||||
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
|
||||
})
|
||||
}
|
||||
|
||||
export function remapConstructionDimensionReferences(
|
||||
dimension: ConstructionDimensionNode,
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): ConstructionDimensionNode {
|
||||
const controllingDimensionId = dimension.controllingDimensionId
|
||||
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
|
||||
dimension.controllingDimensionId)
|
||||
: null
|
||||
return {
|
||||
...dimension,
|
||||
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
|
||||
controllingDimensionId,
|
||||
}
|
||||
}
|
||||
|
||||
export function measurementAnchorReferenceNodeIds(
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
): AnyNodeId[] {
|
||||
const ids = new Set<string>()
|
||||
for (const anchor of anchors) {
|
||||
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
|
||||
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
|
||||
return [...ids] as AnyNodeId[]
|
||||
}
|
||||
|
||||
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
|
||||
const anchors =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points
|
||||
: measurement.base
|
||||
return measurementAnchorReferenceNodeIds(anchors)
|
||||
}
|
||||
|
||||
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
|
||||
if (points.length < 3) return [0, 0, 0]
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue {
|
||||
|
||||
export function deriveZoneQuantityReport(
|
||||
zone: ZoneNode,
|
||||
sceneNodes: Record<string, AnyNode>,
|
||||
sceneNodes: Readonly<Record<string, AnyNode>>,
|
||||
): ZoneQuantityReport {
|
||||
const levelId = zone.parentId
|
||||
const levelNodes = levelId
|
||||
|
||||
@@ -65,6 +65,8 @@ export type {
|
||||
Capabilities,
|
||||
CapabilityCtx,
|
||||
CuttableConfig,
|
||||
DimensionTerminator,
|
||||
DimensionTextPosition,
|
||||
DistributionRole,
|
||||
DragAction,
|
||||
DuplicableConfig,
|
||||
@@ -88,6 +90,7 @@ export type {
|
||||
FloorplanPoint,
|
||||
FloorplanStyle,
|
||||
GeometryContext,
|
||||
GroupMoveSnapArgs,
|
||||
HostableConfig,
|
||||
IconRef,
|
||||
Issue,
|
||||
|
||||
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
|
||||
) {
|
||||
const anchor = clonedMeasurement.measurement.points[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (!Array.isArray(anchor)) {
|
||||
if (anchor && !Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
|
||||
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
|
||||
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[1, 0, 0],
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
|
||||
fallback: [2, 0, 0],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
})
|
||||
const result = cloneNodesInto([wall, dimension], {
|
||||
rootId: 'wall_1' as AnyNodeId,
|
||||
})
|
||||
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
|
||||
|
||||
expect(clonedDimension?.type).toBe('construction-dimension')
|
||||
if (clonedDimension?.type === 'construction-dimension') {
|
||||
const anchor = clonedDimension.anchors[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (anchor && !Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
const lastAnchor = clonedDimension.anchors[2]
|
||||
expect(Array.isArray(lastAnchor)).toBe(false)
|
||||
if (lastAnchor && !Array.isArray(lastAnchor)) {
|
||||
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('remaps a construction dimension foundation controller when both are cloned', () => {
|
||||
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
drawingType: 'foundation-plan',
|
||||
})
|
||||
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
controllingDimensionId: controller.id,
|
||||
})
|
||||
|
||||
const result = cloneNodesInto([controller, dependent], {
|
||||
rootId: controller.id as AnyNodeId,
|
||||
})
|
||||
const clonedDependent = result.nodes.find(
|
||||
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
|
||||
)
|
||||
|
||||
expect(clonedDependent?.type).toBe('construction-dimension')
|
||||
if (clonedDependent?.type === 'construction-dimension') {
|
||||
expect(clonedDependent.controllingDimensionId).toBe(
|
||||
result.idMap.get(
|
||||
controller.id as AnyNodeId,
|
||||
) as typeof clonedDependent.controllingDimensionId,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('regenerates drawing-sheet identities while preserving external level references', () => {
|
||||
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [1, 1],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
})
|
||||
|
||||
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
|
||||
const cloned = nodes[0]
|
||||
|
||||
expect(cloned?.type).toBe('drawing-sheet')
|
||||
if (cloned?.type === 'drawing-sheet') {
|
||||
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
|
||||
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
|
||||
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
|
||||
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('parents the cloned root under opts.parentId when supplied', () => {
|
||||
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
|
||||
const { nodes } = cloneNodesInto([orig], {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { remapMeasurementReferences } from '../lib/measurement-geometry'
|
||||
import {
|
||||
remapConstructionDimensionReferences,
|
||||
remapMeasurementReferences,
|
||||
} from '../lib/measurement-geometry'
|
||||
import { generateId } from '../schema/base'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
// Generic, opinion-free primitives the host app composes to implement
|
||||
@@ -141,7 +145,7 @@ export function cloneNodesInto(
|
||||
const out: AnyNode[] = []
|
||||
let root: AnyNode | null = null
|
||||
for (const original of nodes) {
|
||||
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
|
||||
let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
|
||||
const freshId = idMap.get(original.id)!
|
||||
;(cloned as { id: AnyNodeId }).id = freshId
|
||||
// parentId: root's parentId becomes opts.parentId (or preserved
|
||||
@@ -169,6 +173,12 @@ export function cloneNodesInto(
|
||||
if (cloned.type === 'measurement') {
|
||||
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
|
||||
}
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
if (original.id === opts.rootId) {
|
||||
if (opts.position) {
|
||||
|
||||
@@ -51,6 +51,8 @@ export type GeometryContext = {
|
||||
* `scene:` refs.
|
||||
*/
|
||||
materials?: Record<SceneMaterialId, SceneMaterial>
|
||||
/** Opaque host/plugin context. Core never interprets extension values. */
|
||||
extensions?: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Optional view state — only populated for `def.floorplan` builders. The
|
||||
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
|
||||
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
|
||||
|
||||
export type FloorplanPoint = readonly [x: number, y: number]
|
||||
|
||||
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
|
||||
|
||||
export type DimensionTextPosition = 'above' | 'centered'
|
||||
|
||||
export type FloorplanStyle = {
|
||||
stroke?: string
|
||||
fill?: string
|
||||
strokeWidth?: number
|
||||
strokeDasharray?: string
|
||||
opacity?: number
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
|
||||
* as a constant screen-pixel width regardless of viewport zoom. Maps
|
||||
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
|
||||
* of the floor-plan's scene rotation (default 90°).
|
||||
*/
|
||||
upright?: boolean
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/**
|
||||
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
|
||||
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
|
||||
children: FloorplanGeometry[]
|
||||
/** Optional transform applied to all children. Rotation in radians. */
|
||||
transform?: { translate?: FloorplanPoint; rotate?: number }
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/**
|
||||
* Hatched fill overlay — same polygon shape as the kind's main fill but
|
||||
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
|
||||
kind: 'dimension'
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint
|
||||
/**
|
||||
* Optional explicit dimension-line endpoints. Use these when the
|
||||
* measured origins sit at different depths, such as stepped facades or
|
||||
* an exterior column row. Extension lines still originate at
|
||||
* `start`/`end`, while the measurement is drawn between these aligned
|
||||
* baseline points.
|
||||
*/
|
||||
dimensionStart?: FloorplanPoint
|
||||
dimensionEnd?: FloorplanPoint
|
||||
/** Outward-pointing unit normal — the dimension line offsets along this. */
|
||||
offsetNormal: FloorplanPoint
|
||||
/** Distance (plan units) from the edge to the dimension line. */
|
||||
offsetDistance: number
|
||||
/** How far past the offset point the extension line continues. */
|
||||
extensionOvershoot: number
|
||||
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
|
||||
extensionStartGap?: number
|
||||
/** Dimension-line terminator. Defaults to an architectural tick. */
|
||||
terminator?: DimensionTerminator
|
||||
/** Dimension text position relative to the baseline. Defaults above the line. */
|
||||
textPosition?: DimensionTextPosition
|
||||
text: string
|
||||
/** Optional override for the line/text colour. Defaults to the palette accent. */
|
||||
stroke?: string
|
||||
}
|
||||
| {
|
||||
kind: 'dimension-string'
|
||||
segments: readonly {
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint
|
||||
/**
|
||||
* Optional explicit dimension-line endpoints. Use these when the
|
||||
* measured origins sit at different depths, such as stepped facades or
|
||||
* an exterior column row. Extension lines still originate at
|
||||
* `start`/`end`, while the measurement is drawn between these aligned
|
||||
* baseline points.
|
||||
*/
|
||||
dimensionStart?: FloorplanPoint
|
||||
dimensionEnd?: FloorplanPoint
|
||||
text: string
|
||||
}[]
|
||||
/** Outward-pointing unit normal shared by every segment in the string. */
|
||||
offsetNormal: FloorplanPoint
|
||||
/** Distance (plan units) from each measured origin to its dimension line. */
|
||||
offsetDistance: number
|
||||
/** How far past each offset point the extension line continues. */
|
||||
extensionOvershoot: number
|
||||
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
|
||||
extensionStartGap?: number
|
||||
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
|
||||
terminator?: DimensionTerminator
|
||||
/** Dimension text position shared by every segment. Defaults above the line. */
|
||||
textPosition?: DimensionTextPosition
|
||||
/** Optional override for the line/text colour. Defaults to the palette accent. */
|
||||
stroke?: string
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// ─── FloorplanAffordance ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
/** Opaque host/plugin contributions. Core stores but never interprets them. */
|
||||
extensions?: Readonly<Record<string, unknown>>
|
||||
surfaceRole?: SurfaceRole
|
||||
/**
|
||||
* Show a floor direction-triangle while placing/moving — the kind has a
|
||||
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
portConnectivityFollow?: boolean
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
|
||||
capabilities: Capabilities
|
||||
relations?: Relations
|
||||
|
||||
@@ -51,8 +51,33 @@ export {
|
||||
ColumnStyle,
|
||||
ColumnSupportStyle,
|
||||
} from './nodes/column'
|
||||
export {
|
||||
CONSTRUCTION_DRAWING_TYPES,
|
||||
ConstructionDimensionBaseline,
|
||||
ConstructionDimensionChainMode,
|
||||
ConstructionDimensionDatumPolicy,
|
||||
ConstructionDimensionDrawingOverride,
|
||||
ConstructionDimensionDrawingPresentation,
|
||||
ConstructionDimensionImperialPrecision,
|
||||
ConstructionDimensionMetricNotation,
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
ConstructionDimensionTerminator,
|
||||
ConstructionDimensionTextPosition,
|
||||
ConstructionDrawingType,
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingSuppressedSegments,
|
||||
} from './nodes/construction-dimension'
|
||||
export { CupolaNode } from './nodes/cupola'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export {
|
||||
DoorNode,
|
||||
DoorSegment,
|
||||
OpeningConstructionType,
|
||||
OpeningDimensionReference,
|
||||
} from './nodes/door'
|
||||
export {
|
||||
DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
@@ -60,6 +85,25 @@ export {
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
remapDrawingSheetReferences,
|
||||
} from './nodes/drawing-sheet'
|
||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -200,9 +244,13 @@ export {
|
||||
StairType,
|
||||
} from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { StructuralGridNode } from './nodes/structural-grid'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { TurbineVentNode } from './nodes/turbine-vent'
|
||||
export type {
|
||||
WallAssemblyDatumReference,
|
||||
WallAssemblyDatumSide,
|
||||
WallAssemblyLayer,
|
||||
WallBandSurfaceSlotId,
|
||||
WallFaceBand,
|
||||
WallFaceBandConfig,
|
||||
@@ -215,11 +263,18 @@ export {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyLayers,
|
||||
getWallAssemblyThickness,
|
||||
getWallBandSlotId,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallSurfaceMaterialSignature,
|
||||
getWallSurfaceSideFromBandSlot,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -230,11 +285,18 @@ export {
|
||||
WALL_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WALL_TRIM_DEFAULTS,
|
||||
WallAssemblyLayerRole,
|
||||
WallDimensionDatum,
|
||||
WallNode,
|
||||
WallTreatmentSide,
|
||||
WallTrimProfile,
|
||||
} from './nodes/wall'
|
||||
export { WindowNode, WindowType } from './nodes/window'
|
||||
export {
|
||||
WindowConstructionType,
|
||||
WindowDimensionReference,
|
||||
WindowNode,
|
||||
WindowType,
|
||||
} from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { DrawingSheetNode } from './drawing-sheet'
|
||||
import { ElevatorNode } from './elevator'
|
||||
import { LevelNode } from './level'
|
||||
|
||||
export const BuildingNode = BaseNode.extend({
|
||||
id: objectId('building'),
|
||||
type: nodeType('building'),
|
||||
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
|
||||
children: z
|
||||
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
|
||||
.default([]),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
}).describe(
|
||||
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
|
||||
Building node - used to represent a building
|
||||
- position: position in site coordinate system
|
||||
- rotation: rotation in site coordinate system
|
||||
- children: array of level nodes and building-level systems such as elevators
|
||||
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
ConstructionDimensionNode,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingSuppressedSegments,
|
||||
} from './construction-dimension'
|
||||
|
||||
describe('ConstructionDimensionNode', () => {
|
||||
test('creates valid free-anchor defaults', () => {
|
||||
const node = ConstructionDimensionNode.parse({})
|
||||
|
||||
expect(node.type).toBe('construction-dimension')
|
||||
expect(node.id).toMatch(/^construction-dimension_/)
|
||||
expect(node.anchors).toEqual([
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
])
|
||||
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
|
||||
expect(node.chainMode).toBe('point-to-point')
|
||||
expect(node).toMatchObject({
|
||||
mode: 'linear',
|
||||
featureCount: 1,
|
||||
showCenterMark: true,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
textOverride: null,
|
||||
datumPolicy: 'centerline',
|
||||
terminator: 'architectural-tick',
|
||||
textPosition: 'above',
|
||||
imperialPrecision: '1/16',
|
||||
metricNotation: 'meters',
|
||||
extensionStartGap: 0.075,
|
||||
extensionOvershoot: 0.12,
|
||||
drawingType: 'floor-plan',
|
||||
drawingOverrides: [],
|
||||
controllingDimensionId: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
|
||||
expect(
|
||||
ConstructionDimensionNode.safeParse({
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0],
|
||||
},
|
||||
[3, 0, 0],
|
||||
],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
ConstructionDimensionNode.safeParse({
|
||||
baseline: { origin: [0, 0], direction: [0, 0] },
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts continuous strings with three or more anchors', () => {
|
||||
expect(
|
||||
ConstructionDimensionNode.safeParse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
],
|
||||
chainMode: 'continuous',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
|
||||
.success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts curved and circular notation settings', () => {
|
||||
expect(
|
||||
ConstructionDimensionNode.safeParse({
|
||||
mode: 'diameter',
|
||||
featureCount: 6,
|
||||
prefix: 'TYP · ',
|
||||
suffix: ' CLR',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
|
||||
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
|
||||
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
|
||||
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
datumPolicy: 'finish-face',
|
||||
terminator: 'filled-arrow',
|
||||
textPosition: 'centered',
|
||||
imperialPrecision: '1/8',
|
||||
metricNotation: 'millimeters',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
|
||||
expect(node).toMatchObject({
|
||||
datumPolicy: 'finish-face',
|
||||
terminator: 'filled-arrow',
|
||||
textPosition: 'centered',
|
||||
imperialPrecision: '1/8',
|
||||
metricNotation: 'millimeters',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
|
||||
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('coordinates one associative dimension across persistent drawing types', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
drawingType: 'foundation-plan',
|
||||
drawingOverrides: [
|
||||
{ drawingType: 'floor-plan', presentation: 'controlled' },
|
||||
{ drawingType: 'roof-plan', presentation: 'shown' },
|
||||
],
|
||||
controllingDimensionId: 'construction-dimension_foundation',
|
||||
})
|
||||
|
||||
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
|
||||
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
|
||||
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
|
||||
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
|
||||
})
|
||||
|
||||
test('stores only drawing presentations that differ from the primary defaults', () => {
|
||||
const node = ConstructionDimensionNode.parse({})
|
||||
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
|
||||
expect(shown).toEqual([
|
||||
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
|
||||
])
|
||||
expect(
|
||||
setConstructionDimensionDrawingPresentation(
|
||||
{ ...node, drawingOverrides: shown },
|
||||
'roof-plan',
|
||||
'omit',
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
|
||||
const node = ConstructionDimensionNode.parse({})
|
||||
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
|
||||
node,
|
||||
'floor-plan',
|
||||
[3, 1, 1, -1],
|
||||
)
|
||||
|
||||
expect(drawingOverrides).toEqual([
|
||||
{
|
||||
drawingType: 'floor-plan',
|
||||
presentation: 'shown',
|
||||
suppressedSegmentIndexes: [1, 3],
|
||||
},
|
||||
])
|
||||
expect(
|
||||
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
|
||||
?.suppressedSegmentIndexes,
|
||||
).toEqual([1, 3])
|
||||
expect(
|
||||
setConstructionDimensionDrawingSuppressedSegments(
|
||||
{ ...node, drawingOverrides },
|
||||
'floor-plan',
|
||||
[],
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MeasurementAnchor } from './measurement'
|
||||
|
||||
const FiniteCoordinate = z.number().finite()
|
||||
|
||||
export const ConstructionDimensionBaseline = z
|
||||
.object({
|
||||
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
|
||||
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
|
||||
})
|
||||
.superRefine((baseline, ctx) => {
|
||||
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['direction'],
|
||||
message: 'Construction dimension baseline direction must be non-zero',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
|
||||
export const ConstructionDimensionMode = z.enum([
|
||||
'linear',
|
||||
'radius',
|
||||
'diameter',
|
||||
'center-mark',
|
||||
'chord',
|
||||
'arc-length',
|
||||
'angular',
|
||||
'coordinate',
|
||||
])
|
||||
export const ConstructionDrawingType = z.enum([
|
||||
'floor-plan',
|
||||
'foundation-plan',
|
||||
'reflected-ceiling-plan',
|
||||
'roof-plan',
|
||||
'site-plan',
|
||||
])
|
||||
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
|
||||
export const ConstructionDimensionDrawingOverride = z.object({
|
||||
drawingType: ConstructionDrawingType,
|
||||
presentation: ConstructionDimensionDrawingPresentation,
|
||||
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
|
||||
})
|
||||
export const ConstructionDimensionDatumPolicy = z.enum([
|
||||
'centerline',
|
||||
'wall-face',
|
||||
'structural-face',
|
||||
'finish-face',
|
||||
])
|
||||
export const ConstructionDimensionTerminator = z.enum([
|
||||
'architectural-tick',
|
||||
'filled-arrow',
|
||||
'open-arrow',
|
||||
'dot',
|
||||
])
|
||||
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
|
||||
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
|
||||
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
|
||||
|
||||
export const ConstructionDimensionNode = BaseNode.extend({
|
||||
id: objectId('construction-dimension'),
|
||||
type: nodeType('construction-dimension'),
|
||||
anchors: z
|
||||
.array(MeasurementAnchor)
|
||||
.min(2)
|
||||
.default([
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
]),
|
||||
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
|
||||
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
|
||||
mode: ConstructionDimensionMode.default('linear'),
|
||||
featureCount: z.number().int().min(1).max(999).default(1),
|
||||
showCenterMark: z.boolean().default(true),
|
||||
prefix: z.string().max(40).default(''),
|
||||
suffix: z.string().max(40).default(''),
|
||||
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
|
||||
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
|
||||
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
|
||||
textPosition: ConstructionDimensionTextPosition.default('above'),
|
||||
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
|
||||
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
|
||||
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
|
||||
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
|
||||
drawingType: ConstructionDrawingType.default('floor-plan'),
|
||||
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
|
||||
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
|
||||
}).describe(
|
||||
dedent`
|
||||
Construction dimension node - an associative floor-plan construction dimension
|
||||
- anchors: two or more free or semantic feature anchors that supply the witness origins
|
||||
- baseline.origin: a point on the independently placed dimension line
|
||||
- baseline.direction: the fixed plan direction used to project the witness origins
|
||||
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
|
||||
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
|
||||
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
|
||||
- showCenterMark: displays the resolved circle/angle center where applicable
|
||||
- prefix/suffix/textOverride: document notation overrides without changing geometry
|
||||
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
|
||||
- drawingType: the primary persistent drawing that owns the dimension
|
||||
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
|
||||
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
|
||||
`,
|
||||
)
|
||||
|
||||
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
|
||||
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
|
||||
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
|
||||
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
|
||||
export type ConstructionDimensionDrawingPresentation = z.infer<
|
||||
typeof ConstructionDimensionDrawingPresentation
|
||||
>
|
||||
export type ConstructionDimensionDrawingOverride = z.infer<
|
||||
typeof ConstructionDimensionDrawingOverride
|
||||
>
|
||||
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
|
||||
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
|
||||
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
|
||||
export type ConstructionDimensionImperialPrecision = z.infer<
|
||||
typeof ConstructionDimensionImperialPrecision
|
||||
>
|
||||
export type ConstructionDimensionMetricNotation = z.infer<
|
||||
typeof ConstructionDimensionMetricNotation
|
||||
>
|
||||
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
|
||||
|
||||
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
|
||||
|
||||
export function resolveConstructionDimensionDrawingPresentation(
|
||||
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
|
||||
drawingType: ConstructionDrawingType,
|
||||
): ConstructionDimensionDrawingPresentation {
|
||||
let override: ConstructionDimensionDrawingOverride | undefined
|
||||
for (const entry of node.drawingOverrides) {
|
||||
if (entry.drawingType === drawingType) override = entry
|
||||
}
|
||||
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
|
||||
}
|
||||
|
||||
export function resolveConstructionDimensionDrawingOverride(
|
||||
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
|
||||
drawingType: ConstructionDrawingType,
|
||||
): ConstructionDimensionDrawingOverride | null {
|
||||
let override: ConstructionDimensionDrawingOverride | undefined
|
||||
for (const entry of node.drawingOverrides) {
|
||||
if (entry.drawingType === drawingType) override = entry
|
||||
}
|
||||
return override ?? null
|
||||
}
|
||||
|
||||
export function setConstructionDimensionDrawingPresentation(
|
||||
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
|
||||
drawingType: ConstructionDrawingType,
|
||||
presentation: ConstructionDimensionDrawingPresentation,
|
||||
): ConstructionDimensionDrawingOverride[] {
|
||||
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
|
||||
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
|
||||
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
|
||||
const next = {
|
||||
drawingType,
|
||||
presentation,
|
||||
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
|
||||
}
|
||||
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
|
||||
? withoutDrawing
|
||||
: [...withoutDrawing, next]
|
||||
}
|
||||
|
||||
export function setConstructionDimensionDrawingSuppressedSegments(
|
||||
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
|
||||
drawingType: ConstructionDrawingType,
|
||||
suppressedSegmentIndexes: readonly number[],
|
||||
): ConstructionDimensionDrawingOverride[] {
|
||||
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
|
||||
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
|
||||
const presentation = existing?.presentation ?? defaultPresentation
|
||||
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
|
||||
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
|
||||
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
|
||||
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
|
||||
? withoutDrawing
|
||||
: [...withoutDrawing, next]
|
||||
}
|
||||
|
||||
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
|
||||
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
|
||||
}
|
||||
|
||||
function isDefaultConstructionDimensionDrawingOverride(
|
||||
override: ConstructionDimensionDrawingOverride,
|
||||
defaultPresentation: ConstructionDimensionDrawingPresentation,
|
||||
): boolean {
|
||||
return (
|
||||
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
|
||||
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
|
||||
(left, right) => left - right,
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
|
||||
export type DoorSegment = z.infer<typeof DoorSegment>
|
||||
|
||||
export const DoorCategory = z.enum(['interior', 'garage'])
|
||||
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
|
||||
export const OpeningDimensionReference = z.enum([
|
||||
'nominal',
|
||||
'rough-opening',
|
||||
'masonry-opening',
|
||||
'finish-opening',
|
||||
])
|
||||
export const DoorType = z.enum([
|
||||
'hinged',
|
||||
'double',
|
||||
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
|
||||
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
|
||||
|
||||
export type DoorCategory = z.infer<typeof DoorCategory>
|
||||
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
|
||||
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
|
||||
export type DoorType = z.infer<typeof DoorType>
|
||||
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
|
||||
|
||||
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
|
||||
width: z.number().default(0.9),
|
||||
height: z.number().default(2.1),
|
||||
|
||||
// Construction-document identity. `mark` overrides the deterministic
|
||||
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
|
||||
// because they are manufacturer/framing inputs, not safe derivations from
|
||||
// the nominal modeled size.
|
||||
mark: z.string().trim().max(16).optional(),
|
||||
constructionType: OpeningConstructionType.default('framed'),
|
||||
dimensionReference: OpeningDimensionReference.default('nominal'),
|
||||
roughOpeningWidth: z.number().positive().optional(),
|
||||
roughOpeningHeight: z.number().positive().optional(),
|
||||
masonryOpeningWidth: z.number().positive().optional(),
|
||||
masonryOpeningHeight: z.number().positive().optional(),
|
||||
finishOpeningWidth: z.number().positive().optional(),
|
||||
finishOpeningHeight: z.number().positive().optional(),
|
||||
|
||||
// Door family
|
||||
doorCategory: DoorCategory.default('interior'),
|
||||
doorType: DoorType.default('hinged'),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { BuildingNode } from './building'
|
||||
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
|
||||
|
||||
describe('DrawingSheetNode', () => {
|
||||
test('creates persistent sheet defaults', () => {
|
||||
const sheet = DrawingSheetNode.parse({})
|
||||
|
||||
expect(sheet.type).toBe('drawing-sheet')
|
||||
expect(sheet.id).toMatch(/^drawing-sheet_/)
|
||||
expect(sheet).toMatchObject({
|
||||
sheetNumber: 'A1.0',
|
||||
sheetTitle: 'Floor Plan',
|
||||
paperSize: 'arch-b',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
annotationProfile: 'architectural-default',
|
||||
placedViews: [],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [],
|
||||
keyedNoteInstances: [],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
titleBlock: {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('stores placed views, notes, schedules, and title-block metadata', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
sheetNumber: 'A2.1',
|
||||
sheetTitle: 'Enlarged Plans',
|
||||
paperSize: 'custom',
|
||||
customPaperWidth: 24,
|
||||
customPaperHeight: 36,
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '2',
|
||||
title: 'Main Floor Plan',
|
||||
levelId: 'level_main',
|
||||
scale: '1/4"=1\'-0"',
|
||||
viewport: { x: 1, y: 1, width: 12, height: 8 },
|
||||
},
|
||||
],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
|
||||
keyedNoteDefinitions: [
|
||||
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
|
||||
],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-1',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
},
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-2',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
position: [5, 4],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
|
||||
documentMarkers: [
|
||||
{
|
||||
id: 'sheet-marker_wall-a',
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [2, 3],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_revision-a',
|
||||
kind: 'revision-cloud',
|
||||
label: '1',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
],
|
||||
schedules: [
|
||||
{
|
||||
id: 'sheet-schedule_room',
|
||||
scheduleType: 'room',
|
||||
title: 'Room Schedule',
|
||||
region: { x: 15, y: 1, width: 6, height: 5 },
|
||||
},
|
||||
],
|
||||
titleBlock: {
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sheet.placedViews[0]).toMatchObject({
|
||||
drawingType: 'floor-plan',
|
||||
levelId: 'level_main',
|
||||
annotationProfile: 'architectural-default',
|
||||
showNorthArrow: true,
|
||||
showGraphicScale: true,
|
||||
})
|
||||
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
|
||||
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
|
||||
expect(sheet.generalNoteSets[0]).toMatchObject({
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ text: 'COORDINATE WITH OWNER.' }],
|
||||
})
|
||||
expect(sheet.keyedNoteLegend[0]).toEqual({
|
||||
key: 'A',
|
||||
text: 'ALIGN WITH EXISTING WALL.',
|
||||
})
|
||||
expect(sheet.keyedNoteDefinitions[0]).toEqual({
|
||||
id: 'keyed-note_patch-slab',
|
||||
key: 'A',
|
||||
text: 'PATCH EXISTING SLAB.',
|
||||
})
|
||||
expect(sheet.keyedNoteInstances).toHaveLength(2)
|
||||
expect(sheet.keyedNoteInstances[0]).toMatchObject({
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
})
|
||||
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
|
||||
expect(sheet.documentMarkers).toHaveLength(2)
|
||||
expect(sheet.documentMarkers[0]).toMatchObject({
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
position: [2, 3],
|
||||
})
|
||||
expect(sheet.documentMarkers[1]).toMatchObject({
|
||||
kind: 'revision-cloud',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
})
|
||||
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
|
||||
expect(sheet.titleBlock).toMatchObject({
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
drawnBy: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('can live under a building instead of a level', () => {
|
||||
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
|
||||
|
||||
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
|
||||
'level_main',
|
||||
sheet.id,
|
||||
])
|
||||
})
|
||||
|
||||
test('remaps sheet-local identities and their references together', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a1',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
},
|
||||
],
|
||||
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
|
||||
schedules: [{ id: 'sheet-schedule_a' }],
|
||||
})
|
||||
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
|
||||
|
||||
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
|
||||
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
|
||||
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
|
||||
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
|
||||
sheet.generalNoteSets[0]?.notes[0]?.id,
|
||||
)
|
||||
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
|
||||
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, generateId, nodeType, objectId } from '../base'
|
||||
import { ConstructionDrawingType } from './construction-dimension'
|
||||
|
||||
const PositiveFinite = z.number().finite().positive()
|
||||
const SheetCoordinate = z.number().finite().min(0)
|
||||
|
||||
export const DrawingSheetPaperSize = z.enum([
|
||||
'letter',
|
||||
'tabloid',
|
||||
'arch-a',
|
||||
'arch-b',
|
||||
'arch-c',
|
||||
'a4',
|
||||
'a3',
|
||||
'custom',
|
||||
])
|
||||
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
|
||||
export const DrawingSheetScale = z.enum([
|
||||
'1:20',
|
||||
'1:25',
|
||||
'1:50',
|
||||
'1:75',
|
||||
'1:100',
|
||||
'1/8"=1\'-0"',
|
||||
'1/4"=1\'-0"',
|
||||
'1/2"=1\'-0"',
|
||||
'1"=1\'-0"',
|
||||
])
|
||||
export const DrawingSheetAnnotationProfile = z.enum([
|
||||
'architectural-default',
|
||||
'presentation',
|
||||
'permit',
|
||||
])
|
||||
|
||||
export const DrawingSheetRect = z.object({
|
||||
x: SheetCoordinate.default(0),
|
||||
y: SheetCoordinate.default(0),
|
||||
width: PositiveFinite.default(1),
|
||||
height: PositiveFinite.default(1),
|
||||
})
|
||||
|
||||
export const DrawingSheetPlacedView = z.object({
|
||||
id: objectId('drawing-view'),
|
||||
drawingType: ConstructionDrawingType.default('floor-plan'),
|
||||
drawingNumber: z.string().trim().min(1).max(24).default('1'),
|
||||
title: z.string().trim().min(1).max(80).default('Floor Plan'),
|
||||
levelId: objectId('level').nullable().default(null),
|
||||
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
|
||||
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
showNorthArrow: z.boolean().default(true),
|
||||
showGraphicScale: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNote = z.object({
|
||||
id: objectId('sheet-note'),
|
||||
number: z.number().int().positive().default(1),
|
||||
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNoteSet = z.object({
|
||||
id: objectId('sheet-note-set'),
|
||||
name: z.string().trim().min(1).max(80).default('General Notes'),
|
||||
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNote = z.object({
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteDefinition = z.object({
|
||||
id: objectId('keyed-note'),
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteInstance = z.object({
|
||||
id: objectId('keyed-note-instance'),
|
||||
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
})
|
||||
|
||||
export const DrawingSheetDocumentMarkerKind = z.enum([
|
||||
'wall-tag',
|
||||
'glazing-tag',
|
||||
'assembly-tag',
|
||||
'section-callout',
|
||||
'elevation-callout',
|
||||
'detail-reference',
|
||||
'delta-marker',
|
||||
'revision-cloud',
|
||||
])
|
||||
|
||||
export const DrawingSheetDocumentMarker = z.object({
|
||||
id: objectId('sheet-marker'),
|
||||
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
label: z.string().trim().min(1).max(32).default('1'),
|
||||
title: z.string().trim().max(120).default(''),
|
||||
sheetReference: z.string().trim().max(24).default(''),
|
||||
drawingReference: z.string().trim().max(24).default(''),
|
||||
revisionId: z.string().trim().max(16).default(''),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
|
||||
points: z
|
||||
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
|
||||
.max(64)
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetSchedulePlacement = z.object({
|
||||
id: objectId('sheet-schedule'),
|
||||
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
|
||||
title: z.string().trim().min(1).max(80).default('Room Schedule'),
|
||||
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
|
||||
})
|
||||
|
||||
export const DrawingSheetTitleBlock = z.object({
|
||||
projectName: z.string().trim().max(120).default(''),
|
||||
projectNumber: z.string().trim().max(40).default(''),
|
||||
clientName: z.string().trim().max(120).default(''),
|
||||
drawnBy: z.string().trim().max(40).default(''),
|
||||
checkedBy: z.string().trim().max(40).default(''),
|
||||
issueDate: z.string().trim().max(40).default(''),
|
||||
revision: z.string().trim().max(20).default(''),
|
||||
})
|
||||
|
||||
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
}
|
||||
|
||||
export const DrawingSheetNode = BaseNode.extend({
|
||||
id: objectId('drawing-sheet'),
|
||||
type: nodeType('drawing-sheet'),
|
||||
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
|
||||
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
|
||||
paperSize: DrawingSheetPaperSize.default('arch-b'),
|
||||
orientation: DrawingSheetOrientation.default('landscape'),
|
||||
customPaperWidth: PositiveFinite.nullable().default(null),
|
||||
customPaperHeight: PositiveFinite.nullable().default(null),
|
||||
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
|
||||
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
|
||||
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
|
||||
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
|
||||
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
|
||||
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
|
||||
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
|
||||
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
|
||||
}).describe(
|
||||
dedent`
|
||||
Drawing sheet node - persistent construction-document sheet metadata
|
||||
- sheetNumber/sheetTitle: sheet identity in the drawing set
|
||||
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
|
||||
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
|
||||
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
|
||||
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
|
||||
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
|
||||
- schedules/titleBlock: sheet-level documentation content and title-block metadata
|
||||
`,
|
||||
)
|
||||
|
||||
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
|
||||
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
|
||||
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
|
||||
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
|
||||
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
|
||||
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
|
||||
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
|
||||
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
|
||||
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
|
||||
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
|
||||
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
|
||||
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
|
||||
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
|
||||
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
|
||||
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
|
||||
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
|
||||
|
||||
/**
|
||||
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
|
||||
* External scene references are preserved when they are not present in
|
||||
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
|
||||
*/
|
||||
export function remapDrawingSheetReferences(
|
||||
sheet: DrawingSheetNode,
|
||||
sceneIdMap: ReadonlyMap<string, string>,
|
||||
): DrawingSheetNode {
|
||||
const placedViewIds = new Map(
|
||||
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
|
||||
)
|
||||
const noteSetIds = new Map(
|
||||
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
|
||||
)
|
||||
const noteIds = new Map(
|
||||
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
|
||||
(note) => [note.id, generateId('sheet-note')] as const,
|
||||
),
|
||||
)
|
||||
const keyedDefinitionIds = new Map(
|
||||
sheet.keyedNoteDefinitions.map(
|
||||
(definition) => [definition.id, generateId('keyed-note')] as const,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...sheet,
|
||||
placedViews: sheet.placedViews.map((view) => ({
|
||||
...view,
|
||||
id: placedViewIds.get(view.id)!,
|
||||
levelId: view.levelId
|
||||
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
|
||||
: null,
|
||||
})),
|
||||
generalNoteSetIds: sheet.generalNoteSetIds.map(
|
||||
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
|
||||
),
|
||||
generalNoteSets: sheet.generalNoteSets.map((set) => ({
|
||||
...set,
|
||||
id: noteSetIds.get(set.id)!,
|
||||
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
})),
|
||||
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
|
||||
...definition,
|
||||
id: keyedDefinitionIds.get(definition.id)!,
|
||||
})),
|
||||
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
|
||||
...instance,
|
||||
id: generateId('keyed-note-instance'),
|
||||
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
|
||||
instance.definitionId) as typeof instance.definitionId,
|
||||
placedViewId: instance.placedViewId
|
||||
? ((placedViewIds.get(instance.placedViewId) ??
|
||||
instance.placedViewId) as typeof instance.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
documentMarkers: sheet.documentMarkers.map((marker) => ({
|
||||
...marker,
|
||||
id: generateId('sheet-marker'),
|
||||
placedViewId: marker.placedViewId
|
||||
? ((placedViewIds.get(marker.placedViewId) ??
|
||||
marker.placedViewId) as typeof marker.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
schedules: sheet.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
id: generateId('sheet-schedule'),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { ColumnNode } from './column'
|
||||
import { ConstructionDimensionNode } from './construction-dimension'
|
||||
import { DuctFittingNode } from './duct-fitting'
|
||||
import { DuctSegmentNode } from './duct-segment'
|
||||
import { DuctTerminalNode } from './duct-terminal'
|
||||
@@ -22,6 +23,7 @@ import { ShelfNode } from './shelf'
|
||||
import { SlabNode } from './slab'
|
||||
import { SpawnNode } from './spawn'
|
||||
import { StairNode } from './stair'
|
||||
import { StructuralGridNode } from './structural-grid'
|
||||
import { WallNode } from './wall'
|
||||
import { ZoneNode } from './zone'
|
||||
|
||||
@@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({
|
||||
WallNode.shape.id,
|
||||
FenceNode.shape.id,
|
||||
ColumnNode.shape.id,
|
||||
ConstructionDimensionNode.shape.id,
|
||||
StructuralGridNode.shape.id,
|
||||
ItemNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { LevelNode } from './level'
|
||||
import { StructuralGridNode } from './structural-grid'
|
||||
|
||||
describe('StructuralGridNode', () => {
|
||||
test('fills stable construction-document defaults', () => {
|
||||
const grid = StructuralGridNode.parse({})
|
||||
|
||||
expect(grid.id).toStartWith('structural-grid_')
|
||||
expect(grid).toMatchObject({
|
||||
type: 'structural-grid',
|
||||
start: [0, 0],
|
||||
end: [0, 5],
|
||||
label: '1',
|
||||
showStartBubble: true,
|
||||
showEndBubble: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('is accepted as a level child', () => {
|
||||
expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([
|
||||
'structural-grid_axis-1',
|
||||
])
|
||||
})
|
||||
|
||||
test('rejects empty labels and zero-length concerns stay in authoring', () => {
|
||||
expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow()
|
||||
expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({
|
||||
start: [1, 1],
|
||||
end: [1, 1],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
|
||||
export const StructuralGridNode = BaseNode.extend({
|
||||
id: objectId('structural-grid'),
|
||||
type: nodeType('structural-grid'),
|
||||
start: z.tuple([z.number(), z.number()]).default([0, 0]),
|
||||
end: z.tuple([z.number(), z.number()]).default([0, 5]),
|
||||
label: z.string().trim().min(1).max(12).default('1'),
|
||||
showStartBubble: z.boolean().default(true),
|
||||
showEndBubble: z.boolean().default(true),
|
||||
}).describe(
|
||||
dedent`
|
||||
Structural grid node - a persistent floor-plan datum axis with identification bubbles
|
||||
- start/end: level-local plan coordinates defining the grid axis extent
|
||||
- label: axis identifier, commonly numeric in one direction and alphabetic in the other
|
||||
- showStartBubble/showEndBubble: independently control the two endpoint identifiers
|
||||
`,
|
||||
)
|
||||
|
||||
export type StructuralGridNode = z.infer<typeof StructuralGridNode>
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -13,7 +19,8 @@ import {
|
||||
WALL_SKIRTING_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WallFaceBandConfig,
|
||||
type WallNode,
|
||||
WallNode,
|
||||
type WallNode as WallNodeType,
|
||||
WallTrimConfig,
|
||||
} from './wall'
|
||||
|
||||
@@ -99,7 +106,7 @@ describe('wall face bands', () => {
|
||||
lowerInterior: 'library:stale-lower',
|
||||
middleExterior: 'library:stale-middle',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>)
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
|
||||
|
||||
expect(patch.faceBands).toEqual({
|
||||
enabled: true,
|
||||
@@ -135,7 +142,7 @@ describe('wall face bands', () => {
|
||||
exterior: 'scene:exterior-finish',
|
||||
topInterior: 'library:stale-top',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
3,
|
||||
)
|
||||
|
||||
@@ -159,7 +166,7 @@ describe('wall face bands', () => {
|
||||
middleInterior: 'library:stale-middle',
|
||||
upperExterior: 'library:stale-upper',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>)
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
|
||||
|
||||
expect(patch.slots).toEqual({
|
||||
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
|
||||
@@ -187,7 +194,7 @@ describe('wall face bands', () => {
|
||||
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
|
||||
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
3,
|
||||
)
|
||||
|
||||
@@ -219,7 +226,7 @@ describe('wall face bands', () => {
|
||||
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
|
||||
upperExterior: 'library:painted-top-exterior',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
4,
|
||||
)
|
||||
|
||||
@@ -260,3 +267,206 @@ describe('wall trim profiles', () => {
|
||||
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wall assembly layers', () => {
|
||||
test('defaults to legacy thickness when no assembly layers are modeled', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(wall.assemblyLayers).toEqual([])
|
||||
expect(getWallAssemblyThickness(wall)).toBe(0.14)
|
||||
})
|
||||
|
||||
test('stores role, side, thickness, material reference, and datum eligibility', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face', 'finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
|
||||
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
|
||||
'interior-gwb',
|
||||
'brick-veneer',
|
||||
])
|
||||
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
|
||||
{ id: 'stud-core', role: 'structure', side: 'core' },
|
||||
])
|
||||
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
|
||||
interior: -0.061,
|
||||
exterior: 0.135,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves stable datum references for legacy single-thickness walls', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
|
||||
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
|
||||
{
|
||||
id: 'wall:structural-face:interior',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:structural-face:exterior',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:interior',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:exterior',
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-sheathing',
|
||||
role: 'exterior-sheathing',
|
||||
side: 'exterior',
|
||||
thickness: 0.012,
|
||||
materialRef: 'library:sheathing',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const references = resolveWallAssemblyDatumReferences(wall)
|
||||
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:centerline:center',
|
||||
datum: 'centerline',
|
||||
side: 'center',
|
||||
offset: 0,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:interior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
layerId: 'stud-core',
|
||||
offset: -0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:exterior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
layerId: 'stud-core',
|
||||
offset: 0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:finish-face:interior:interior-gwb',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
layerId: 'interior-gwb',
|
||||
offset: -0.061,
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
layerId: 'exterior-sheathing',
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
)?.offset,
|
||||
).toBeCloseTo(0.057)
|
||||
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
})
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
|
||||
?.offset,
|
||||
).toBeCloseTo(0.147)
|
||||
expect(
|
||||
resolveWallAssemblyDatumReference(
|
||||
wall,
|
||||
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
offset: 0.147,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
|
||||
|
||||
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
|
||||
|
||||
export const WallAssemblyLayerRole = z.enum([
|
||||
'structure',
|
||||
'interior-finish',
|
||||
'exterior-sheathing',
|
||||
'exterior-finish',
|
||||
'masonry-veneer',
|
||||
'air-space',
|
||||
'concrete-block',
|
||||
'structural-masonry',
|
||||
'solid-concrete',
|
||||
'furring',
|
||||
])
|
||||
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
|
||||
|
||||
export const WallDimensionDatum = z.enum([
|
||||
'centerline',
|
||||
'structural-face',
|
||||
'finish-face',
|
||||
'veneer-face',
|
||||
])
|
||||
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
|
||||
|
||||
export const WallAssemblyLayer = z.object({
|
||||
id: z.string().trim().min(1).max(80).default('structure'),
|
||||
role: WallAssemblyLayerRole.default('structure'),
|
||||
side: z.enum(['core', 'interior', 'exterior']).default('core'),
|
||||
thickness: z.number().finite().positive().default(0.1),
|
||||
materialRef: z.string().trim().max(120).default(''),
|
||||
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
|
||||
})
|
||||
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
|
||||
|
||||
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
|
||||
|
||||
export type WallAssemblyDatumReference = {
|
||||
id: string
|
||||
datum: WallDimensionDatum
|
||||
side: WallAssemblyDatumSide
|
||||
layerId?: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const WallNode = BaseNode.extend({
|
||||
id: objectId('wall'),
|
||||
type: nodeType('wall'),
|
||||
@@ -149,6 +191,7 @@ export const WallNode = BaseNode.extend({
|
||||
// in a follow-up once migrated scenes are the norm.
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
thickness: z.number().optional(),
|
||||
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
|
||||
height: z.number().optional(),
|
||||
curveOffset: z.number().optional(),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
@@ -167,6 +210,7 @@ export const WallNode = BaseNode.extend({
|
||||
dedent`
|
||||
Wall node - used to represent a wall in the building
|
||||
- thickness: thickness in meters
|
||||
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
|
||||
- height: height in meters
|
||||
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
|
||||
- start: start point of the wall in level coordinate system
|
||||
@@ -190,6 +234,222 @@ export type WallBandSurfaceSlotId =
|
||||
| 'upperExterior'
|
||||
| 'topExterior'
|
||||
|
||||
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
|
||||
return wall.assemblyLayers ?? []
|
||||
}
|
||||
|
||||
export function getWallAssemblyThickness(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): number {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return wall.thickness ?? 0.1
|
||||
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
}
|
||||
|
||||
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
|
||||
interior: number
|
||||
exterior: number
|
||||
} {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return { interior: -halfThickness, exterior: halfThickness }
|
||||
}
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const interiorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'interior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
const exteriorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'exterior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
|
||||
return {
|
||||
interior: -coreThickness / 2 - interiorFinishThickness,
|
||||
exterior: coreThickness / 2 + exteriorFinishThickness,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWallDatumEligibleLayers(
|
||||
wall: Pick<WallNode, 'assemblyLayers'>,
|
||||
datum: WallDimensionDatum,
|
||||
): WallAssemblyLayer[] {
|
||||
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
|
||||
}
|
||||
|
||||
export function getWallAssemblyDatumReferenceId(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
layerId?: string,
|
||||
): string {
|
||||
return ['wall', datum, side, layerId].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
type WallAssemblyLayerSpan = {
|
||||
layer: WallAssemblyLayer
|
||||
interiorOffset: number
|
||||
exteriorOffset: number
|
||||
}
|
||||
|
||||
function getWallAssemblyLayerSpans(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyLayerSpan[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const coreInteriorFace = -coreThickness / 2
|
||||
const coreExteriorFace = coreThickness / 2
|
||||
const spans: WallAssemblyLayerSpan[] = []
|
||||
|
||||
let coreOffset = coreInteriorFace
|
||||
for (const layer of coreLayers) {
|
||||
const interiorOffset = coreOffset
|
||||
const exteriorOffset = coreOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
||||
coreOffset = exteriorOffset
|
||||
}
|
||||
|
||||
let interiorOffset = coreInteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
||||
const exteriorOffset = interiorOffset
|
||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
||||
interiorOffset = nextInteriorOffset
|
||||
}
|
||||
|
||||
let exteriorOffset = coreExteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
||||
const interiorFaceOffset = exteriorOffset
|
||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
||||
exteriorOffset = nextExteriorOffset
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
function createWallAssemblyDatumReference(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
offset: number,
|
||||
layerId?: string,
|
||||
): WallAssemblyDatumReference {
|
||||
return {
|
||||
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
|
||||
datum,
|
||||
side,
|
||||
...(layerId ? { layerId } : {}),
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReferences(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyDatumReference[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
const references: WallAssemblyDatumReference[] = [
|
||||
createWallAssemblyDatumReference('centerline', 'center', 0),
|
||||
]
|
||||
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return [
|
||||
...references,
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
]
|
||||
}
|
||||
|
||||
const spans = getWallAssemblyLayerSpans(wall)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.layer.datumEligible.includes('structural-face')) {
|
||||
if (span.layer.side === 'core') {
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'interior',
|
||||
span.interiorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'exterior',
|
||||
span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const side = span.layer.side
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
side,
|
||||
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('finish-face')) {
|
||||
const side = span.layer.side === 'core' ? 'center' : span.layer.side
|
||||
const offset =
|
||||
span.layer.side === 'interior'
|
||||
? span.interiorOffset
|
||||
: span.layer.side === 'exterior'
|
||||
? span.exteriorOffset
|
||||
: (span.interiorOffset + span.exteriorOffset) / 2
|
||||
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('veneer-face')) {
|
||||
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
|
||||
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
|
||||
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'structural-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'finish-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReference(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
referenceId: string,
|
||||
): WallAssemblyDatumReference | null {
|
||||
return (
|
||||
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Declared default appearance for an unpainted wall face in colored mode —
|
||||
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
||||
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
||||
|
||||
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
|
||||
])
|
||||
export type WindowType = z.infer<typeof WindowType>
|
||||
|
||||
export const WindowConstructionType = z.enum(['framed', 'masonry'])
|
||||
export const WindowDimensionReference = z.enum([
|
||||
'nominal',
|
||||
'rough-opening',
|
||||
'masonry-opening',
|
||||
'finish-opening',
|
||||
])
|
||||
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
|
||||
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
|
||||
|
||||
export const WindowNode = BaseNode.extend({
|
||||
id: objectId('window'),
|
||||
type: nodeType('window'),
|
||||
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
|
||||
width: z.number().default(1.5),
|
||||
height: z.number().default(1.5),
|
||||
|
||||
// Construction-document identity and optional manufacturer rough opening.
|
||||
// Legacy scenes omit these fields and continue to parse unchanged.
|
||||
mark: z.string().trim().max(16).optional(),
|
||||
constructionType: WindowConstructionType.default('framed'),
|
||||
dimensionReference: WindowDimensionReference.default('nominal'),
|
||||
roughOpeningWidth: z.number().positive().optional(),
|
||||
roughOpeningHeight: z.number().positive().optional(),
|
||||
masonryOpeningWidth: z.number().positive().optional(),
|
||||
masonryOpeningHeight: z.number().positive().optional(),
|
||||
finishOpeningWidth: z.number().positive().optional(),
|
||||
finishOpeningHeight: z.number().positive().optional(),
|
||||
|
||||
// Opening mode - when set to "opening", the window is only a shaped cutout
|
||||
openingKind: z.enum(['window', 'opening']).default('window'),
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { ZoneNode } from './zone'
|
||||
|
||||
describe('ZoneNode architectural room data', () => {
|
||||
test('keeps legacy zones generic while supplying room-safe defaults', () => {
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_legacy',
|
||||
name: 'Landscape area',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
],
|
||||
})
|
||||
|
||||
expect(zone).toMatchObject({
|
||||
spaceRole: 'generic',
|
||||
roomNumber: '',
|
||||
enclosureStatus: 'auto',
|
||||
floorFinish: '',
|
||||
wallFinish: '',
|
||||
ceilingFinish: '',
|
||||
ceilingHeight: 2.7,
|
||||
occupancy: '',
|
||||
clearDimensionPolicy: 'none',
|
||||
})
|
||||
})
|
||||
|
||||
test('persists a complete architectural room profile', () => {
|
||||
const room = ZoneNode.parse({
|
||||
id: 'zone_office',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
roomNumber: '101',
|
||||
enclosureStatus: 'enclosed',
|
||||
floorFinish: 'Timber',
|
||||
wallFinish: 'Paint',
|
||||
ceilingFinish: 'ACT',
|
||||
ceilingHeight: 3,
|
||||
occupancy: 'Business',
|
||||
clearDimensionPolicy: 'inside-faces',
|
||||
})
|
||||
|
||||
expect(room.spaceRole).toBe('room')
|
||||
expect(room.roomNumber).toBe('101')
|
||||
expect(room.clearDimensionPolicy).toBe('inside-faces')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
|
||||
// stored polygon remains a fallback for missing or temporarily open walls.
|
||||
autoFromWalls: z.boolean().default(false),
|
||||
boundaryWallIds: z.array(objectId('wall')).default([]),
|
||||
// Generic zones remain available for sites and analysis. Architectural
|
||||
// room documentation is opt-in so legacy zone behavior is unchanged.
|
||||
spaceRole: z.enum(['generic', 'room']).default('generic'),
|
||||
roomNumber: z.string().trim().max(32).default(''),
|
||||
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
|
||||
floorFinish: z.string().trim().max(120).default(''),
|
||||
wallFinish: z.string().trim().max(120).default(''),
|
||||
ceilingFinish: z.string().trim().max(120).default(''),
|
||||
ceilingHeight: z.number().min(0.1).default(2.7),
|
||||
occupancy: z.string().trim().max(80).default(''),
|
||||
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
|
||||
// Visual styling
|
||||
color: z.string().default('#3b82f6'), // Default blue
|
||||
metadata: z.json().optional().default({}),
|
||||
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
|
||||
- polygon: array of [x, z] points defining the zone boundary
|
||||
- autoFromWalls: whether the boundary follows an enclosed wall loop
|
||||
- boundaryWallIds: wall ids that prove the procedural enclosure
|
||||
- spaceRole: generic site/analysis zone or architectural room
|
||||
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
|
||||
- enclosureStatus: auto-detected, explicitly enclosed, or open
|
||||
- clearDimensionPolicy: optional room clear-dimension datum preference
|
||||
- color: hex color for visual styling
|
||||
- metadata: zone metadata (optional)
|
||||
`,
|
||||
|
||||
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { ChimneyNode } from './nodes/chimney'
|
||||
import { ColumnNode } from './nodes/column'
|
||||
import { ConstructionDimensionNode } from './nodes/construction-dimension'
|
||||
import { CupolaNode } from './nodes/cupola'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { DownspoutNode } from './nodes/downspout'
|
||||
import { DrawingSheetNode } from './nodes/drawing-sheet'
|
||||
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
|
||||
import { SpawnNode } from './nodes/spawn'
|
||||
import { StairNode } from './nodes/stair'
|
||||
import { StairSegmentNode } from './nodes/stair-segment'
|
||||
import { StructuralGridNode } from './nodes/structural-grid'
|
||||
import { TurbineVentNode } from './nodes/turbine-vent'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
ElevatorNode,
|
||||
LevelNode,
|
||||
ColumnNode,
|
||||
ConstructionDimensionNode,
|
||||
StructuralGridNode,
|
||||
WallNode,
|
||||
FenceNode,
|
||||
CabinetNode,
|
||||
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctSegmentNode,
|
||||
DuctFittingNode,
|
||||
DuctTerminalNode,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode } from '../schema'
|
||||
import useScene from './use-scene'
|
||||
|
||||
describe('scene construction-dimension migrations', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('normalizes the legacy reference presentation before parsing', () => {
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
site_test: {
|
||||
object: 'node',
|
||||
id: 'site_test',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['building_test'],
|
||||
},
|
||||
building_test: {
|
||||
object: 'node',
|
||||
id: 'building_test',
|
||||
type: 'building',
|
||||
parentId: 'site_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['level_test'],
|
||||
},
|
||||
level_test: {
|
||||
object: 'node',
|
||||
id: 'level_test',
|
||||
type: 'level',
|
||||
parentId: 'building_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['construction-dimension_test'],
|
||||
level: 0,
|
||||
},
|
||||
'construction-dimension_test': {
|
||||
object: 'node',
|
||||
id: 'construction-dimension_test',
|
||||
type: 'construction-dimension',
|
||||
parentId: 'level_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
reference: true,
|
||||
referenceStyle: 'suffix',
|
||||
drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }],
|
||||
},
|
||||
} as unknown as Record<string, AnyNode>,
|
||||
['site_test'] as never,
|
||||
)
|
||||
|
||||
const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
|
||||
Record<string, unknown>
|
||||
expect(dimension.reference).toBeUndefined()
|
||||
expect(dimension.referenceStyle).toBeUndefined()
|
||||
expect(dimension.drawingOverrides).toEqual([
|
||||
{ drawingType: 'roof-plan', presentation: 'shown' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -570,6 +570,27 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||
return next
|
||||
}
|
||||
|
||||
function migrateConstructionDimension(node: Record<string, any>) {
|
||||
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
|
||||
const hasLegacyDrawingOverride = drawingOverrides.some(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
!Array.isArray(entry) &&
|
||||
entry.presentation === 'reference',
|
||||
)
|
||||
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
|
||||
|
||||
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
|
||||
return {
|
||||
...dimension,
|
||||
drawingOverrides: drawingOverrides.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
|
||||
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Walls whose top lands within this of the storey plane become plane-bound;
|
||||
// ceilings whose stored height lands within this of their clamp bound become
|
||||
// follows-mode (step 3f) — same census-backed threshold for both.
|
||||
@@ -687,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'construction-dimension') {
|
||||
patchedNodes[id] = migrateConstructionDimension(node)
|
||||
}
|
||||
|
||||
if (node.type === 'stair') {
|
||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||
if (normalized) {
|
||||
|
||||
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
|
||||
}
|
||||
}
|
||||
|
||||
function getWallArcData(wall: WallCurveLike) {
|
||||
export function getWallArcData(wall: WallCurveLike) {
|
||||
const chord = getWallChordFrame(wall)
|
||||
const sagitta = getClampedWallCurveOffset(wall)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
|
||||
visible: true,
|
||||
parentId: 'level_test',
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
thickness: 0.1,
|
||||
|
||||
@@ -77,6 +77,119 @@ describe('forkSceneGraph', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('construction-dimension clone references', () => {
|
||||
function sceneWithControlledDimensions(): SceneGraph {
|
||||
const site = makeNode('site_1', 'site', { children: ['level_1'] })
|
||||
const level = makeNode('level_1', 'level', {
|
||||
parentId: 'site_1',
|
||||
children: ['construction-dimension_foundation', 'construction-dimension_floor'],
|
||||
})
|
||||
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
|
||||
name: 'Foundation controller',
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
],
|
||||
controllingDimensionId: null,
|
||||
})
|
||||
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
|
||||
name: 'Floor dependent',
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
],
|
||||
controllingDimensionId: controller.id,
|
||||
})
|
||||
return {
|
||||
nodes: {
|
||||
[site.id]: site,
|
||||
[level.id]: level,
|
||||
[controller.id]: controller,
|
||||
[dependent.id]: dependent,
|
||||
},
|
||||
rootNodeIds: [site.id],
|
||||
}
|
||||
}
|
||||
|
||||
test('remaps controller IDs in whole-scene clones', () => {
|
||||
const cloned = cloneSceneGraph(sceneWithControlledDimensions())
|
||||
const dimensions = Object.values(cloned.nodes).filter(
|
||||
(node) => node.type === 'construction-dimension',
|
||||
)
|
||||
const controller = dimensions.find((node) => node.name === 'Foundation controller')
|
||||
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
|
||||
|
||||
expect(controller?.type).toBe('construction-dimension')
|
||||
expect(dependent?.type).toBe('construction-dimension')
|
||||
if (
|
||||
controller?.type === 'construction-dimension' &&
|
||||
dependent?.type === 'construction-dimension'
|
||||
) {
|
||||
expect(dependent.controllingDimensionId).toBe(controller.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('remaps controller IDs in level-subtree clones', () => {
|
||||
const scene = sceneWithControlledDimensions()
|
||||
const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
|
||||
const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
|
||||
const controller = dimensions.find((node) => node.name === 'Foundation controller')
|
||||
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
|
||||
|
||||
expect(controller?.type).toBe('construction-dimension')
|
||||
expect(dependent?.type).toBe('construction-dimension')
|
||||
if (
|
||||
controller?.type === 'construction-dimension' &&
|
||||
dependent?.type === 'construction-dimension'
|
||||
) {
|
||||
expect(dependent.controllingDimensionId).toBe(controller.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawing-sheet clone references', () => {
|
||||
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
|
||||
const level = makeNode('level_main', 'level')
|
||||
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [1, 1],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
})
|
||||
const cloned = cloneSceneGraph({
|
||||
nodes: { [level.id]: level, [sheet.id]: sheet },
|
||||
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
|
||||
})
|
||||
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
|
||||
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
|
||||
|
||||
expect(clonedLevel).toBeDefined()
|
||||
expect(clonedSheet?.type).toBe('drawing-sheet')
|
||||
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
|
||||
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
|
||||
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
|
||||
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
|
||||
clonedSheet.keyedNoteDefinitions[0]?.id,
|
||||
)
|
||||
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportSlabId remap', () => {
|
||||
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
|
||||
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
|
||||
import { remapMeasurementReferences } from '../lib/measurement-geometry'
|
||||
import {
|
||||
remapConstructionDimensionReferences,
|
||||
remapMeasurementReferences,
|
||||
} from '../lib/measurement-geometry'
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
import { generateId } from '../schema/base'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
@@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
|
||||
for (const [oldId, node] of Object.entries(nodes)) {
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
|
||||
// Remap parentId
|
||||
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
|
||||
@@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
if (clonedNode.type === 'measurement') {
|
||||
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
|
||||
}
|
||||
if (clonedNode.type === 'construction-dimension') {
|
||||
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
|
||||
}
|
||||
if (clonedNode.type === 'drawing-sheet') {
|
||||
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
|
||||
}
|
||||
|
||||
clonedNodes[newId] = clonedNode
|
||||
}
|
||||
@@ -221,7 +231,7 @@ export function cloneLevelSubtree(
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
|
||||
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
|
||||
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
|
||||
let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
|
||||
;(cloned as Record<string, unknown>).id = newId
|
||||
|
||||
// Remap parentId — but only for descendants, not the level node itself
|
||||
@@ -274,6 +284,12 @@ export function cloneLevelSubtree(
|
||||
if (cloned.type === 'measurement') {
|
||||
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
|
||||
}
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
clonedNodes.push(cloned)
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@visual-json/react": "^0.4.0",
|
||||
"blob-stream": "^0.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "~0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
@@ -59,8 +59,10 @@
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
|
||||
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
|
||||
const guides = useAlignmentGuides((s) => s.guides)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const metricNotation = useViewer((s) => s.metricNotation)
|
||||
const ctx = useFloorplanRender()
|
||||
|
||||
if (guides.length === 0) return null
|
||||
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
|
||||
// offset along X.
|
||||
const pillX = axis === 'x' ? midX + pillOffset : midX
|
||||
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
|
||||
const distLabel = formatMeasurement(distMeters, unit)
|
||||
const distLabel = formatMeasurement(distMeters, unit, metricNotation)
|
||||
const charWidth = pillFontSize * 0.55
|
||||
const pillWidth = distLabel.length * charWidth + pillPadX * 2
|
||||
const pillHeight = pillFontSize + pillPadY * 2
|
||||
|
||||
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
const draftLevelId = useMeasurementDraft((state) => state.levelId)
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
|
||||
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
|
||||
screenUpright: false,
|
||||
text: formatLinearMeasurement(measurementDistance(start, end), unit),
|
||||
text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
|
||||
}
|
||||
} else if (kind === 'angle' && livePoints.length >= 3) {
|
||||
const anglePoints = livePoints.slice(0, 3) as [
|
||||
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
text:
|
||||
kind === 'area'
|
||||
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
|
||||
}
|
||||
}
|
||||
} else if (kind === 'volume' && center && baseNormal) {
|
||||
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
|
||||
(segmentStart[1] + segmentEnd[1]) / 2,
|
||||
(segmentStart[2] + segmentEnd[2]) / 2,
|
||||
],
|
||||
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
|
||||
text: formatLinearMeasurement(
|
||||
measurementDistance(segmentStart, segmentEnd),
|
||||
unit,
|
||||
metricNotation,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
|
||||
polygonPoints,
|
||||
segmentLabel,
|
||||
}
|
||||
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
|
||||
}, [
|
||||
axisGuide,
|
||||
baseNormal,
|
||||
extrusionHeight,
|
||||
hover,
|
||||
kind,
|
||||
metricNotation,
|
||||
points,
|
||||
stage,
|
||||
unit,
|
||||
vertexDrag,
|
||||
])
|
||||
|
||||
if (smartActive) return <FloorplanQuickMeasureLayer />
|
||||
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
|
||||
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
text={`${hover.semantic.label}${
|
||||
hover.semantic.length === null
|
||||
? ''
|
||||
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
|
||||
: ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
|
||||
}`}
|
||||
textColor={labelText}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
|
||||
@@ -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 { useReducedMotion } from '../../hooks/use-reduced-motion'
|
||||
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
||||
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
createFreshPlacementSubtree,
|
||||
duplicatesAsFreshSubtree,
|
||||
} from '../../lib/fresh-planar-placement'
|
||||
import { curveReshapeScope } from '../../lib/interaction/scope'
|
||||
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { cn } from '../../lib/utils'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { useMovingNode } from '../../store/use-interaction-scope'
|
||||
import useInteractionScope, {
|
||||
useIsCurveReshape,
|
||||
useMovingNode,
|
||||
} from '../../store/use-interaction-scope'
|
||||
import { NodeActionMenu } from '../editor/node-action-menu'
|
||||
import { IconRefGlyph } from '../ui/icon-ref'
|
||||
|
||||
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
|
||||
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
|
||||
* Walls are excluded — their move is reached via the side-arrow
|
||||
* handles emitted from `def.floorplan`, not via a menu button.
|
||||
* - Curve (wall only): enters curve reshape mode. The selected wall's
|
||||
* midpoint curve handle remains visible so it can be dragged in plan.
|
||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
|
||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||
* `relations.cascadeDelete` if declared on the def.
|
||||
*
|
||||
* Hidden while in a move state (so we don't show buttons over a ghost).
|
||||
* Hidden while moving or curving so the menu does not compete with the active affordance.
|
||||
*/
|
||||
export function FloorplanRegistryActionMenu() {
|
||||
const reducedMotion = useReducedMotion()
|
||||
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
||||
) as AnyNodeId | undefined
|
||||
const movingNode = useMovingNode()
|
||||
const isCurveReshape = useIsCurveReshape()
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
|
||||
// Only show for registered kinds (skip legacy kinds — they have their
|
||||
// own FloorplanActionMenuLayer entries).
|
||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||
const canCurve = useScene((s) => {
|
||||
if (!selectedId) return false
|
||||
const selectedNode = s.nodes[selectedId]
|
||||
if (!selectedNode) return false
|
||||
const definition = nodeRegistry.get(selectedNode.type)
|
||||
const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
|
||||
return (
|
||||
!!definition?.floorplanAffordances?.curve &&
|
||||
!!canCurveNode?.({
|
||||
node: selectedNode as never,
|
||||
nodes: s.nodes,
|
||||
})
|
||||
)
|
||||
})
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible =
|
||||
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
|
||||
isRegistryKind &&
|
||||
def?.presentation?.actionMenu !== false &&
|
||||
!movingNode &&
|
||||
!isCurveReshape &&
|
||||
isFloorplanHovered
|
||||
const isWall = selectedKind === 'wall'
|
||||
const quickActionNodes = useScene(
|
||||
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
|
||||
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleCurve = () => {
|
||||
if (!canCurve) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
||||
}
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (!node.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={canAddHole ? handleAddHole : undefined}
|
||||
onCurve={canCurve ? handleCurve : undefined}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
onDuplicate={canDuplicate ? handleDuplicate : undefined}
|
||||
onMove={canMove ? handleMove : undefined}
|
||||
|
||||
+408
@@ -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,
|
||||
}
|
||||
}
|
||||
+236
@@ -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'-1 1/2"')
|
||||
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)}`
|
||||
}
|
||||
+369
@@ -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"')
|
||||
})
|
||||
})
|
||||
+425
-32
@@ -2,6 +2,29 @@
|
||||
|
||||
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
floorplanAnnotationObstacleMode,
|
||||
isFloorplanAnnotationObstacleGeometry,
|
||||
} from './floorplan-annotation-layout'
|
||||
import {
|
||||
FloorplanDimensionRenderer,
|
||||
FloorplanDimensionStringRenderer,
|
||||
} from './floorplan-dimension-renderer'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
|
||||
const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
|
||||
const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
|
||||
const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
|
||||
const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
|
||||
const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
|
||||
const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
|
||||
const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
|
||||
const DOCUMENT_MARK_HEIGHT_PT = 14
|
||||
const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
|
||||
|
||||
type FloorplanRenderMode = 'screen' | 'pdf'
|
||||
|
||||
/**
|
||||
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
|
||||
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
|
||||
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
|
||||
geometry,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg = 0,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode = 'screen',
|
||||
}: {
|
||||
geometry: FloorplanGeometry
|
||||
pointerEventsOverride?: string
|
||||
sceneRotationDeg?: number
|
||||
annotationUnitsPerPoint?: number
|
||||
screenUnitsPerPixel?: number
|
||||
renderMode?: FloorplanRenderMode
|
||||
}) {
|
||||
return renderNode(geometry, 0, pointerEventsOverride)
|
||||
return renderNode(
|
||||
geometry,
|
||||
0,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode,
|
||||
)
|
||||
})
|
||||
|
||||
function styleAttrs(
|
||||
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
|
||||
pointerEventsOverride?: string,
|
||||
annotationUnitsPerPoint?: number,
|
||||
renderMode: FloorplanRenderMode = 'screen',
|
||||
) {
|
||||
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
|
||||
// primitive switch arms terse and ensures new style fields land
|
||||
@@ -54,37 +95,241 @@ function styleAttrs(
|
||||
pointerEvents?: string
|
||||
cursor?: string
|
||||
}
|
||||
const annotationMetadata = readFloorplanGeometryMetadata(g)
|
||||
const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
|
||||
const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
|
||||
const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
|
||||
const strokeWidth =
|
||||
renderMode === 'pdf' &&
|
||||
vectorEffect === 'non-scaling-stroke' &&
|
||||
resolvedStrokeWidth !== undefined
|
||||
? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
|
||||
: resolvedStrokeWidth
|
||||
return {
|
||||
fill: s.fill ?? 'none',
|
||||
'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
|
||||
'data-floorplan-annotation-role': annotationMetadata.annotationRole,
|
||||
fill: documentStyle.fill ?? s.fill ?? 'none',
|
||||
fillOpacity: s.fillOpacity,
|
||||
stroke: s.stroke,
|
||||
strokeWidth: s.strokeWidth,
|
||||
stroke: documentStyle.stroke ?? s.stroke,
|
||||
strokeWidth,
|
||||
strokeDasharray: s.strokeDasharray,
|
||||
strokeLinecap: s.strokeLinecap,
|
||||
strokeLinejoin: s.strokeLinejoin,
|
||||
strokeOpacity: s.strokeOpacity,
|
||||
opacity: s.opacity,
|
||||
vectorEffect: s.vectorEffect,
|
||||
vectorEffect,
|
||||
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
|
||||
style: s.cursor ? { cursor: s.cursor } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDocumentFloorplanAnnotationStyle(
|
||||
geometry: FloorplanGeometry & { kind: Exclude<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(
|
||||
g: FloorplanGeometry,
|
||||
keyHint: number,
|
||||
pointerEventsOverride?: string,
|
||||
sceneRotationDeg = 0,
|
||||
annotationUnitsPerPoint?: number,
|
||||
screenUnitsPerPixel?: number,
|
||||
renderMode: FloorplanRenderMode = 'screen',
|
||||
): React.ReactElement | null {
|
||||
switch (g.kind) {
|
||||
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':
|
||||
return (
|
||||
<polygon
|
||||
key={keyHint}
|
||||
points={pointsToAttr(g.points)}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -93,34 +338,38 @@ function renderNode(
|
||||
<polyline
|
||||
key={keyHint}
|
||||
points={pointsToAttr(g.points)}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'rect':
|
||||
case 'rect': {
|
||||
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
|
||||
return (
|
||||
<rect
|
||||
height={g.height}
|
||||
height={attrs.height}
|
||||
key={keyHint}
|
||||
rx={g.rx}
|
||||
ry={g.ry}
|
||||
width={g.width}
|
||||
x={g.x}
|
||||
y={g.y}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
rx={attrs.rx}
|
||||
ry={attrs.ry}
|
||||
width={attrs.width}
|
||||
x={attrs.x}
|
||||
y={attrs.y}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'circle':
|
||||
case 'circle': {
|
||||
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
|
||||
return (
|
||||
<circle
|
||||
cx={g.cx}
|
||||
cy={g.cy}
|
||||
key={keyHint}
|
||||
r={g.r}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
r={attrs.r}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'line':
|
||||
return (
|
||||
@@ -130,25 +379,65 @@ function renderNode(
|
||||
x2={g.x2}
|
||||
y1={g.y1}
|
||||
y2={g.y2}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
case 'text': {
|
||||
const fontSize =
|
||||
annotationUnitsPerPoint !== undefined
|
||||
? documentTextFontSize(g, annotationUnitsPerPoint)
|
||||
: g.fontSize
|
||||
const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
|
||||
const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
|
||||
const fill =
|
||||
pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
|
||||
? g.stroke
|
||||
: (g.fill ?? '#171717')
|
||||
if (g.upright) {
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
||||
key={keyHint}
|
||||
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
|
||||
>
|
||||
<text
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
fill={g.fill ?? '#171717'}
|
||||
fill={fill}
|
||||
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}
|
||||
key={keyHint}
|
||||
opacity={g.opacity}
|
||||
paintOrder={g.paintOrder}
|
||||
stroke={g.stroke}
|
||||
strokeLinecap={g.stroke ? 'round' : undefined}
|
||||
strokeLinejoin={g.stroke ? 'round' : undefined}
|
||||
strokeWidth={g.strokeWidth}
|
||||
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
|
||||
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'}
|
||||
pointerEvents={pointerEventsOverride}
|
||||
x={g.x}
|
||||
@@ -157,6 +446,93 @@ function renderNode(
|
||||
{g.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':
|
||||
return (
|
||||
@@ -174,15 +550,32 @@ function renderNode(
|
||||
|
||||
case 'group': {
|
||||
const transform = formatTransform(g.transform)
|
||||
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
|
||||
return (
|
||||
<g key={keyHint} transform={transform}>
|
||||
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
|
||||
<g
|
||||
data-floorplan-annotation-obstacle={
|
||||
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
|
||||
}
|
||||
key={keyHint}
|
||||
transform={transform}
|
||||
>
|
||||
{children.map((child, i) =>
|
||||
renderNode(
|
||||
child,
|
||||
i,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode,
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// The interactive primitives (hatch / hit-line / endpoint-handle /
|
||||
// dimension-label) need the SVG context + theme palette + units-per-
|
||||
// The remaining interactive primitives (hatch / hit-line / endpoint-handle)
|
||||
// need the SVG context + theme palette + units-per-
|
||||
// pixel that only the registry layer has access to. They're rendered
|
||||
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
|
||||
// a caller routes one of these through this pure renderer it
|
||||
|
||||
@@ -3,15 +3,25 @@ import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
FloorplanAffordanceSession,
|
||||
FloorplanGeometry,
|
||||
LiveNodeOverrides,
|
||||
} from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
FLOORPLAN_NODE_EXTENSION_KEY,
|
||||
floorplanGeometryMetadata,
|
||||
} from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
cancelFloorplanAffordanceDrag,
|
||||
collectFloorplanDependencyNodes,
|
||||
collectFloorplanLinkedLevelNodes,
|
||||
computeAffectedSiblingIds,
|
||||
floorplanHandleDoubleClickAffordance,
|
||||
InteractiveGeometry,
|
||||
splitFloorplanOverlay,
|
||||
subscribeFloorplanAffordanceToolCancel,
|
||||
} from './floorplan-registry-layer'
|
||||
|
||||
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan annotation overlay routing', () => {
|
||||
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
|
||||
const noop = () => {}
|
||||
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
'svg',
|
||||
null,
|
||||
createElement(InteractiveGeometry, {
|
||||
activeDragId: null,
|
||||
activeRotateNodeId: null,
|
||||
geometry,
|
||||
hatchPatternId: undefined,
|
||||
hoveredHandleId: null,
|
||||
isMarqueeSelectionActive: false,
|
||||
nodeId: 'wall_test' as AnyNodeId,
|
||||
onHandleDoubleClick: noop,
|
||||
onHandleHoverChange: noop,
|
||||
onHandlePointerDown: noop,
|
||||
onMoveHandlePointerDown: noop,
|
||||
palette: undefined,
|
||||
sceneRotationDeg: 180,
|
||||
unitsPerPixel: 0.01,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const dimensionString = (
|
||||
end: readonly [number, number],
|
||||
offsetNormal: readonly [number, number],
|
||||
): FloorplanGeometry => ({
|
||||
kind: 'dimension-string',
|
||||
segments: [{ start: [0, 0], end, text: '2m' }],
|
||||
offsetNormal,
|
||||
offsetDistance: 0.55,
|
||||
extensionOvershoot: 0.12,
|
||||
textPosition: 'above',
|
||||
})
|
||||
|
||||
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
|
||||
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
|
||||
})
|
||||
|
||||
test('keeps a fixed mark pill together in the overlay pass', () => {
|
||||
const mark = {
|
||||
kind: 'group',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
|
||||
children: [
|
||||
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
|
||||
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
|
||||
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
|
||||
})
|
||||
|
||||
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
|
||||
const columnCenter = {
|
||||
kind: 'line',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAffectedSiblingIds', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFloorplanLinkedLevelNodes', () => {
|
||||
test('projects a node onto a linked destination level with its real children', () => {
|
||||
nodeRegistry._reset()
|
||||
registerNode({
|
||||
kind: 'linked-floorplan-test',
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
|
||||
category: 'structure',
|
||||
defaults: () => ({}) as never,
|
||||
floorplan: () => null,
|
||||
extensions: {
|
||||
[FLOORPLAN_NODE_EXTENSION_KEY]: {
|
||||
linkedLevelIds: () => ['level_upper' as AnyNodeId],
|
||||
},
|
||||
},
|
||||
} as unknown as AnyNodeDefinition)
|
||||
const child = {
|
||||
id: 'linked_child',
|
||||
type: 'linked-child',
|
||||
parentId: 'linked_parent',
|
||||
} as unknown as AnyNode
|
||||
const parent = {
|
||||
id: 'linked_parent',
|
||||
type: 'linked-floorplan-test',
|
||||
parentId: 'level_lower',
|
||||
children: [child.id],
|
||||
} as unknown as AnyNode
|
||||
const nodes = { [parent.id]: parent, [child.id]: child }
|
||||
|
||||
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
|
||||
{ id: parent.id, node: parent, children: [child] },
|
||||
])
|
||||
expect(
|
||||
collectFloorplanLinkedLevelNodes(
|
||||
nodes,
|
||||
'level_upper' as AnyNodeId,
|
||||
new Set([parent.id as AnyNodeId]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -47,6 +48,16 @@ import {
|
||||
snapDirectRotationDelta,
|
||||
} from '../../../lib/direct-manipulation'
|
||||
import { createEditorApi } from '../../../lib/editor-api'
|
||||
import {
|
||||
type FloorplanAnnotationVisibility,
|
||||
filterFloorplanAnnotationGeometry,
|
||||
} from '../../../lib/floorplan/annotation-visibility'
|
||||
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
|
||||
import {
|
||||
createFloorplanContextExtensions,
|
||||
type FloorplanWallDimensionReference,
|
||||
getFloorplanNodeExtension,
|
||||
} from '../../../lib/floorplan/floorplan-extension'
|
||||
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
||||
import {
|
||||
type ActiveInteractionScope,
|
||||
@@ -60,7 +71,10 @@ import {
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
||||
import useDrawingView from '../../../store/use-drawing-view'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
||||
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
|
||||
import useInteractionScope, {
|
||||
useEndpointReshape,
|
||||
useMovingNode,
|
||||
@@ -74,6 +88,14 @@ import {
|
||||
startFloorplanGroupRotate,
|
||||
} from '../floorplan-group-move'
|
||||
import { useFloorplanRender } from '../floorplan-render-context'
|
||||
import {
|
||||
floorplanAnnotationObstacleMode,
|
||||
isFloorplanAnnotationObstacleGeometry,
|
||||
observeSvgAnnotationLayoutChanges,
|
||||
resolveSvgAnnotationCollisions,
|
||||
svgAnnotationLabelId,
|
||||
} from './floorplan-annotation-layout'
|
||||
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
|
||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
@@ -276,6 +298,8 @@ type NodeDeps = {
|
||||
node: AnyNode
|
||||
live: LiveTransform | undefined
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation: 'meters' | 'millimeters'
|
||||
wallDimensionReference: FloorplanWallDimensionReference
|
||||
selected: boolean
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
@@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const showMeasurements = useViewer((s) => s.showMeasurements)
|
||||
const metricNotation = useViewer((s) => s.metricNotation)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
@@ -423,6 +447,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// selectors freeze to `undefined` so drag publishes do not re-render the
|
||||
// hidden floor-plan tree.
|
||||
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
|
||||
const drawingType = useDrawingView((s) => s.drawingType)
|
||||
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
|
||||
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
|
||||
// Elevator builders read runtime state imperatively, so entries include this
|
||||
// rare-changing ref in their cache deps.
|
||||
const interactiveElevators = useInteractive((s) => s.elevators)
|
||||
@@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
|
||||
if (!drawingNode) return
|
||||
const def = nodeRegistry.get(drawingNode.type)
|
||||
if (!def?.floorplan) return
|
||||
if (node.type === 'measurement' && !showMeasurements) return
|
||||
const dependsOnSiblingInputs = !!(
|
||||
def.floorplanDependsOnSiblings ||
|
||||
def.floorplanSiblingOverrides ||
|
||||
def.floorplanAffectedIds
|
||||
)
|
||||
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
|
||||
const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
|
||||
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
|
||||
out.push(descriptor)
|
||||
}
|
||||
@@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
visit(levelId as AnyNodeId)
|
||||
|
||||
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
||||
if (activeLevelNode) {
|
||||
const collectedIds = new Set(out.map((entry) => entry.id))
|
||||
for (const linked of collectFloorplanLinkedLevelNodes(
|
||||
nodes,
|
||||
levelId as AnyNodeId,
|
||||
collectedIds,
|
||||
)) {
|
||||
pushEntry(linked.id, linked.node, {
|
||||
children: linked.children,
|
||||
siblings: [],
|
||||
parent: activeLevelNode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
|
||||
// as siblings of the level, not under it — the `visit(levelId)` DFS
|
||||
// above doesn't reach them. Walk every node of those kinds whose
|
||||
@@ -871,7 +915,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// builders that gate on the current floor — e.g. elevator service
|
||||
// range — keep working). Pure registry-driven dispatch: no kind
|
||||
// name appears in this file.
|
||||
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
|
||||
const activeBuildingId = activeLevelNode
|
||||
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
||||
: null
|
||||
@@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
||||
}
|
||||
return { entries: out, levelNodeIdsByType }
|
||||
}, [installedPlugins, levelId, nodes, showMeasurements])
|
||||
}, [drawingType, installedPlugins, levelId, nodes])
|
||||
|
||||
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
||||
//
|
||||
@@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
<FloorplanRegistryEntry
|
||||
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
||||
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
||||
annotationVisibility={annotationVisibility}
|
||||
floorplanVisible={floorplanVisible}
|
||||
geometryCacheRef={geometryCacheRef}
|
||||
hatchPatternId={renderCtx?.hatchPatternId}
|
||||
@@ -1323,6 +1367,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
metricNotation={metricNotation}
|
||||
wallDimensionReference={wallDimensionReference}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
@@ -1341,6 +1387,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
<FloorplanRegistryEntry
|
||||
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
||||
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
||||
annotationVisibility={annotationVisibility}
|
||||
floorplanVisible={floorplanVisible}
|
||||
geometryCacheRef={geometryCacheRef}
|
||||
hatchPatternId={renderCtx?.hatchPatternId}
|
||||
@@ -1374,12 +1421,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
metricNotation={metricNotation}
|
||||
wallDimensionReference={wallDimensionReference}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
|
||||
{/* Dashed group bbox — shows what a group drag carries along while a
|
||||
multi-selection exists, rides the live delta mid-drag, and doubles
|
||||
as the group's whole-area drag handle. */}
|
||||
@@ -1403,9 +1453,136 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
)
|
||||
})
|
||||
|
||||
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
const markerRef = useRef<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 = {
|
||||
activeDragId: string | null
|
||||
activeRotateNodeId: AnyNodeId | null
|
||||
annotationVisibility: FloorplanAnnotationVisibility
|
||||
ctxOverrides: FloorplanContextOverrides | undefined
|
||||
floorplanVisible: boolean
|
||||
geometryCacheRef: { current: Map<string, CacheEntry> }
|
||||
@@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = {
|
||||
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
||||
siblingEpoch: number
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation: 'meters' | 'millimeters'
|
||||
wallDimensionReference: FloorplanWallDimensionReference
|
||||
unitsPerPixel: number
|
||||
visibilityRootId: AnyNodeId | undefined
|
||||
}
|
||||
@@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = {
|
||||
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
activeDragId,
|
||||
activeRotateNodeId,
|
||||
annotationVisibility,
|
||||
ctxOverrides,
|
||||
floorplanVisible,
|
||||
geometryCacheRef,
|
||||
@@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
setMovingNodeOrigin,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
unitsPerPixel,
|
||||
visibilityRootId,
|
||||
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
||||
@@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
selected,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
visibilityRootId,
|
||||
})
|
||||
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
||||
const visibleGeometry = rawGeometry
|
||||
? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
|
||||
: null
|
||||
// Multi-selection shows highlight only: strip this member's edit handles /
|
||||
// dimension chrome (all of which live in the overlay pass) while keeping
|
||||
// its highlighted body geometry.
|
||||
const geometry =
|
||||
rawGeometry && suppressHandles && pass === 'overlay'
|
||||
? stripHandleChrome(rawGeometry)
|
||||
: rawGeometry
|
||||
visibleGeometry && suppressHandles && pass === 'overlay'
|
||||
? stripHandleChrome(visibleGeometry)
|
||||
: visibleGeometry
|
||||
if (!geometry) return null
|
||||
|
||||
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
|
||||
@@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = {
|
||||
selected: boolean
|
||||
siblingEpoch: number
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation: 'meters' | 'millimeters'
|
||||
wallDimensionReference: FloorplanWallDimensionReference
|
||||
visibilityRootId: AnyNodeId | undefined
|
||||
}
|
||||
|
||||
@@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({
|
||||
selected,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
visibilityRootId,
|
||||
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
||||
const def = nodeRegistry.get(node.type)
|
||||
@@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({
|
||||
node,
|
||||
live,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
@@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({
|
||||
const viewState = {
|
||||
selected,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
@@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({
|
||||
siblings: ctxOverrides.siblings,
|
||||
parent: ctxOverrides.parent,
|
||||
levelData,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
metricNotation,
|
||||
purpose: 'edit',
|
||||
wallDimensionReference,
|
||||
}),
|
||||
viewState: palette
|
||||
? {
|
||||
selected,
|
||||
@@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = {
|
||||
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
||||
}
|
||||
|
||||
const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
geometry,
|
||||
unitsPerPixel,
|
||||
palette,
|
||||
@@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
case 'group': {
|
||||
const transform = formatGroupTransform(g.transform)
|
||||
return (
|
||||
<g 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>
|
||||
)
|
||||
@@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
||||
const plateW = textWidth + padX * 2
|
||||
const plateH = fontSize + padY * 2
|
||||
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority="20"
|
||||
key={keyHint}
|
||||
pointerEvents="none"
|
||||
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
|
||||
transform={labelTransform}
|
||||
>
|
||||
{outlined ? null : (
|
||||
<rect
|
||||
@@ -2598,147 +2809,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
}
|
||||
case 'dimension': {
|
||||
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 (
|
||||
<g key={keyHint} pointerEvents="none">
|
||||
{/* Extension lines (dashed). */}
|
||||
<line
|
||||
stroke={stroke}
|
||||
strokeDasharray="0.08 0.12"
|
||||
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]}
|
||||
<FloorplanDimensionRenderer
|
||||
geometry={g}
|
||||
key={keyHint}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
stroke={g.stroke ?? palette.measurementStroke}
|
||||
/>
|
||||
<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': {
|
||||
@@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
// horizontally on screen even when the floor-plan view is
|
||||
// rotated (default `sceneRotationDeg` is 90°).
|
||||
return (
|
||||
<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
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
fill={g.fill ?? '#171717'}
|
||||
@@ -2775,6 +2856,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
geometry={g}
|
||||
key={keyHint}
|
||||
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2853,6 +2935,9 @@ export function buildContext(
|
||||
viewState: {
|
||||
selected: boolean
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation?: 'meters' | 'millimeters'
|
||||
purpose?: 'edit' | 'document'
|
||||
wallDimensionReference?: FloorplanWallDimensionReference
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
moving: boolean
|
||||
@@ -2892,6 +2977,11 @@ export function buildContext(
|
||||
siblings,
|
||||
parent,
|
||||
levelData,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
metricNotation: viewState.metricNotation ?? 'meters',
|
||||
purpose: viewState.purpose ?? 'edit',
|
||||
wallDimensionReference: viewState.wallDimensionReference,
|
||||
}),
|
||||
viewState: viewState.palette
|
||||
? {
|
||||
selected: viewState.selected,
|
||||
@@ -2905,6 +2995,29 @@ export function buildContext(
|
||||
}
|
||||
}
|
||||
|
||||
export function collectFloorplanLinkedLevelNodes(
|
||||
nodes: Record<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
|
||||
* payload. Used to track hover / active visual state when multiple
|
||||
@@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
||||
'move-arrow',
|
||||
'rotate-arrow',
|
||||
'dimension',
|
||||
'dimension-string',
|
||||
'dimension-label',
|
||||
'equal-spacing-badge',
|
||||
])
|
||||
@@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
} {
|
||||
if (isFloorplanAnnotationObstacleGeometry(g)) {
|
||||
return { base: null, overlay: g }
|
||||
}
|
||||
if (OVERLAY_KINDS.has(g.kind)) {
|
||||
return { base: null, overlay: g }
|
||||
}
|
||||
@@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
'node',
|
||||
'live',
|
||||
'unit',
|
||||
'metricNotation',
|
||||
'wallDimensionReference',
|
||||
'selected',
|
||||
'highlighted',
|
||||
'hovered',
|
||||
|
||||
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
|
||||
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
|
||||
}
|
||||
|
||||
function getFloorplanStairBreakStep(stepCount: number) {
|
||||
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
|
||||
}
|
||||
|
||||
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
|
||||
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
|
||||
return 0
|
||||
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
const angle = sectorStartAngle + stepSweep * index
|
||||
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
|
||||
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
|
||||
const dashedFromIndex = Math.floor(stepCount * 0.68)
|
||||
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${stair.id}:spiral-step:${index}`}
|
||||
pointerEvents="none"
|
||||
stroke={index === stepCount ? curvedAccent : curvedStroke}
|
||||
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
|
||||
strokeWidth={index === stepCount ? '1.8' : '1.15'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={toSvgX(innerPoint.x)}
|
||||
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
const angle = sectorStartAngle + stepSweep * index
|
||||
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
|
||||
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
|
||||
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<line
|
||||
@@ -397,7 +405,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
strokeWidth={isSelectionActive ? '2' : '1.35'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{treadBars.map((treadBar, treadIndex) => (
|
||||
{treadBars
|
||||
.slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
|
||||
.map((treadBar, treadIndex) => (
|
||||
<polygon
|
||||
fill={straightTread}
|
||||
key={`${segment.id}:tread:${treadIndex}`}
|
||||
|
||||
@@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver
|
||||
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
|
||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
|
||||
import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer'
|
||||
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
|
||||
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
|
||||
import {
|
||||
@@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
frontSide: 'unknown',
|
||||
@@ -2719,9 +2721,10 @@ function formatMeasurement(
|
||||
value: number,
|
||||
unit: 'metric' | 'imperial',
|
||||
metersPerUnit: number | null = null,
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
) {
|
||||
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
|
||||
return formatLinearMeasurement(measuredValue, unit)
|
||||
return formatLinearMeasurement(measuredValue, unit, metricNotation)
|
||||
}
|
||||
|
||||
function formatNumber(value: number, fractionDigits = 2) {
|
||||
@@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({
|
||||
unitsPerPixel: number
|
||||
}) {
|
||||
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
if (!cursor) {
|
||||
return null
|
||||
}
|
||||
@@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({
|
||||
label={`Ref ${formatMeasurement(
|
||||
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
|
||||
unit,
|
||||
null,
|
||||
metricNotation,
|
||||
)}`}
|
||||
palette={palette}
|
||||
start={start}
|
||||
@@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
|
||||
unit: 'metric' | 'imperial'
|
||||
unitsPerPixel: number
|
||||
}) {
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
|
||||
return null
|
||||
}
|
||||
@@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
|
||||
labelAngleDeg += 180
|
||||
}
|
||||
|
||||
const label = formatMeasurement(length, unit)
|
||||
const label = formatMeasurement(length, unit, null, metricNotation)
|
||||
const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
|
||||
const height = fontSize + padY * 2
|
||||
|
||||
@@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({
|
||||
unitsPerPixel: number
|
||||
sceneRotationDeg: number
|
||||
}) {
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
|
||||
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
|
||||
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
|
||||
@@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({
|
||||
}
|
||||
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
lengthLabel: formatMeasurement(length, unit, null, metricNotation),
|
||||
midpoint: [
|
||||
(wallDraftStart[0] + wallDraftEnd[0]) / 2,
|
||||
(wallDraftStart[1] + wallDraftEnd[1]) / 2,
|
||||
@@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({
|
||||
direction: [dx / length, dy / length] as WallPlanPoint,
|
||||
angleLabels,
|
||||
}
|
||||
}, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
|
||||
}, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -5244,6 +5252,7 @@ export function FloorplanPanel({
|
||||
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const showGuides = useViewer((state) => state.showGuides)
|
||||
const setShowGuides = useViewer((state) => state.setShowGuides)
|
||||
@@ -11169,7 +11178,12 @@ export function FloorplanPanel({
|
||||
Drawn line
|
||||
</div>
|
||||
<div className="mt-1 font-medium text-sm">
|
||||
{formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)}
|
||||
{formatMeasurement(
|
||||
pendingReferenceScale.measuredLengthUnits,
|
||||
unit,
|
||||
null,
|
||||
metricNotation,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({
|
||||
<FloorplanWallMoveGhostLayer />
|
||||
</g>
|
||||
<FloorplanMeasurementToolLayer />
|
||||
<FloorplanRegisteredToolLayer />
|
||||
{floorplanSceneSlot}
|
||||
</FloorplanRenderProvider>
|
||||
{/* 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
|
||||
// feet/inches. Shared by every measurement readout so they read the same.
|
||||
export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string {
|
||||
export function formatMeasurement(
|
||||
value: number,
|
||||
unit: 'metric' | 'imperial',
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
): string {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
@@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
|
||||
|
||||
function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
|
||||
function formatMetric(
|
||||
metric: QuickMeasurementMetric,
|
||||
unit: 'metric' | 'imperial',
|
||||
metricNotation: 'meters' | 'millimeters',
|
||||
): string {
|
||||
if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
|
||||
if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
|
||||
return formatLinearMeasurement(metric.value, unit)
|
||||
return formatLinearMeasurement(metric.value, unit, metricNotation)
|
||||
}
|
||||
|
||||
export const QuickMeasurementCard = memo(function QuickMeasurementCard({
|
||||
report,
|
||||
unit,
|
||||
metricNotation,
|
||||
lensState,
|
||||
}: {
|
||||
report: QuickMeasurementReport
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation: 'meters' | 'millimeters'
|
||||
lensState: 'live' | 'pinned'
|
||||
}) {
|
||||
const pinned = lensState === 'pinned'
|
||||
@@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
|
||||
<span className="truncate">{metric.label}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
|
||||
{formatMetric(metric, unit)}
|
||||
{formatMetric(metric, unit, metricNotation)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function QuickMeasurementHud() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
@@ -21,7 +22,12 @@ export function QuickMeasurementHud() {
|
||||
data-quick-measure-hud
|
||||
>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import type {
|
||||
ConstructionDimensionChainMode,
|
||||
ConstructionDimensionMode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
Box,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CircleIcon,
|
||||
Crosshair,
|
||||
Grid2X2,
|
||||
Minus,
|
||||
Ruler,
|
||||
ScanSearch,
|
||||
Square,
|
||||
@@ -37,27 +42,64 @@ const measurementMenuOptions = [
|
||||
...measurementOptions,
|
||||
] as const
|
||||
|
||||
const constructionDimensionOptions = [
|
||||
{ mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler },
|
||||
{ mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints },
|
||||
{ mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon },
|
||||
{ mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon },
|
||||
{ mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair },
|
||||
{ mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus },
|
||||
{ mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon },
|
||||
{ mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle },
|
||||
{ mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 },
|
||||
] as const satisfies readonly {
|
||||
mode: ConstructionDimensionMode
|
||||
chainMode: ConstructionDimensionChainMode
|
||||
label: string
|
||||
icon: typeof Ruler
|
||||
}[]
|
||||
|
||||
export function MeasurementControl() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const selectedKind = useEditor((state) => state.lastMeasurementKind)
|
||||
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
||||
const constructionDimensionChainMode = useEditor(
|
||||
(state) => state.toolDefaults['construction-dimension']?.chainMode,
|
||||
)
|
||||
const constructionDimensionMode = useEditor(
|
||||
(state) => state.toolDefaults['construction-dimension']?.mode,
|
||||
)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setToolDefaults = useEditor((state) => state.setToolDefaults)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
|
||||
const setViewMode = useEditor((state) => state.setViewMode)
|
||||
|
||||
const selectedOption =
|
||||
measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
|
||||
const isActive = mode === 'build' && tool === 'measurement'
|
||||
const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension'
|
||||
const activeConstructionDimensionOption = constructionDimensionOptions.find(
|
||||
(option) =>
|
||||
option.mode === (constructionDimensionMode ?? 'linear') &&
|
||||
option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'),
|
||||
)
|
||||
const isControlActive = isActive || isConstructionDimensionActive
|
||||
const isSmartActive = isActive && activeToolKind === 'smart'
|
||||
const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon
|
||||
const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label
|
||||
const SelectedIcon = isConstructionDimensionActive
|
||||
? (activeConstructionDimensionOption?.icon ?? Ruler)
|
||||
: isSmartActive
|
||||
? ScanSearch
|
||||
: selectedOption.icon
|
||||
const selectedLabel = isConstructionDimensionActive
|
||||
? (activeConstructionDimensionOption?.label ?? 'Linear dimension')
|
||||
: isSmartActive
|
||||
? 'Smart'
|
||||
: selectedOption.label
|
||||
|
||||
const activateMeasurement = (kind: CreatableMeasurementKind) => {
|
||||
setPhase('structure')
|
||||
@@ -69,7 +111,7 @@ export function MeasurementControl() {
|
||||
}
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
if (isActive) {
|
||||
if (isControlActive) {
|
||||
setMode('select')
|
||||
return
|
||||
}
|
||||
@@ -84,15 +126,27 @@ export function MeasurementControl() {
|
||||
setTool('measurement')
|
||||
}
|
||||
|
||||
const activateConstructionDimension = (
|
||||
dimensionMode: ConstructionDimensionMode,
|
||||
chainMode: ConstructionDimensionChainMode,
|
||||
) => {
|
||||
setPhase('structure')
|
||||
setStructureLayer('elements')
|
||||
setViewMode('2d')
|
||||
setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode })
|
||||
setMode('build')
|
||||
setTool('construction-dimension')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||
<div className="flex items-center">
|
||||
<ActionButton
|
||||
aria-label={`Measure: ${selectedLabel}`}
|
||||
aria-pressed={isActive}
|
||||
aria-pressed={isControlActive}
|
||||
className={cn(
|
||||
'rounded-r-none p-0 text-muted-foreground',
|
||||
isActive
|
||||
isControlActive
|
||||
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
|
||||
: 'hover:bg-cyan-500/15 hover:text-cyan-400',
|
||||
)}
|
||||
@@ -128,7 +182,7 @@ export function MeasurementControl() {
|
||||
|
||||
<PopoverContent
|
||||
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"
|
||||
sideOffset={14}
|
||||
>
|
||||
@@ -138,7 +192,7 @@ export function MeasurementControl() {
|
||||
const isSmart = option.kind === 'smart'
|
||||
const isSelected = isSmart
|
||||
? isSmartActive
|
||||
: !isSmartActive && option.kind === selectedKind
|
||||
: !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind
|
||||
return (
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
@@ -165,22 +219,37 @@ export function MeasurementControl() {
|
||||
})}
|
||||
|
||||
<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
|
||||
aria-checked={showMeasurements}
|
||||
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"
|
||||
onClick={() => setShowMeasurements(!showMeasurements)}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
|
||||
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"
|
||||
>
|
||||
{showMeasurements ? (
|
||||
<Eye aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
<span>Show measurements</span>
|
||||
<span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
|
||||
<OptionIcon 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}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</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 { TreeView, VisualJson } from '@visual-json/react'
|
||||
import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
|
||||
@@ -394,14 +399,14 @@ export function SettingsPanel({
|
||||
</div>
|
||||
|
||||
<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
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('full')}
|
||||
variant="outline"
|
||||
>
|
||||
<MapIcon className="size-4" />
|
||||
Full floorplan
|
||||
Full floor plan
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
|
||||
@@ -81,6 +81,9 @@ export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
} 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 {
|
||||
FloorplanNodePreview,
|
||||
type FloorplanNodePreviewProps,
|
||||
@@ -332,6 +335,28 @@ export {
|
||||
type FloorplanStairSegmentEntry,
|
||||
getFloorplanWallThickness,
|
||||
} 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 { exportSceneToGlb } from './lib/glb-export'
|
||||
export {
|
||||
@@ -462,6 +487,10 @@ export {
|
||||
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
export {
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
default as useDrawingView,
|
||||
} from './store/use-drawing-view'
|
||||
export type {
|
||||
CaptureMode,
|
||||
FloorplanSelectionTool,
|
||||
@@ -485,7 +514,13 @@ export {
|
||||
export { default as useFacingPose, type FacingPose } from './store/use-facing-pose'
|
||||
export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
|
||||
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 {
|
||||
default as useFloorplanPreflight,
|
||||
type FloorplanPreflightIssue,
|
||||
type FloorplanPreflightIssueKind,
|
||||
} from './store/use-floorplan-preflight'
|
||||
export {
|
||||
default as useInteractionScope,
|
||||
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
|
||||
// screen. The on-screen floor-plan scene `<g>` is rotated by
|
||||
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF
|
||||
// export mirrors the aligned-to-north case (user offset 0) so an export points
|
||||
// the same way as the app's north-aligned view.
|
||||
// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export
|
||||
// uses the same user rotation so its orientation matches the live plan.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -72,6 +72,11 @@ describe('linear measurements', () => {
|
||||
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', () => {
|
||||
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
|
||||
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MeasurementPoint } from '@pascal-app/core'
|
||||
|
||||
export type LinearUnit = 'metric' | 'imperial'
|
||||
export type MetricNotation = 'meters' | 'millimeters'
|
||||
|
||||
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
|
||||
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
|
||||
@@ -173,7 +174,11 @@ export function formatVolumeLabel(
|
||||
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 '--'
|
||||
|
||||
const absoluteMeters = Math.abs(meters)
|
||||
@@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin
|
||||
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 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
|
||||
@@ -140,7 +140,7 @@ export type Phase = 'site' | 'structure' | 'furnish'
|
||||
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
|
||||
|
||||
// Structure mode tools (building elements)
|
||||
export type StructureTool =
|
||||
type BuiltInStructureTool =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'room'
|
||||
@@ -149,6 +149,7 @@ export type StructureTool =
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'column'
|
||||
| 'structural-grid'
|
||||
| 'elevator'
|
||||
| 'stair'
|
||||
| 'item'
|
||||
@@ -178,6 +179,9 @@ export type StructureTool =
|
||||
| 'pipe-fitting'
|
||||
| 'pipe-trap'
|
||||
|
||||
/** Registry node kinds are valid build tools without central union edits. */
|
||||
export type StructureTool = BuiltInStructureTool | (string & {})
|
||||
|
||||
// Furnish mode tools (items and decoration)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
|
||||
*/
|
||||
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
kind: 'building',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: BuildingNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
type GroupMoveSnapArgs,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
|
||||
import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances'
|
||||
import { columnFloorplanMoveTarget } from './floorplan-move'
|
||||
import { columnPaint } from './paint'
|
||||
@@ -295,6 +300,18 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
|
||||
return handles
|
||||
}
|
||||
|
||||
function resolveColumnStructuralGridMoveSnap({
|
||||
candidatePosition,
|
||||
nodes,
|
||||
levelId,
|
||||
}: GroupMoveSnapArgs): [number, number, number] | null {
|
||||
const snap = resolveStructuralGridSnap(
|
||||
[candidatePosition[0], candidatePosition[2]],
|
||||
collectStructuralGridAxes(nodes, levelId),
|
||||
)
|
||||
return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Column — Stage A registration. Wrap-export of the legacy
|
||||
* `ColumnRenderer` (no system — column geometry is computed inline in
|
||||
@@ -334,7 +351,11 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
// Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the
|
||||
// mode-driven snapping the overhaul standardised). 2D move keeps using
|
||||
// `floorplanMoveTarget`, which wins the 2D move dispatch.
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
movable: {
|
||||
axes: ['x', 'z'],
|
||||
gridSnap: true,
|
||||
groupMoveSnap: resolveColumnStructuralGridMoveSnap,
|
||||
},
|
||||
slots: (node) => columnSlots(node as ColumnNodeType),
|
||||
paint: columnPaint,
|
||||
// Slab elevation lift via the generic `<FloorElevationSystem>` + the
|
||||
@@ -374,6 +395,8 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
{ key: 'Left click', label: 'Place column' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
computeFloorplanLevelData: computeColumnFloorplanLevelData,
|
||||
floorplanDependsOnSiblings: true,
|
||||
floorplan: buildColumnFloorplan,
|
||||
// 2D body move routes through this kind-specific target so the column
|
||||
// aligns by its footprint *edges* (and snaps flush to wall faces) instead
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for column. Columns need the same footprint-edge
|
||||
@@ -65,10 +69,15 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget<ColumnNode> = ({ nod
|
||||
candidates,
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(snapped, collectStructuralGridAxes(nodes, node.parentId))
|
||||
: null
|
||||
const coordinated = structuralSnap?.point ?? snapped
|
||||
const next: [number, number, number] = [coordinated[0], originalPosition[1], coordinated[1]]
|
||||
lastPosition = next
|
||||
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
const snapKey = `${coordinated[0]},${coordinated[1]}`
|
||||
if (snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { ColumnNode, type GeometryContext, StructuralGridNode } from '@pascal-app/core'
|
||||
import { readFloorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan'
|
||||
|
||||
const context = {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
} satisfies GeometryContext
|
||||
|
||||
describe('buildColumnFloorplan', () => {
|
||||
test('marks the structural center of the column footprint', () => {
|
||||
const column = ColumnNode.parse({
|
||||
id: 'column_main',
|
||||
parentId: 'level_main',
|
||||
position: [2, 0, 3],
|
||||
crossSection: 'square',
|
||||
width: 0.4,
|
||||
depth: 0.4,
|
||||
})
|
||||
|
||||
const geometry = buildColumnFloorplan(column, context)
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
|
||||
expect(geometry.children[0]?.kind).toBe('polygon')
|
||||
expect(readFloorplanGeometryMetadata(geometry.children[0]!)).toMatchObject({
|
||||
annotationObstacle: 'bounds',
|
||||
})
|
||||
|
||||
expect(geometry.children.filter((child) => child.kind === 'line')).toEqual([
|
||||
expect.objectContaining({
|
||||
x1: 1.91,
|
||||
y1: 2.91,
|
||||
x2: 2.09,
|
||||
y2: 3.09,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
x1: 1.91,
|
||||
y1: 3.09,
|
||||
x2: 2.09,
|
||||
y2: 2.91,
|
||||
pointerEvents: 'none',
|
||||
}),
|
||||
])
|
||||
expect(
|
||||
geometry.children
|
||||
.filter((child) => child.kind === 'line')
|
||||
.every((child) => readFloorplanGeometryMetadata(child).annotationRole === 'column-center'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('labels a column with its associative structural-grid reference', () => {
|
||||
const column = ColumnNode.parse({
|
||||
id: 'column_main',
|
||||
parentId: 'level_main',
|
||||
position: [2, 0, 3],
|
||||
crossSection: 'square',
|
||||
width: 0.4,
|
||||
depth: 0.4,
|
||||
})
|
||||
const vertical = StructuralGridNode.parse({
|
||||
id: 'structural-grid_2',
|
||||
parentId: 'level_main',
|
||||
start: [2, 0],
|
||||
end: [2, 6],
|
||||
label: '2',
|
||||
})
|
||||
const horizontal = StructuralGridNode.parse({
|
||||
id: 'structural-grid_b',
|
||||
parentId: 'level_main',
|
||||
start: [0, 3],
|
||||
end: [6, 3],
|
||||
label: 'B',
|
||||
})
|
||||
const levelData = computeColumnFloorplanLevelData({
|
||||
siblings: [column],
|
||||
nodes: {
|
||||
[column.id]: column,
|
||||
[vertical.id]: vertical,
|
||||
[horizontal.id]: horizontal,
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildColumnFloorplan(column, { ...context, levelData })
|
||||
expect(geometry?.kind).toBe('group')
|
||||
if (geometry?.kind !== 'group') return
|
||||
|
||||
const label = geometry.children.find((child) => child.kind === 'text' && child.text === 'B-2')
|
||||
expect(label).toMatchObject({ kind: 'text', text: 'B-2', upright: true })
|
||||
expect(label && readFloorplanGeometryMetadata(label).annotationRole).toBe('column-center')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,16 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
ColumnNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
StructuralGridNode,
|
||||
} from '@pascal-app/core'
|
||||
import { floorplanGeometryMetadata } from '@pascal-app/editor'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridReference,
|
||||
} from '../structural-grid/coordination'
|
||||
import type { ColumnResizePayload } from './floorplan-affordances'
|
||||
|
||||
// Offsets for the floor-plan selection arrows. Resize chevrons hug the
|
||||
@@ -11,6 +18,8 @@ import type { ColumnResizePayload } from './floorplan-affordances'
|
||||
// further out so it doesn't crowd the resize arrows.
|
||||
const RESIZE_ARROW_OFFSET = 0.12
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
const GRID_REFERENCE_OFFSET = 0.16
|
||||
const GRID_REFERENCE_FONT_SIZE = 0.13
|
||||
|
||||
const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
||||
'round',
|
||||
@@ -18,6 +27,22 @@ const ROUND_CROSS_SECTIONS = new Set<ColumnNode['crossSection']>([
|
||||
'sixteen-sided',
|
||||
])
|
||||
|
||||
export type ColumnFloorplanLevelData = {
|
||||
structuralGrids: StructuralGridNode[]
|
||||
}
|
||||
|
||||
export function computeColumnFloorplanLevelData({
|
||||
siblings,
|
||||
nodes,
|
||||
}: {
|
||||
siblings: readonly ColumnNode[]
|
||||
nodes: Record<string, AnyNode>
|
||||
}): ColumnFloorplanLevelData {
|
||||
return {
|
||||
structuralGrids: collectStructuralGridAxes(nodes, siblings[0]?.parentId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for column. Inlined from the legacy
|
||||
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
|
||||
@@ -34,8 +59,8 @@ export function buildColumnFloorplan(
|
||||
node: ColumnNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = getColumnPlanFootprint(node)
|
||||
if (polygon.length < 3) return null
|
||||
const points = getColumnFloorplanFootprint(node)
|
||||
if (points.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
@@ -46,8 +71,6 @@ export function buildColumnFloorplan(
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
|
||||
|
||||
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
@@ -56,8 +79,60 @@ export function buildColumnFloorplan(
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }),
|
||||
},
|
||||
]
|
||||
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
||||
const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45))
|
||||
const centerX = node.position[0]
|
||||
const centerZ = node.position[2]
|
||||
children.push(
|
||||
{
|
||||
kind: 'line',
|
||||
x1: centerX - centerMarkHalf,
|
||||
y1: centerZ - centerMarkHalf,
|
||||
x2: centerX + centerMarkHalf,
|
||||
y2: centerZ + centerMarkHalf,
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: centerX - centerMarkHalf,
|
||||
y1: centerZ + centerMarkHalf,
|
||||
x2: centerX + centerMarkHalf,
|
||||
y2: centerZ - centerMarkHalf,
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
},
|
||||
)
|
||||
|
||||
const levelData = ctx.levelData as ColumnFloorplanLevelData | undefined
|
||||
const gridReference = resolveStructuralGridReference(
|
||||
[centerX, centerZ],
|
||||
levelData?.structuralGrids ?? [],
|
||||
)
|
||||
if (gridReference) {
|
||||
children.push({
|
||||
kind: 'text',
|
||||
x: centerX,
|
||||
y: centerZ + halfZ + GRID_REFERENCE_OFFSET,
|
||||
text: gridReference,
|
||||
fontSize: GRID_REFERENCE_FONT_SIZE,
|
||||
fill: stroke,
|
||||
fontWeight: 700,
|
||||
textAnchor: 'middle',
|
||||
dominantBaseline: 'middle',
|
||||
upright: true,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
})
|
||||
}
|
||||
|
||||
// Hatch overlay on selected — same `<defs>` pattern as the wall.
|
||||
if (isSelected && palette) {
|
||||
@@ -146,7 +221,6 @@ export function buildColumnFloorplan(
|
||||
// Rotate-arrow at the +X / +Z corner — matches the 3D
|
||||
// `columnRotateHandle` corner placement so users see the rotation
|
||||
// affordance in the same quadrant across views.
|
||||
const { halfX, halfZ } = columnPlanHalfExtents(node)
|
||||
const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET
|
||||
const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot)
|
||||
@@ -163,6 +237,10 @@ export function buildColumnFloorplan(
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] {
|
||||
return getColumnPlanFootprint(node).map((point) => [point.x, point.y])
|
||||
}
|
||||
|
||||
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
|
||||
|
||||
type PlanPoint = { x: number; y: number }
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
stopPlacementCommitPropagation,
|
||||
subscribeFloorPlacementClicks,
|
||||
} from '../shared/floor-placement'
|
||||
import {
|
||||
collectStructuralGridAxes,
|
||||
resolveStructuralGridSnap,
|
||||
} from '../structural-grid/coordination'
|
||||
import { ColumnPreview } from './renderer'
|
||||
|
||||
const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId
|
||||
@@ -87,7 +91,7 @@ const ColumnTool = () => {
|
||||
setCursorVisible(true)
|
||||
}
|
||||
|
||||
const { position, guides } = resolveAlignedFloorPlacement({
|
||||
const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({
|
||||
node: previewNode,
|
||||
rawX: event.localPosition[0],
|
||||
rawZ: event.localPosition[2],
|
||||
@@ -97,7 +101,18 @@ const ColumnTool = () => {
|
||||
applyAlignmentSnap: isMagneticSnapActive(),
|
||||
bypassGrid: !isGridSnapActive(),
|
||||
})
|
||||
useAlignmentGuides.getState().set(guides)
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(
|
||||
[alignedPosition[0], alignedPosition[2]],
|
||||
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
|
||||
)
|
||||
: null
|
||||
const position: [number, number, number] = structuralSnap
|
||||
? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]]
|
||||
: alignedPosition
|
||||
if (structuralSnap) useAlignmentGuides.getState().clear()
|
||||
else useAlignmentGuides.getState().set(guides)
|
||||
|
||||
const visualPosition = getFloorStackPreviewPosition({
|
||||
node: previewNode,
|
||||
@@ -134,7 +149,7 @@ const ColumnTool = () => {
|
||||
}
|
||||
|
||||
const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => {
|
||||
const position =
|
||||
const fallbackPosition =
|
||||
lastCursorRef.current ??
|
||||
getLevelLocalSnappedPosition(
|
||||
activeLevelId,
|
||||
@@ -142,6 +157,16 @@ const ColumnTool = () => {
|
||||
useEditor.getState().gridSnapStep,
|
||||
!isGridSnapActive(),
|
||||
)
|
||||
const structuralSnap =
|
||||
isGridSnapActive() || isMagneticSnapActive()
|
||||
? resolveStructuralGridSnap(
|
||||
[fallbackPosition[0], fallbackPosition[2]],
|
||||
collectStructuralGridAxes(useScene.getState().nodes, activeLevelId),
|
||||
)
|
||||
: null
|
||||
const position: [number, number, number] = structuralSnap
|
||||
? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]]
|
||||
: fallbackPosition
|
||||
|
||||
const column = ColumnNode.parse({
|
||||
...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { constructionDimensionDefinition } from './definition'
|
||||
|
||||
describe('constructionDimensionDefinition', () => {
|
||||
test('registers a selectable floor-plan construction annotation', () => {
|
||||
expect(constructionDimensionDefinition.kind).toBe('construction-dimension')
|
||||
expect(constructionDimensionDefinition.category).toBe('analysis')
|
||||
expect(constructionDimensionDefinition.bake).toBe('strip')
|
||||
expect(constructionDimensionDefinition.schemaVersion).toBe(7)
|
||||
expect(constructionDimensionDefinition.dirtyTracking).toBe(false)
|
||||
expect(constructionDimensionDefinition.capabilities).toMatchObject({
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
|
||||
'move-construction-dimension-baseline',
|
||||
)
|
||||
expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty(
|
||||
'move-construction-dimension-witness',
|
||||
)
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
constructionDimensionDefinition.schema.safeParse({
|
||||
id: 'construction-dimension_default',
|
||||
type: 'construction-dimension',
|
||||
...constructionDimensionDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { measurementAnchorReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
|
||||
import { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
import {
|
||||
moveConstructionDimensionBaselineAffordance,
|
||||
moveConstructionDimensionWitnessAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { constructionDimensionParametrics } from './parametrics'
|
||||
import { ConstructionDimensionNode } from './schema'
|
||||
|
||||
export const constructionDimensionDefinition: NodeDefinition<typeof ConstructionDimensionNode> = {
|
||||
kind: 'construction-dimension',
|
||||
bake: 'strip',
|
||||
schemaVersion: 7,
|
||||
schema: ConstructionDimensionNode,
|
||||
category: 'analysis',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
resolveForDrawing: resolveConstructionDimensionForDrawing,
|
||||
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
|
||||
},
|
||||
snapProfile: 'item',
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 0.6], direction: [1, 0] },
|
||||
chainMode: 'point-to-point',
|
||||
mode: 'linear',
|
||||
featureCount: 1,
|
||||
showCenterMark: true,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
textOverride: null,
|
||||
datumPolicy: 'centerline',
|
||||
terminator: 'architectural-tick',
|
||||
textPosition: 'above',
|
||||
imperialPrecision: '1/16',
|
||||
metricNotation: 'meters',
|
||||
extensionStartGap: 0.075,
|
||||
extensionOvershoot: 0.12,
|
||||
drawingType: 'floor-plan',
|
||||
drawingOverrides: [],
|
||||
controllingDimensionId: null,
|
||||
}),
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
parametrics: constructionDimensionParametrics,
|
||||
floorplan: buildConstructionDimensionFloorplan,
|
||||
floorplanDependencies: (node) => [
|
||||
...measurementAnchorReferenceNodeIds(node.anchors),
|
||||
...(node.controllingDimensionId ? [node.controllingDimensionId] : []),
|
||||
],
|
||||
floorplanAffordances: {
|
||||
'move-construction-dimension-baseline': moveConstructionDimensionBaselineAffordance,
|
||||
'move-construction-dimension-witness': moveConstructionDimensionWitnessAffordance,
|
||||
},
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Pick witness point' },
|
||||
{ key: 'Enter', label: 'Finish multi-point witnesses' },
|
||||
{ key: 'Left click', label: 'Place dimension line when needed' },
|
||||
{ key: 'Backspace', label: 'Remove last witness' },
|
||||
{ key: 'Esc', label: 'Step back or cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Construction Dimension',
|
||||
description: 'Associative linear, curved, circular, angular, or coordinate plan dimension.',
|
||||
icon: { kind: 'iconify', name: 'lucide:ruler-dimension-line' },
|
||||
hidden: true,
|
||||
actionMenu: false,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'An associative construction dimension with linear, curved, circular, angular, and coordinate modes, semantic witness anchors, document notation overrides, and coordinated plan-view presentation.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, ConstructionDimensionNode } from '@pascal-app/core'
|
||||
import { resolveConstructionDimensionForDrawing } from './drawing-coordination'
|
||||
|
||||
const foundation = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_foundation',
|
||||
drawingType: 'foundation-plan',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[6, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 2], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const resolve = (
|
||||
node: ConstructionDimensionNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
drawingType: 'floor-plan' | 'foundation-plan',
|
||||
) => resolveConstructionDimensionForDrawing({ node, nodes, drawingType })
|
||||
|
||||
describe('resolveConstructionDimensionForDrawing', () => {
|
||||
test('omits a dimension outside its primary drawing by default', () => {
|
||||
expect(resolve(foundation, { [foundation.id]: foundation }, 'floor-plan')).toBeNull()
|
||||
expect(resolve(foundation, { [foundation.id]: foundation }, 'foundation-plan')).toBe(foundation)
|
||||
})
|
||||
|
||||
test('applies view-specific suppressed segments without changing physical anchors', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
],
|
||||
drawingOverrides: [
|
||||
{
|
||||
drawingType: 'floor-plan',
|
||||
presentation: 'shown',
|
||||
suppressedSegmentIndexes: [1],
|
||||
},
|
||||
],
|
||||
})
|
||||
const resolved = resolve(node, { [node.id]: node }, 'floor-plan')
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
id: node.id,
|
||||
anchors: node.anchors,
|
||||
metadata: { suppressedDimensionSegmentIndexes: [1] },
|
||||
})
|
||||
expect(node.metadata).toEqual({})
|
||||
})
|
||||
|
||||
test('derives linked floor-plan geometry from a controlling foundation dimension', () => {
|
||||
const floor = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_floor',
|
||||
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
|
||||
controllingDimensionId: foundation.id,
|
||||
anchors: [
|
||||
[1, 0, 1],
|
||||
[2, 0, 1],
|
||||
],
|
||||
})
|
||||
const nodes = { [floor.id]: floor, [foundation.id]: foundation } as Record<string, AnyNode>
|
||||
const resolved = resolve(floor, nodes, 'floor-plan')
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
id: floor.id,
|
||||
anchors: foundation.anchors,
|
||||
baseline: foundation.baseline,
|
||||
metadata: { drawingCoordinationLocked: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('marks a missing foundation controller as unlinked', () => {
|
||||
const floor = ConstructionDimensionNode.parse({
|
||||
drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }],
|
||||
controllingDimensionId: 'construction-dimension_missing',
|
||||
prefix: 'TYP · ',
|
||||
})
|
||||
expect(resolve(floor, { [floor.id]: floor }, 'floor-plan')).toMatchObject({
|
||||
prefix: 'UNLINKED CONTROL · TYP · ',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type ConstructionDimensionNode,
|
||||
type ConstructionDrawingType,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function resolveConstructionDimensionForDrawing(args: {
|
||||
node: ConstructionDimensionNode
|
||||
nodes: Record<string, AnyNode>
|
||||
drawingType: ConstructionDrawingType
|
||||
}): ConstructionDimensionNode | null {
|
||||
const { node, nodes, drawingType } = args
|
||||
const presentation = resolveConstructionDimensionDrawingPresentation(node, drawingType)
|
||||
if (presentation === 'omit') return null
|
||||
if (presentation === 'shown') return applyDrawingOverride(node, drawingType)
|
||||
|
||||
const controller = node.controllingDimensionId ? nodes[node.controllingDimensionId] : undefined
|
||||
if (
|
||||
controller?.type !== 'construction-dimension' ||
|
||||
controller.id === node.id ||
|
||||
controller.drawingType !== 'foundation-plan'
|
||||
) {
|
||||
return {
|
||||
...node,
|
||||
metadata: lockedMetadata(node),
|
||||
prefix: `UNLINKED CONTROL · ${node.prefix}`,
|
||||
}
|
||||
}
|
||||
|
||||
return resolveControlledDimension(node, controller)
|
||||
}
|
||||
|
||||
function resolveControlledDimension(
|
||||
node: ConstructionDimensionNode,
|
||||
controller: ConstructionDimensionNode,
|
||||
): ConstructionDimensionNode {
|
||||
const overridden = applyDrawingOverride(node, 'floor-plan')
|
||||
return {
|
||||
...overridden,
|
||||
metadata: lockedMetadata(overridden),
|
||||
anchors: controller.anchors,
|
||||
baseline: controller.baseline,
|
||||
chainMode: controller.chainMode,
|
||||
mode: controller.mode,
|
||||
showCenterMark: controller.showCenterMark,
|
||||
}
|
||||
}
|
||||
|
||||
function applyDrawingOverride(
|
||||
node: ConstructionDimensionNode,
|
||||
drawingType: ConstructionDrawingType,
|
||||
): ConstructionDimensionNode {
|
||||
const override = resolveConstructionDimensionDrawingOverride(node, drawingType)
|
||||
if (!override || override.suppressedSegmentIndexes.length === 0) return node
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...(typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata)
|
||||
? node.metadata
|
||||
: {}),
|
||||
suppressedDimensionSegmentIndexes: override.suppressedSegmentIndexes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function lockedMetadata(node: ConstructionDimensionNode): ConstructionDimensionNode['metadata'] {
|
||||
const metadata =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? node.metadata
|
||||
: {}
|
||||
return { ...metadata, drawingCoordinationLocked: true }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { moveConstructionDimensionWitnessAffordance } from './floorplan-affordances'
|
||||
|
||||
type RafFn = (cb: (t: number) => void) => number
|
||||
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||
cb: (t: number) => void,
|
||||
) => {
|
||||
cb(0)
|
||||
return 0
|
||||
}) as RafFn
|
||||
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||
() => {}
|
||||
|
||||
const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
|
||||
|
||||
function seedScene() {
|
||||
const levelId = 'level_construction-dimension-affordance' as AnyNodeId
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_dimension-target',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
parentId: levelId,
|
||||
})
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_drag-witness',
|
||||
parentId: levelId,
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:centerline',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [1, 0, 0],
|
||||
},
|
||||
[4, 0, 0],
|
||||
],
|
||||
})
|
||||
const level = {
|
||||
id: levelId,
|
||||
type: 'level',
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: '',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
children: [wall.id, dimension.id],
|
||||
} as unknown as AnyNode
|
||||
const nodes = { [levelId]: level, [wall.id]: wall, [dimension.id]: dimension } as Record<
|
||||
AnyNodeId,
|
||||
AnyNode
|
||||
>
|
||||
|
||||
useScene.setState({ nodes: nodes as never })
|
||||
return { dimension, nodes, wall }
|
||||
}
|
||||
|
||||
describe('moveConstructionDimensionWitnessAffordance', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('reassociates a dragged witness to a nearby semantic wall feature', () => {
|
||||
const { dimension, nodes, wall } = seedScene()
|
||||
const session = moveConstructionDimensionWitnessAffordance.start({
|
||||
node: dimension,
|
||||
payload: { witnessIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 0.04], modifiers: MODIFIERS })
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
|
||||
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
|
||||
const anchor = updated.anchors[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (!Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(wall.id)
|
||||
expect(anchor.reference.featureId).toMatch(/^wall:/)
|
||||
expect(anchor.fallback[0]).toBeCloseTo(3)
|
||||
}
|
||||
})
|
||||
|
||||
test('detaches a dragged witness as an explicit free point when Alt bypasses association', () => {
|
||||
const { dimension, nodes } = seedScene()
|
||||
const session = moveConstructionDimensionWitnessAffordance.start({
|
||||
node: dimension,
|
||||
payload: { witnessIndex: 0 },
|
||||
nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 2], modifiers: { ...MODIFIERS, altKey: true } })
|
||||
expect(session.canCommit()).toBe(true)
|
||||
session.commit?.()
|
||||
|
||||
const updated = useScene.getState().nodes[dimension.id] as typeof dimension
|
||||
expect(updated.anchors[0]).toEqual([3, 0, 2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
ConstructionDimensionNode,
|
||||
type ConstructionDimensionNode as ConstructionDimensionNodeType,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
resolveSurfacePlanPointSnap,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { matchMeasurementFeatureForNode, resolveMeasurementAnchor } from '../measurement/resolve'
|
||||
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
|
||||
|
||||
function semanticWitnessAnchor(
|
||||
point: MeasurementPoint,
|
||||
wallIds: readonly string[],
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
maxDistance: number,
|
||||
): MeasurementAnchor {
|
||||
const matches = wallIds.flatMap((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return []
|
||||
const match = matchMeasurementFeatureForNode(
|
||||
node,
|
||||
(nodeId) => nodes[nodeId],
|
||||
point,
|
||||
maxDistance,
|
||||
)
|
||||
return match ? [{ match, node }] : []
|
||||
})
|
||||
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
|
||||
if (!closest) return point
|
||||
return {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: closest.node.id,
|
||||
featureId: closest.match.feature.id,
|
||||
parameters: closest.match.parameters,
|
||||
},
|
||||
fallback: closest.match.point,
|
||||
}
|
||||
}
|
||||
|
||||
function withRefreshedFallbacks(
|
||||
node: ConstructionDimensionNodeType,
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
): ConstructionDimensionNodeType['anchors'] {
|
||||
return node.anchors.map((anchor) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const resolved = resolveMeasurementAnchor(anchor, (id) => nodes[id])
|
||||
return { ...anchor, fallback: resolved.point }
|
||||
})
|
||||
}
|
||||
|
||||
export const moveConstructionDimensionWitnessAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
|
||||
{
|
||||
start({ node, nodes, payload }): FloorplanAffordanceSession {
|
||||
const witnessIndex = (payload as { witnessIndex?: unknown }).witnessIndex
|
||||
const originalAnchors = withRefreshedFallbacks(node, nodes)
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
let latest: ConstructionDimensionNodeType['anchors'] | null = null
|
||||
|
||||
if (!Number.isInteger(witnessIndex)) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const forceFree = modifiers.altKey === true
|
||||
const gridStep = !forceFree && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||||
const fallbackPoint: [number, number] =
|
||||
gridStep > 0
|
||||
? [
|
||||
Math.round(planPoint[0] / gridStep) * gridStep,
|
||||
Math.round(planPoint[1] / gridStep) * gridStep,
|
||||
]
|
||||
: [planPoint[0], planPoint[1]]
|
||||
const magnetic = !forceFree && isMagneticSnapActive()
|
||||
const snapped = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [planPoint[0], planPoint[1]],
|
||||
fallbackPoint,
|
||||
excludeId: node.id,
|
||||
levelId,
|
||||
movingId: node.id,
|
||||
nodes,
|
||||
magnetic,
|
||||
})
|
||||
const point: MeasurementPoint = [snapped.point[0], 0, snapped.point[1]]
|
||||
const nextAnchor = semanticWitnessAnchor(
|
||||
point,
|
||||
snapped.wallIds,
|
||||
nodes,
|
||||
magnetic ? SEMANTIC_FEATURE_SNAP_DISTANCE : SEMANTIC_FEATURE_BYPASS_DISTANCE,
|
||||
)
|
||||
const anchors = originalAnchors.map((anchor, index) =>
|
||||
index === witnessIndex ? nextAnchor : anchor,
|
||||
)
|
||||
if (!ConstructionDimensionNode.safeParse({ ...node, anchors }).success) {
|
||||
latest = null
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
return
|
||||
}
|
||||
latest = anchors
|
||||
useLiveNodeOverrides.getState().set(node.id, { anchors })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
const anchors = latest
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (anchors) useScene.getState().updateNode(node.id, { anchors })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const moveConstructionDimensionBaselineAffordance: FloorplanAffordance<ConstructionDimensionNodeType> =
|
||||
{
|
||||
start({ node }) {
|
||||
let latest: [number, number] | null = null
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint }) {
|
||||
const origin: [number, number] = [planPoint[0], planPoint[1]]
|
||||
const baseline = { ...node.baseline, origin }
|
||||
if (!ConstructionDimensionNode.safeParse({ ...node, baseline }).success) return
|
||||
latest = origin
|
||||
useLiveNodeOverrides.getState().set(node.id, { baseline })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (latest) {
|
||||
useScene.getState().updateNode(node.id, {
|
||||
baseline: { ...node.baseline, origin: latest },
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type MeasurementPoint, WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
buildConstructionDimensionPreviewGeometries,
|
||||
buildCurvedWallConstructionDimensionDraft,
|
||||
constructionDimensionUsesBaseline,
|
||||
normalizeConstructionDimensionChainMode,
|
||||
normalizeConstructionDimensionMode,
|
||||
resolveConstructionDimensionDraftDirection,
|
||||
} from './floorplan-tool'
|
||||
|
||||
describe('continuous construction-dimension drafting', () => {
|
||||
test('derives a stable baseline direction from the first witness pair', () => {
|
||||
expect(
|
||||
resolveConstructionDimensionDraftDirection([
|
||||
[1, 0, 2],
|
||||
[4, 0, 6],
|
||||
[8, 0, 7],
|
||||
]),
|
||||
).toEqual([0.6, 0.8])
|
||||
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
|
||||
})
|
||||
|
||||
test('previews one adjacent dimension for every witness interval', () => {
|
||||
const geometry = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
[0, 0, 2],
|
||||
'metric',
|
||||
)
|
||||
|
||||
expect(geometry).toHaveLength(3)
|
||||
expect(geometry.map((segment) => segment.text)).toEqual(['2m', '3m', '4m'])
|
||||
expect(geometry[1]).toMatchObject({
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 2],
|
||||
dimensionEnd: [5, 2],
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizes unknown tool defaults to the point-to-point workflow', () => {
|
||||
expect(normalizeConstructionDimensionChainMode('continuous')).toBe('continuous')
|
||||
expect(normalizeConstructionDimensionChainMode('unknown')).toBe('point-to-point')
|
||||
})
|
||||
|
||||
test('normalizes curved and circular construction-dimension modes', () => {
|
||||
expect(normalizeConstructionDimensionMode('radius')).toBe('radius')
|
||||
expect(normalizeConstructionDimensionMode('arc-length')).toBe('arc-length')
|
||||
expect(normalizeConstructionDimensionMode('unknown')).toBe('linear')
|
||||
})
|
||||
|
||||
test('previews radius and diameter notation before commit', () => {
|
||||
const points: MeasurementPoint[] = [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
]
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
|
||||
).toMatchObject({ text: 'R 2m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
|
||||
).toMatchObject({ text: 'Ø 2m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'angular'),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('previews the arc value leader while placing the fourth point', () => {
|
||||
const preview = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
[3, 0, 3],
|
||||
'metric',
|
||||
'arc-length',
|
||||
)
|
||||
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]).toMatchObject({
|
||||
kind: 'group',
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'path' }),
|
||||
expect.objectContaining({ kind: 'line', x2: 3, y2: 3 }),
|
||||
expect.objectContaining({ kind: 'dimension-label', text: 'ARC 3.14m' }),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
test('previews the angular arc and value while placing the fourth point', () => {
|
||||
const preview = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
[1.5, 0, 0.5],
|
||||
'metric',
|
||||
'angular',
|
||||
)
|
||||
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]).toMatchObject({
|
||||
kind: 'group',
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'path' }),
|
||||
expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }),
|
||||
expect.objectContaining({
|
||||
kind: 'dimension-label',
|
||||
cx: 1.5,
|
||||
cy: 0.5,
|
||||
text: '∠ 90°',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
test('only requests a label baseline for modes that use one', () => {
|
||||
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
|
||||
})
|
||||
|
||||
test('derives associative radius, chord, and center drafts from one curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
|
||||
anchors: [
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
|
||||
],
|
||||
points: [
|
||||
[2, 0, 1.5],
|
||||
[2, 0, -1],
|
||||
],
|
||||
})
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
|
||||
{ reference: { featureId: 'wall:start' } },
|
||||
{ reference: { featureId: 'wall:end' } },
|
||||
])
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'center-mark')?.anchors).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps arc length in the manual start-center-end and baseline workflow', () => {
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'arc-length')).toBeNull()
|
||||
expect(constructionDimensionUsesBaseline('arc-length')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps angular dimensions in the manual ray-center-ray and baseline workflow', () => {
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'angular')).toBeNull()
|
||||
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps manual point drafting for straight walls and unsupported modes', () => {
|
||||
const straight = WallNode.parse({ start: [0, 0], end: [4, 0] })
|
||||
const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 })
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(straight, 'radius')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,726 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionChainMode,
|
||||
type ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
closestMeasurementFeatureBinding,
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
getWallArcData,
|
||||
getWallCurveFrameAt,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementFeatureAnchor,
|
||||
type MeasurementPoint,
|
||||
nodeRegistry,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildSvgArcPath,
|
||||
clearSurfacePlanSnapFeedback,
|
||||
FloorplanGeometryRenderer,
|
||||
type FloorplanToolContext,
|
||||
formatLinearMeasurement,
|
||||
getArcPlanPoint,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
markToolCancelConsumed,
|
||||
resolveSurfacePlanPointSnap,
|
||||
triggerSFX,
|
||||
useDrawingView,
|
||||
useFloorplanRender,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { resolveCircularConstructionDimensionLayout } from './geometry'
|
||||
|
||||
const SEMANTIC_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_BYPASS_DISTANCE = 0.012
|
||||
const MIN_DIMENSION_LENGTH = 0.001
|
||||
const MIN_ARC_SWEEP = 1e-6
|
||||
|
||||
type Draft = {
|
||||
anchors: MeasurementAnchor[]
|
||||
points: MeasurementPoint[]
|
||||
stage: 'witnesses' | 'baseline'
|
||||
}
|
||||
|
||||
type AssociatedPoint = {
|
||||
anchor: MeasurementAnchor
|
||||
point: MeasurementPoint
|
||||
semantic: boolean
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
const emptyDraft = (): Draft => ({ anchors: [], points: [], stage: 'witnesses' })
|
||||
|
||||
function geometryContext(node: AnyNode, nodes: Record<AnyNodeId, AnyNode>): GeometryContext {
|
||||
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
||||
nodes[id] as N | undefined
|
||||
const childIds =
|
||||
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
||||
const children = childIds
|
||||
.map((id) => nodes[id])
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
|
||||
const siblings =
|
||||
parent && 'children' in parent && Array.isArray(parent.children)
|
||||
? (parent.children as AnyNodeId[])
|
||||
.map((id) => nodes[id])
|
||||
.filter(
|
||||
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
||||
)
|
||||
: []
|
||||
return { resolve, children, parent, siblings }
|
||||
}
|
||||
|
||||
function associatePoint(
|
||||
point: MeasurementPoint,
|
||||
targetNodeId: string | null,
|
||||
maxDistance: number,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): AssociatedPoint {
|
||||
if (!targetNodeId) return { anchor: point, point, semantic: false, targetNodeId: null }
|
||||
const node = nodes[targetNodeId as AnyNodeId]
|
||||
const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined
|
||||
if (!(node && contribution)) return { anchor: point, point, semantic: false, targetNodeId }
|
||||
const context = geometryContext(node, nodes)
|
||||
const features = contribution.features(node, context)
|
||||
const match =
|
||||
contribution.match?.(node, context, point, maxDistance) ??
|
||||
closestMeasurementFeatureBinding(features, point, maxDistance)
|
||||
if (!match) return { anchor: point, point, semantic: false, targetNodeId }
|
||||
|
||||
const reference = {
|
||||
nodeId: node.id,
|
||||
featureId: match.featureId,
|
||||
parameters: match.parameters,
|
||||
}
|
||||
const anchor: MeasurementFeatureAnchor = {
|
||||
kind: 'feature',
|
||||
reference,
|
||||
fallback: match.point,
|
||||
}
|
||||
return { anchor, point: match.point, semantic: true, targetNodeId }
|
||||
}
|
||||
|
||||
function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number) {
|
||||
const matrix = group.getScreenCTM()
|
||||
if (!matrix) return null
|
||||
const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse())
|
||||
return [local.x, 0, local.y] satisfies MeasurementPoint
|
||||
}
|
||||
|
||||
function registryTargetNodeId(target: EventTarget | null): string | null {
|
||||
if (!(target instanceof Element)) return null
|
||||
return (
|
||||
target.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')?.dataset.nodeId ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveConstructionDimensionDraftDirection(
|
||||
points: readonly MeasurementPoint[],
|
||||
): [number, number] | null {
|
||||
if (points.length < 2) return null
|
||||
const dx = points[1]![0] - points[0]![0]
|
||||
const dz = points[1]![2] - points[0]![2]
|
||||
const magnitude = Math.hypot(dx, dz)
|
||||
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
|
||||
}
|
||||
|
||||
export function buildConstructionDimensionPreviewGeometries(
|
||||
points: readonly MeasurementPoint[],
|
||||
baselinePoint: MeasurementPoint,
|
||||
unit: 'metric' | 'imperial',
|
||||
mode: ConstructionDimensionMode = 'linear',
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
): FloorplanGeometry[] {
|
||||
if (mode === 'arc-length' || mode === 'angular') {
|
||||
const layout = resolveCircularConstructionDimensionLayout(mode, points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > MIN_ARC_SWEEP)) return []
|
||||
const center = { x: layout.center[0], y: layout.center[1] }
|
||||
const end = getArcPlanPoint(center, layout.radius, layout.endAngle)
|
||||
const arcMid = getArcPlanPoint(center, layout.radius, layout.startAngle + layout.sweep / 2)
|
||||
const stroke = '#06b6d4'
|
||||
const lineStyle = {
|
||||
fill: 'none',
|
||||
pointerEvents: 'none' as const,
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke' as const,
|
||||
}
|
||||
if (mode === 'angular') {
|
||||
const endRadius = Math.hypot(layout.end[0] - center.x, layout.end[1] - center.y)
|
||||
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
|
||||
const requestedRadius = Math.hypot(baselinePoint[0] - center.x, baselinePoint[2] - center.y)
|
||||
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = getArcPlanPoint(center, arcRadius, midAngle)
|
||||
const startRayEnd = getArcPlanPoint(
|
||||
center,
|
||||
Math.max(layout.radius, arcRadius + 0.12),
|
||||
layout.startAngle,
|
||||
)
|
||||
const endRayEnd = getArcPlanPoint(
|
||||
center,
|
||||
Math.max(endRadius, arcRadius + 0.12),
|
||||
layout.endAngle,
|
||||
)
|
||||
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
|
||||
const formattedDegrees = Number.parseFloat(degrees.toFixed(degrees < 10 ? 1 : 0))
|
||||
return [
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'line',
|
||||
x1: center.x,
|
||||
y1: center.y,
|
||||
x2: startRayEnd.x,
|
||||
y2: startRayEnd.y,
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: center.x,
|
||||
y1: center.y,
|
||||
x2: endRayEnd.x,
|
||||
y2: endRayEnd.y,
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'path',
|
||||
d: buildSvgArcPath(
|
||||
center,
|
||||
arcRadius,
|
||||
layout.startAngle,
|
||||
layout.startAngle + layout.sweep,
|
||||
),
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: arcMid.x,
|
||||
y1: arcMid.y,
|
||||
x2: baselinePoint[0],
|
||||
y2: baselinePoint[2],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
cx: baselinePoint[0],
|
||||
cy: baselinePoint[2],
|
||||
text: `∠ ${formattedDegrees}°`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
appearance: 'outlined',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'path',
|
||||
d: buildSvgArcPath(
|
||||
center,
|
||||
layout.radius,
|
||||
layout.startAngle,
|
||||
layout.startAngle + layout.sweep,
|
||||
),
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: layout.center[0],
|
||||
y1: layout.center[1],
|
||||
x2: layout.start[0],
|
||||
y2: layout.start[1],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: layout.center[0],
|
||||
y1: layout.center[1],
|
||||
x2: end.x,
|
||||
y2: end.y,
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: arcMid.x,
|
||||
y1: arcMid.y,
|
||||
x2: baselinePoint[0],
|
||||
y2: baselinePoint[2],
|
||||
strokeDasharray: '6 5',
|
||||
...lineStyle,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
cx: baselinePoint[0],
|
||||
cy: baselinePoint[2],
|
||||
text: `ARC ${formatLinearMeasurement(layout.arcLength, unit, metricNotation)}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
appearance: 'outlined',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(points)
|
||||
if (!direction) return []
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const project = (point: MeasurementPoint): [number, number] => {
|
||||
const along =
|
||||
(point[0] - baselinePoint[0]) * direction[0] + (point[2] - baselinePoint[2]) * direction[1]
|
||||
return [baselinePoint[0] + along * direction[0], baselinePoint[2] + along * direction[1]]
|
||||
}
|
||||
const dimensionPoints = points.map(project)
|
||||
return points.slice(0, -1).map((start, index) => {
|
||||
const end = points[index + 1]!
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[2] - start[2]
|
||||
const value = Math.abs(dx * direction[0] + dz * direction[1])
|
||||
const rawText = formatLinearMeasurement(value, unit, metricNotation)
|
||||
const text =
|
||||
mode === 'radius'
|
||||
? `R ${rawText}`
|
||||
: mode === 'diameter'
|
||||
? `Ø ${rawText}`
|
||||
: mode === 'chord'
|
||||
? `CH ${rawText}`
|
||||
: rawText
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: [start[0], start[2]],
|
||||
end: [end[0], end[2]],
|
||||
dimensionStart: dimensionPoints[index]!,
|
||||
dimensionEnd: dimensionPoints[index + 1]!,
|
||||
offsetNormal: normal,
|
||||
offsetDistance: 0,
|
||||
extensionOvershoot: 0.12,
|
||||
text,
|
||||
stroke: '#06b6d4',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeConstructionDimensionChainMode(
|
||||
value: unknown,
|
||||
): ConstructionDimensionChainMode {
|
||||
return value === 'continuous' ? 'continuous' : 'point-to-point'
|
||||
}
|
||||
|
||||
export function normalizeConstructionDimensionMode(value: unknown): ConstructionDimensionMode {
|
||||
return [
|
||||
'radius',
|
||||
'diameter',
|
||||
'center-mark',
|
||||
'chord',
|
||||
'arc-length',
|
||||
'angular',
|
||||
'coordinate',
|
||||
].includes(value as string)
|
||||
? (value as ConstructionDimensionMode)
|
||||
: 'linear'
|
||||
}
|
||||
|
||||
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
|
||||
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
}
|
||||
|
||||
function wallFeatureAnchor(
|
||||
wall: WallNode,
|
||||
featureId: string,
|
||||
fallback: MeasurementPoint,
|
||||
): MeasurementFeatureAnchor {
|
||||
return {
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId },
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCurvedWallConstructionDimensionDraft(
|
||||
wall: WallNode,
|
||||
mode: ConstructionDimensionMode,
|
||||
): Pick<Draft, 'anchors' | 'points'> | null {
|
||||
const arc = getWallArcData(wall)
|
||||
if (!arc) return null
|
||||
|
||||
const center: MeasurementPoint = [arc.center.x, 0, arc.center.y]
|
||||
const start: MeasurementPoint = [wall.start[0], 0, wall.start[1]]
|
||||
const end: MeasurementPoint = [wall.end[0], 0, wall.end[1]]
|
||||
const midpointFrame = getWallCurveFrameAt(wall, 0.5)
|
||||
const midpoint: MeasurementPoint = [midpointFrame.point.x, 0, midpointFrame.point.y]
|
||||
const feature = (featureId: string, fallback: MeasurementPoint) =>
|
||||
wallFeatureAnchor(wall, featureId, fallback)
|
||||
|
||||
switch (mode) {
|
||||
case 'radius':
|
||||
case 'center-mark':
|
||||
return {
|
||||
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
|
||||
points: [center, midpoint],
|
||||
}
|
||||
case 'chord':
|
||||
return {
|
||||
anchors: [feature('wall:start', start), feature('wall:end', end)],
|
||||
points: [start, end],
|
||||
}
|
||||
case 'arc-length':
|
||||
case 'angular':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function FloorplanConstructionDimensionToolLayer({
|
||||
activeLevelId,
|
||||
finishTool,
|
||||
gridSnapStep,
|
||||
metricNotation,
|
||||
sceneApi,
|
||||
selectNode,
|
||||
toolDefaults,
|
||||
unit,
|
||||
}: FloorplanToolContext) {
|
||||
const groupRef = useRef<SVGGElement>(null)
|
||||
const draftRef = useRef<Draft>(emptyDraft())
|
||||
const [draft, setDraft] = useState<Draft>(draftRef.current)
|
||||
const [hover, setHover] = useState<AssociatedPoint | null>(null)
|
||||
const chainMode = normalizeConstructionDimensionChainMode(toolDefaults?.chainMode)
|
||||
const dimensionMode = normalizeConstructionDimensionMode(toolDefaults?.mode)
|
||||
const collectsMany =
|
||||
dimensionMode === 'coordinate' || (dimensionMode === 'linear' && chainMode === 'continuous')
|
||||
const usesBaseline = constructionDimensionUsesBaseline(dimensionMode)
|
||||
const renderContext = useFloorplanRender()
|
||||
const drawingType = useDrawingView((state) => state.drawingType)
|
||||
|
||||
useEffect(() => {
|
||||
useInteractionScope.getState().begin({ kind: 'drafting', tool: 'construction-dimension' })
|
||||
return () =>
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf((scope) => scope.kind === 'drafting' && scope.tool === 'construction-dimension')
|
||||
}, [])
|
||||
|
||||
const updateDraft = useCallback((next: Draft) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
updateDraft(emptyDraft())
|
||||
setHover(null)
|
||||
const group = groupRef.current
|
||||
const svg = group?.ownerSVGElement
|
||||
if (!(activeLevelId && group && svg)) return
|
||||
|
||||
const consume = (event: Event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
const resolveEvent = (event: MouseEvent | PointerEvent): AssociatedPoint | null => {
|
||||
const raw = clientToPlanPoint(group, event.clientX, event.clientY)
|
||||
if (!raw) return null
|
||||
const forceFree = event.altKey
|
||||
const gridStep = !forceFree && isGridSnapActive() ? gridSnapStep : 0
|
||||
const fallbackPoint: [number, number] =
|
||||
gridStep > 0
|
||||
? [Math.round(raw[0] / gridStep) * gridStep, Math.round(raw[2] / gridStep) * gridStep]
|
||||
: [raw[0], raw[2]]
|
||||
const magnetic = !forceFree && isMagneticSnapActive()
|
||||
const surface = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [raw[0], raw[2]],
|
||||
fallbackPoint,
|
||||
levelId: activeLevelId,
|
||||
align: false,
|
||||
magnetic,
|
||||
})
|
||||
const point: MeasurementPoint = [surface.point[0], 0, surface.point[1]]
|
||||
const targetNodeId = surface.wallIds[0] ?? registryTargetNodeId(event.target)
|
||||
return associatePoint(
|
||||
point,
|
||||
targetNodeId,
|
||||
magnetic ? SEMANTIC_SNAP_DISTANCE : SEMANTIC_BYPASS_DISTANCE,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
}
|
||||
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
|
||||
const direction = resolveConstructionDimensionDraftDirection(current.points)
|
||||
const originPoint = baselinePoint ?? current.points.at(-1)
|
||||
if (!(direction && originPoint)) return false
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
name:
|
||||
dimensionMode === 'linear' && chainMode === 'continuous'
|
||||
? 'Continuous Dimension'
|
||||
: `${dimensionMode.replaceAll('-', ' ')} Dimension`,
|
||||
anchors: current.anchors,
|
||||
baseline: {
|
||||
origin: [originPoint[0], originPoint[2]],
|
||||
direction,
|
||||
},
|
||||
chainMode,
|
||||
mode: dimensionMode,
|
||||
drawingType,
|
||||
})
|
||||
sceneApi.upsert(node, activeLevelId)
|
||||
selectNode(node.id)
|
||||
triggerSFX('sfx:structure-build')
|
||||
finishTool()
|
||||
updateDraft(emptyDraft())
|
||||
setHover(null)
|
||||
return true
|
||||
}
|
||||
const finishWitnesses = () => {
|
||||
const current = draftRef.current
|
||||
const required = constructionDimensionRequiredAnchorCount(dimensionMode)
|
||||
if (current.stage !== 'witnesses' || current.points.length < required) return false
|
||||
if (!usesBaseline) return commitDraft(current)
|
||||
updateDraft({ ...current, stage: 'baseline' })
|
||||
triggerSFX('sfx:grid-snap')
|
||||
return true
|
||||
}
|
||||
const removeLastWitness = () => {
|
||||
const current = draftRef.current
|
||||
if (current.points.length === 0) return false
|
||||
updateDraft({
|
||||
anchors: current.anchors.slice(0, -1),
|
||||
points: current.points.slice(0, -1),
|
||||
stage: 'witnesses',
|
||||
})
|
||||
return true
|
||||
}
|
||||
const commitAt = (associated: AssociatedPoint) => {
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') commitDraft(current, associated.point)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0) consume(event)
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
consume(event)
|
||||
setHover(resolveEvent(event))
|
||||
}
|
||||
const onPointerLeave = () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
setHover(null)
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
consume(event)
|
||||
if (event.detail > 1) return
|
||||
const associated = resolveEvent(event)
|
||||
if (!associated) return
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') {
|
||||
commitAt(associated)
|
||||
return
|
||||
}
|
||||
const targetNode = associated.targetNodeId
|
||||
? sceneApi.get(associated.targetNodeId as AnyNodeId)
|
||||
: undefined
|
||||
const curvedWallDraft =
|
||||
current.points.length === 0 && targetNode?.type === 'wall'
|
||||
? buildCurvedWallConstructionDimensionDraft(targetNode, dimensionMode)
|
||||
: null
|
||||
if (curvedWallDraft) {
|
||||
const next: Draft = { ...curvedWallDraft, stage: 'witnesses' }
|
||||
updateDraft(next)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
|
||||
else commitDraft(next)
|
||||
return
|
||||
}
|
||||
const previous = current.points.at(-1)
|
||||
if (
|
||||
previous &&
|
||||
Math.hypot(associated.point[0] - previous[0], associated.point[2] - previous[2]) <=
|
||||
MIN_DIMENSION_LENGTH
|
||||
) {
|
||||
return
|
||||
}
|
||||
const next: Draft = {
|
||||
anchors: [...current.anchors, associated.anchor],
|
||||
points: [...current.points, associated.point],
|
||||
stage: 'witnesses',
|
||||
}
|
||||
updateDraft(next)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
if (
|
||||
!collectsMany &&
|
||||
next.points.length === constructionDimensionRequiredAnchorCount(dimensionMode)
|
||||
) {
|
||||
if (usesBaseline) updateDraft({ ...next, stage: 'baseline' })
|
||||
else commitDraft(next)
|
||||
}
|
||||
}
|
||||
const onDoubleClick = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !collectsMany) return
|
||||
consume(event)
|
||||
finishWitnesses()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && collectsMany) {
|
||||
if (!finishWitnesses()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
if (!removeLastWitness()) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
markToolCancelConsumed()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
markToolCancelConsumed()
|
||||
const current = draftRef.current
|
||||
if (current.stage === 'baseline') {
|
||||
updateDraft({ ...current, stage: 'witnesses' })
|
||||
return
|
||||
}
|
||||
if (removeLastWitness()) return
|
||||
finishTool()
|
||||
}
|
||||
const onBlur = () => clearSurfacePlanSnapFeedback()
|
||||
|
||||
svg.addEventListener('pointerdown', onPointerDown, true)
|
||||
svg.addEventListener('pointermove', onPointerMove, true)
|
||||
svg.addEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.addEventListener('click', onClick, true)
|
||||
svg.addEventListener('dblclick', onDoubleClick, true)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
svg.removeEventListener('pointerdown', onPointerDown, true)
|
||||
svg.removeEventListener('pointermove', onPointerMove, true)
|
||||
svg.removeEventListener('pointerleave', onPointerLeave, true)
|
||||
svg.removeEventListener('click', onClick, true)
|
||||
svg.removeEventListener('dblclick', onDoubleClick, true)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [
|
||||
activeLevelId,
|
||||
chainMode,
|
||||
collectsMany,
|
||||
dimensionMode,
|
||||
drawingType,
|
||||
finishTool,
|
||||
gridSnapStep,
|
||||
sceneApi,
|
||||
selectNode,
|
||||
updateDraft,
|
||||
usesBaseline,
|
||||
])
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
draft.stage === 'baseline' && hover
|
||||
? buildConstructionDimensionPreviewGeometries(
|
||||
draft.points,
|
||||
hover.point,
|
||||
unit,
|
||||
dimensionMode,
|
||||
metricNotation,
|
||||
)
|
||||
: [],
|
||||
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
|
||||
)
|
||||
const witnessDraftPoints =
|
||||
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
|
||||
|
||||
if (!activeLevelId) return null
|
||||
const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01
|
||||
const reticleRadius = 10 * unitsPerPixel
|
||||
const hoverColor = hover?.semantic ? '#22c55e' : '#06b6d4'
|
||||
|
||||
return (
|
||||
<g ref={groupRef}>
|
||||
{witnessDraftPoints.length >= 2 && (draft.stage === 'witnesses' || preview.length === 0) ? (
|
||||
<polyline
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
points={witnessDraftPoints.map((point) => `${point[0]},${point[2]}`).join(' ')}
|
||||
stroke="#06b6d4"
|
||||
strokeDasharray="6 5"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null}
|
||||
{preview.map((geometry, index) => (
|
||||
<FloorplanGeometryRenderer
|
||||
annotationUnitsPerPoint={unitsPerPixel}
|
||||
geometry={geometry}
|
||||
key={`${index}-${geometry.kind}`}
|
||||
sceneRotationDeg={renderContext?.sceneRotationDeg ?? 0}
|
||||
/>
|
||||
))}
|
||||
{draft.points.map((point, index) => (
|
||||
<circle
|
||||
fill="#06b6d4"
|
||||
key={`${index}-${point.join('-')}`}
|
||||
pointerEvents="none"
|
||||
r={4.5 * unitsPerPixel}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
cx={point[0]}
|
||||
cy={point[2]}
|
||||
/>
|
||||
))}
|
||||
{hover ? (
|
||||
<g pointerEvents="none">
|
||||
<circle
|
||||
cx={hover.point[0]}
|
||||
cy={hover.point[2]}
|
||||
fill="none"
|
||||
r={reticleRadius}
|
||||
stroke={hoverColor}
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<line
|
||||
stroke={hoverColor}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover.point[0] - reticleRadius * 1.4}
|
||||
x2={hover.point[0] + reticleRadius * 1.4}
|
||||
y1={hover.point[2]}
|
||||
y2={hover.point[2]}
|
||||
/>
|
||||
<line
|
||||
stroke={hoverColor}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={hover.point[0]}
|
||||
x2={hover.point[0]}
|
||||
y1={hover.point[2] - reticleRadius * 1.4}
|
||||
y2={hover.point[2] + reticleRadius * 1.4}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorplanConstructionDimensionToolLayer
|
||||
@@ -0,0 +1,518 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
ConstructionDimensionNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions } from '@pascal-app/editor'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
|
||||
const palette = {
|
||||
selectedStroke: '#2563eb',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedHatch: '#93c5fd',
|
||||
wallHoverStroke: '#60a5fa',
|
||||
endpointHandleFill: '#f97316',
|
||||
endpointHandleStroke: '#ffffff',
|
||||
endpointHandleHoverStroke: '#fdba74',
|
||||
endpointHandleActiveFill: '#ea580c',
|
||||
endpointHandleActiveStroke: '#ffffff',
|
||||
curveHandleFill: '#14b8a6',
|
||||
curveHandleStroke: '#ffffff',
|
||||
curveHandleHoverStroke: '#5eead4',
|
||||
measurementStroke: '#334155',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#0f172a',
|
||||
}
|
||||
|
||||
function context(
|
||||
nodes: Record<string, AnyNode> = {},
|
||||
selected = false,
|
||||
purpose: 'edit' | 'document' = 'edit',
|
||||
metricNotation?: 'meters' | 'millimeters',
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: (id) => nodes[id],
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({ metricNotation, purpose }),
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] {
|
||||
return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry]
|
||||
}
|
||||
|
||||
function dimensionSegments(geometry: FloorplanGeometry | null): Array<{
|
||||
start: readonly [number, number]
|
||||
end: readonly [number, number]
|
||||
dimensionStart?: readonly [number, number]
|
||||
dimensionEnd?: readonly [number, number]
|
||||
text: string
|
||||
stroke?: string
|
||||
}> {
|
||||
if (!geometry) return []
|
||||
return flatten(geometry).flatMap((entry) => {
|
||||
if (entry.kind === 'dimension') return [entry]
|
||||
if (entry.kind === 'dimension-string')
|
||||
return entry.segments.map((segment) => ({ ...segment, stroke: entry.stroke }))
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildConstructionDimensionFloorplan', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
})
|
||||
|
||||
afterEach(() => nodeRegistry._reset())
|
||||
|
||||
test('projects witness origins onto the placed baseline', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[1, 0, 1],
|
||||
[4, 0, 2],
|
||||
],
|
||||
baseline: { origin: [0, 5], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimension = dimensionSegments(geometry)[0]
|
||||
|
||||
expect(dimension).toMatchObject({
|
||||
start: [1, 1],
|
||||
end: [4, 2],
|
||||
dimensionStart: [1, 5],
|
||||
dimensionEnd: [4, 5],
|
||||
text: '3m',
|
||||
})
|
||||
})
|
||||
|
||||
test('follows semantic anchors and reports dangling references', () => {
|
||||
const wall = WallNode.parse({ id: 'wall_target', start: [0, 0], end: [4, 0] })
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0],
|
||||
},
|
||||
[4, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const linked = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
|
||||
const movedWall = WallNode.parse({ ...wall, start: [2, 0], end: [6, 0] })
|
||||
const moved = buildConstructionDimensionFloorplan(node, context({ [wall.id]: movedWall }))
|
||||
const dangling = buildConstructionDimensionFloorplan(node, context())
|
||||
const linkedDimension = dimensionSegments(linked)[0]
|
||||
const movedDimension = dimensionSegments(moved)[0]
|
||||
const danglingDimension = dimensionSegments(dangling)[0]
|
||||
|
||||
expect(linkedDimension).toMatchObject({ start: [1, 0], text: '3m' })
|
||||
expect(movedDimension).toMatchObject({ start: [3, 0], text: '1m' })
|
||||
expect(danglingDimension).toMatchObject({
|
||||
start: [1, 0],
|
||||
text: 'UNLINKED · 3m',
|
||||
stroke: '#dc2626',
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves wall anchors against the selected assembly datum', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_assembly',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const anchor = {
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0] as [number, number, number],
|
||||
}
|
||||
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
|
||||
buildConstructionDimensionFloorplan(
|
||||
ConstructionDimensionNode.parse({
|
||||
anchors: [anchor, [3, 0, 0]],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy,
|
||||
}),
|
||||
context({ [wall.id]: wall }),
|
||||
)
|
||||
|
||||
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
|
||||
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
|
||||
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
})
|
||||
|
||||
test('uses millimetre notation in document output', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[3, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
expect(
|
||||
dimensionSegments(
|
||||
buildConstructionDimensionFloorplan(node, context({}, false, 'document')),
|
||||
)[0]?.text,
|
||||
).toBe('3000')
|
||||
})
|
||||
|
||||
test('renders a continuous string as adjacent associative segments', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(dimensions).toHaveLength(3)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '3m', '4m'])
|
||||
expect(dimensions[1]).toMatchObject({
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 1],
|
||||
dimensionEnd: [5, 1],
|
||||
})
|
||||
})
|
||||
|
||||
test('renders point-to-point strings as independent witness pairs', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'point-to-point',
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(dimensions).toHaveLength(2)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
|
||||
expect(dimensions[1]).toMatchObject({
|
||||
start: [5, 0],
|
||||
end: [9, 0],
|
||||
dimensionStart: [5, 1],
|
||||
dimensionEnd: [9, 1],
|
||||
})
|
||||
})
|
||||
|
||||
test('suppresses view-specific string segments without mutating physical anchors', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[5, 0, 0],
|
||||
[9, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
metadata: { suppressedDimensionSegmentIndexes: [1] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const dimensions = dimensionSegments(geometry)
|
||||
|
||||
expect(node.anchors).toHaveLength(4)
|
||||
expect(dimensions).toHaveLength(2)
|
||||
expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m'])
|
||||
})
|
||||
|
||||
test('passes persistent dimension standards to linear dimension strings', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy: 'finish-face',
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
metricNotation: 'millimeters',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context({}, false, 'document'))
|
||||
const string = geometry
|
||||
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
|
||||
: null
|
||||
|
||||
expect(string).toMatchObject({
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
extensionStartGap: 0.025,
|
||||
extensionOvershoot: 0.08,
|
||||
})
|
||||
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
|
||||
})
|
||||
|
||||
test('uses the live metric notation for manual dimensions in edit mode', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
|
||||
const geometry = buildConstructionDimensionFloorplan(
|
||||
node,
|
||||
context({}, false, 'edit', 'millimeters'),
|
||||
)
|
||||
expect(dimensionSegments(geometry)[0]?.text).toBe('2000')
|
||||
})
|
||||
|
||||
test('shows witness and baseline handles only while selected', () => {
|
||||
const node = ConstructionDimensionNode.parse({})
|
||||
const idle = buildConstructionDimensionFloorplan(node, context())
|
||||
const selected = buildConstructionDimensionFloorplan(node, context({}, true))
|
||||
|
||||
expect(idle && flatten(idle).filter((entry) => entry.kind === 'endpoint-handle')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
const handles = selected
|
||||
? flatten(selected).filter((entry) => entry.kind === 'endpoint-handle')
|
||||
: []
|
||||
expect(handles).toHaveLength(3)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex: 0 },
|
||||
}),
|
||||
)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex: 1 },
|
||||
}),
|
||||
)
|
||||
expect(handles).toContainEqual(
|
||||
expect.objectContaining({ affordance: 'move-construction-dimension-baseline' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps linked geometry read-only in a dependent drawing', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
metadata: { drawingCoordinationLocked: true },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context({}, true))
|
||||
|
||||
expect(
|
||||
geometry && flatten(geometry).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('renders radius notation with a leader and center mark', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [3, 1], direction: [1, 0] },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
text: 'R 2m',
|
||||
cx: 3,
|
||||
cy: 1,
|
||||
})
|
||||
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
test('updates an associative curved-wall radius when the host curve changes', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
|
||||
fallback: [2, 0, 1.5],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
|
||||
fallback: [2, 0, -1],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [2, -1.5], direction: [0, -1] },
|
||||
})
|
||||
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
|
||||
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
|
||||
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
|
||||
const originalLabel =
|
||||
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
|
||||
const reshapedLabel =
|
||||
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
|
||||
|
||||
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
|
||||
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
|
||||
})
|
||||
|
||||
test('renders diameter and repeated-feature notation', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'diameter',
|
||||
anchors: [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
featureCount: 6,
|
||||
prefix: 'TYP · ',
|
||||
suffix: ' CLR',
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(dimensionSegments(geometry)[0]).toMatchObject({
|
||||
text: 'TYP · 6 x Ø 2m CLR',
|
||||
start: [-1, 0],
|
||||
end: [1, 0],
|
||||
})
|
||||
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('renders a standalone center mark from a center and radius point', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'center-mark',
|
||||
anchors: [
|
||||
[3, 0, 4],
|
||||
[5, 0, 4],
|
||||
],
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4)
|
||||
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
|
||||
expect(dimensionSegments(geometry).length).toBe(0)
|
||||
})
|
||||
|
||||
test('renders chord and arc-length dimensions', () => {
|
||||
const chord = ConstructionDimensionNode.parse({
|
||||
mode: 'chord',
|
||||
anchors: [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
})
|
||||
const arc = ConstructionDimensionNode.parse({
|
||||
mode: 'arc-length',
|
||||
anchors: [
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
baseline: { origin: [2, 2], direction: [1, 0] },
|
||||
})
|
||||
const chordGeometry = buildConstructionDimensionFloorplan(chord, context())
|
||||
const arcGeometry = buildConstructionDimensionFloorplan(arc, context())
|
||||
const chordEntries = chordGeometry ? flatten(chordGeometry) : []
|
||||
const arcEntries = arcGeometry ? flatten(arcGeometry) : []
|
||||
|
||||
expect(dimensionSegments(chordGeometry)[0]).toMatchObject({
|
||||
text: 'CH 2m',
|
||||
})
|
||||
expect(arcEntries.some((entry) => entry.kind === 'path')).toBe(true)
|
||||
expect(arcEntries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
text: 'ARC 3.14m',
|
||||
})
|
||||
})
|
||||
|
||||
test('renders angular dimensions with an architectural angle label', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'angular',
|
||||
anchors: [
|
||||
[2, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 2],
|
||||
],
|
||||
baseline: { origin: [1.5, 0.5], direction: [1, 0] },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.some((entry) => entry.kind === 'path')).toBe(true)
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
cx: 1.5,
|
||||
cy: 0.5,
|
||||
text: '∠ 90°',
|
||||
screenUpright: true,
|
||||
})
|
||||
expect(entries).toContainEqual(expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }))
|
||||
})
|
||||
|
||||
test('renders signed coordinate labels for repeated circular features', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'coordinate',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 3],
|
||||
[-1, 0, 4],
|
||||
],
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const labels = geometry
|
||||
? flatten(geometry)
|
||||
.filter((entry) => entry.kind === 'dimension-label')
|
||||
.map((entry) => entry.text)
|
||||
: []
|
||||
|
||||
expect(labels).toEqual(['P1 · X 2m · Y 3m', 'P2 · X -1m · Y 4m'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,660 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
FloorplanStyle,
|
||||
GeometryContext,
|
||||
MeasurementAnchor,
|
||||
MeasurementPoint,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallCurveFrameAt,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
readFloorplanContext,
|
||||
readFloorplanMetricNotationOverride,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from '@pascal-app/editor'
|
||||
import { resolveMeasurementAnchor } from '../measurement/resolve'
|
||||
import {
|
||||
type ConstructionLengthFormatOptions,
|
||||
type ConstructionLengthProfile,
|
||||
formatConstructionLength,
|
||||
} from '../shared/construction-length'
|
||||
import { buildDimensionStringGeometry } from '../shared/dimension-string'
|
||||
import {
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
|
||||
const DEFAULT_STROKE = '#334155'
|
||||
const DANGLING_STROKE = '#dc2626'
|
||||
const EPSILON = 1e-6
|
||||
|
||||
export function buildConstructionDimensionFloorplan(
|
||||
node: ConstructionDimensionNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
if (node.visible === false) return null
|
||||
|
||||
const resolved = node.anchors.map((anchor) => resolveDimensionAnchor(node, anchor, ctx))
|
||||
const points = resolved.map((anchor) => anchor.point) as MeasurementPoint[]
|
||||
if (points.length < constructionDimensionRequiredAnchorCount(node.mode)) return null
|
||||
|
||||
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
|
||||
const baseStroke = selected
|
||||
? (ctx.viewState?.palette.selectedStroke ?? '#2563eb')
|
||||
: (ctx.viewState?.palette.measurementStroke ?? DEFAULT_STROKE)
|
||||
const dangling = resolved.some((anchor) => anchor.dangling)
|
||||
const stroke = dangling ? DANGLING_STROKE : baseStroke
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const floorplanContext = readFloorplanContext(ctx)
|
||||
const profile: ConstructionLengthProfile =
|
||||
floorplanContext.purpose === 'document' ? 'document' : 'editor'
|
||||
const metricNotationOverride = readFloorplanMetricNotationOverride(ctx)
|
||||
const displayNode =
|
||||
profile === 'editor' && metricNotationOverride
|
||||
? { ...node, metricNotation: metricNotationOverride }
|
||||
: node
|
||||
const editable =
|
||||
ctx.viewState?.selected === true &&
|
||||
!(
|
||||
typeof node.metadata === 'object' &&
|
||||
node.metadata !== null &&
|
||||
!Array.isArray(node.metadata) &&
|
||||
node.metadata.drawingCoordinationLocked === true
|
||||
)
|
||||
|
||||
switch (node.mode) {
|
||||
case 'linear':
|
||||
case 'chord':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'radius':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'diameter':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildDiameter(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'center-mark':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildCenterMarkOnly(displayNode, points, stroke, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'arc-length':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildArcLength(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'angular':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildAngular(displayNode, points, stroke, dangling, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
case 'coordinate':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildCoordinate(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDimensionAnchor(
|
||||
node: ConstructionDimensionNode,
|
||||
anchor: MeasurementAnchor,
|
||||
ctx: GeometryContext,
|
||||
): ReturnType<typeof resolveMeasurementAnchor> {
|
||||
const resolved = resolveMeasurementAnchor(anchor, (id) => ctx.resolve(id))
|
||||
if (Array.isArray(anchor) || resolved.dangling) return resolved
|
||||
if (!supportsWallDatum(anchor.reference.featureId)) return resolved
|
||||
|
||||
const referenced = ctx.resolve<WallNode>(anchor.reference.nodeId as AnyNodeId)
|
||||
if (referenced?.type !== 'wall') return resolved
|
||||
|
||||
const t = wallFeatureParameter(anchor.reference.featureId, anchor.reference.parameters?.t)
|
||||
const frame = getWallCurveFrameAt(referenced, t)
|
||||
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
|
||||
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
point: [
|
||||
frame.point.x + frame.normal.x * offset,
|
||||
resolved.point[1],
|
||||
frame.point.y + frame.normal.y * offset,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function supportsWallDatum(featureId: string): boolean {
|
||||
return (
|
||||
featureId === 'wall:start' ||
|
||||
featureId === 'wall:end' ||
|
||||
featureId === 'wall:centerline' ||
|
||||
featureId === 'wall:midpoint' ||
|
||||
featureId === 'wall:face:left' ||
|
||||
featureId === 'wall:face:right' ||
|
||||
featureId === 'wall:top-centerline'
|
||||
)
|
||||
}
|
||||
|
||||
function wallFeatureParameter(featureId: string, parameter: unknown): number {
|
||||
if (featureId === 'wall:start') return 0
|
||||
if (featureId === 'wall:end') return 1
|
||||
return typeof parameter === 'number' ? Math.max(0, Math.min(1, parameter)) : 0.5
|
||||
}
|
||||
|
||||
function wallDatumSide(
|
||||
node: ConstructionDimensionNode,
|
||||
featureId: string,
|
||||
resolved: ReturnType<typeof resolveMeasurementAnchor>,
|
||||
frame: ReturnType<typeof getWallCurveFrameAt>,
|
||||
): 1 | -1 {
|
||||
if (featureId === 'wall:face:left') return 1
|
||||
if (featureId === 'wall:face:right') return -1
|
||||
|
||||
const baselineProjection =
|
||||
(node.baseline.origin[0] - frame.point.x) * frame.normal.x +
|
||||
(node.baseline.origin[1] - frame.point.y) * frame.normal.y
|
||||
if (Math.abs(baselineProjection) > EPSILON) return baselineProjection > 0 ? 1 : -1
|
||||
|
||||
const resolvedNormal = resolved.normal
|
||||
if (resolvedNormal) {
|
||||
const normalProjection = resolvedNormal[0] * frame.normal.x + resolvedNormal[2] * frame.normal.y
|
||||
if (Math.abs(normalProjection) > EPSILON) return normalProjection > 0 ? 1 : -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
function wallDatumOffset(
|
||||
wall: WallNode,
|
||||
policy: ConstructionDimensionNode['datumPolicy'],
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
if (policy === 'centerline') return 0
|
||||
if (policy === 'wall-face') {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
return side > 0 ? faces.exterior : faces.interior
|
||||
}
|
||||
|
||||
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
|
||||
const candidates = resolveWallAssemblyDatumReferences(wall)
|
||||
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
|
||||
.map((reference) => reference.offset)
|
||||
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
|
||||
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
|
||||
}
|
||||
|
||||
function buildLinearOrChord(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry {
|
||||
const layout = resolveConstructionDimensionLayout(node, points)
|
||||
const children: FloorplanGeometry[] = []
|
||||
const suppressedSegments = suppressedDimensionSegmentIndexes(node)
|
||||
const visibleSegments = layout.segments.filter((_, index) => !suppressedSegments.has(index))
|
||||
const dimensionSegments = visibleSegments.map((segment) => {
|
||||
const baseText = `${node.mode === 'chord' ? 'CH ' : ''}${formatConstructionLength(segment.value, unit, profile, lengthFormatOptions(node))}`
|
||||
return {
|
||||
witnessStart: segment.witnessStart,
|
||||
witnessEnd: segment.witnessEnd,
|
||||
dimensionStart: segment.dimensionStart,
|
||||
dimensionEnd: segment.dimensionEnd,
|
||||
text: notation(node, baseText, dangling),
|
||||
}
|
||||
})
|
||||
children.push(
|
||||
...(dimensionSegments.length > 0
|
||||
? [
|
||||
buildDimensionStringGeometry({
|
||||
segments: dimensionSegments,
|
||||
offsetNormal: layout.normal,
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: node.extensionStartGap,
|
||||
extensionOvershoot: node.extensionOvershoot,
|
||||
terminator: node.terminator,
|
||||
textPosition: node.textPosition,
|
||||
stroke,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...visibleSegments.map((segment) => hitLine(segment.dimensionStart, segment.dimensionEnd)),
|
||||
)
|
||||
if (editable)
|
||||
children.push(...witnessHandles(layout.witnessPoints), baselineHandle(layout.midpoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildRadius(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('radius', points)
|
||||
if (!layout) return null
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const children: FloorplanGeometry[] = [
|
||||
styledPolyline([layout.center, layout.start, labelPoint], stroke),
|
||||
...openArrow(layout.start, layout.center, stroke),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
notation(
|
||||
node,
|
||||
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
angle(layout.start, labelPoint),
|
||||
),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildDiameter(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('diameter', points)
|
||||
if (!layout?.end) return null
|
||||
const direction = normalized(layout.start, layout.end)
|
||||
if (!direction) return null
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const children: FloorplanGeometry[] = [
|
||||
dimensionGeometry(
|
||||
node,
|
||||
layout.start,
|
||||
layout.end,
|
||||
layout.start,
|
||||
layout.end,
|
||||
normal,
|
||||
notation(
|
||||
node,
|
||||
`Ø ${formatConstructionLength(layout.radius * 2, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
stroke,
|
||||
),
|
||||
hitLine(layout.start, layout.end),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildCenterMarkOnly(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('center-mark', points)
|
||||
if (!layout) return null
|
||||
const children: FloorplanGeometry[] = centerMark(layout.center, layout.radius, stroke, true)
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildArcLength(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('arc-length', points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
|
||||
const projectedEnd = arcPoint(layout.center, layout.radius, layout.endAngle)
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = arcPoint(layout.center, layout.radius, midAngle)
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const children: FloorplanGeometry[] = [
|
||||
arcGeometry(layout.center, layout.radius, layout.startAngle, layout.sweep, stroke),
|
||||
styledLine(layout.center, layout.start, stroke, '0.08 0.08'),
|
||||
styledLine(layout.center, projectedEnd, stroke, '0.08 0.08'),
|
||||
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
|
||||
...openArrow(
|
||||
layout.start,
|
||||
arcPoint(layout.center, layout.radius, layout.startAngle + layout.sweep * 0.08),
|
||||
stroke,
|
||||
),
|
||||
...openArrow(
|
||||
projectedEnd,
|
||||
arcPoint(layout.center, layout.radius, layout.endAngle - layout.sweep * 0.08),
|
||||
stroke,
|
||||
),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
notation(
|
||||
node,
|
||||
`ARC ${formatConstructionLength(layout.arcLength, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
0,
|
||||
true,
|
||||
),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildAngular(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('angular', points)
|
||||
if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null
|
||||
const endRadius = distance(layout.center, layout.end)
|
||||
const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9)
|
||||
const requestedRadius = distance(layout.center, node.baseline.origin)
|
||||
const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius))
|
||||
const midAngle = layout.startAngle + layout.sweep / 2
|
||||
const arcMid = arcPoint(layout.center, arcRadius, midAngle)
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
const startRayEnd = arcPoint(
|
||||
layout.center,
|
||||
Math.max(layout.radius, arcRadius + 0.12),
|
||||
layout.startAngle,
|
||||
)
|
||||
const endRayEnd = arcPoint(layout.center, Math.max(endRadius, arcRadius + 0.12), layout.endAngle)
|
||||
const degrees = (Math.abs(layout.sweep) * 180) / Math.PI
|
||||
const children: FloorplanGeometry[] = [
|
||||
styledLine(layout.center, startRayEnd, stroke),
|
||||
styledLine(layout.center, endRayEnd, stroke),
|
||||
arcGeometry(layout.center, arcRadius, layout.startAngle, layout.sweep, stroke),
|
||||
styledLine(arcMid, labelPoint, stroke, '0.08 0.08'),
|
||||
labelGeometry(labelPoint, notation(node, `∠ ${formatDegrees(degrees)}`, dangling), 0, true),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, arcRadius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(node.baseline.origin))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function buildCoordinate(
|
||||
node: ConstructionDimensionNode,
|
||||
points: MeasurementPoint[],
|
||||
stroke: string,
|
||||
dangling: boolean,
|
||||
unit: 'metric' | 'imperial',
|
||||
profile: ConstructionLengthProfile,
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const datum: FloorplanPoint = [points[0]![0], points[0]![2]]
|
||||
const features = points.slice(1).map((point): FloorplanPoint => [point[0], point[2]])
|
||||
if (features.length === 0) return null
|
||||
const children: FloorplanGeometry[] = [...centerMark(datum, 0.4, stroke, true)]
|
||||
features.forEach((feature, index) => {
|
||||
const dx = feature[0] - datum[0]
|
||||
const dy = feature[1] - datum[1]
|
||||
const label = notation(
|
||||
node,
|
||||
`P${index + 1} · X ${formatConstructionLength(dx, unit, profile, lengthFormatOptions(node))} · Y ${formatConstructionLength(dy, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
false,
|
||||
)
|
||||
children.push(
|
||||
styledLine(datum, feature, stroke, '0.08 0.08'),
|
||||
labelGeometry(feature, label, 0, true, 10),
|
||||
...centerMark(feature, 0.3, stroke, true),
|
||||
)
|
||||
})
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
function suppressedDimensionSegmentIndexes(node: ConstructionDimensionNode): ReadonlySet<number> {
|
||||
const metadata = node.metadata
|
||||
if (!(typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata))) {
|
||||
return new Set()
|
||||
}
|
||||
const value = metadata.suppressedDimensionSegmentIndexes
|
||||
if (!Array.isArray(value)) return new Set()
|
||||
return new Set(
|
||||
value.filter(
|
||||
(entry): entry is number =>
|
||||
typeof entry === 'number' && Number.isInteger(entry) && entry >= 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function dimensionGroup(children: FloorplanGeometry[]): FloorplanGeometry {
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
function lengthFormatOptions(node: ConstructionDimensionNode): ConstructionLengthFormatOptions {
|
||||
return {
|
||||
imperialPrecision: node.imperialPrecision,
|
||||
metricNotation: node.metricNotation,
|
||||
}
|
||||
}
|
||||
|
||||
function notation(
|
||||
node: ConstructionDimensionNode,
|
||||
base: string,
|
||||
dangling: boolean,
|
||||
includeFeatureCount = true,
|
||||
): string {
|
||||
const repeated = includeFeatureCount && node.featureCount > 1 ? `${node.featureCount} x ` : ''
|
||||
const content = node.textOverride ?? `${repeated}${base}`
|
||||
const decorated = `${node.prefix}${content}${node.suffix}`
|
||||
return dangling ? `UNLINKED · ${decorated}` : decorated
|
||||
}
|
||||
|
||||
function dimensionGeometry(
|
||||
node: ConstructionDimensionNode,
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
dimensionStart: FloorplanPoint,
|
||||
dimensionEnd: FloorplanPoint,
|
||||
offsetNormal: FloorplanPoint,
|
||||
text: string,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start,
|
||||
end,
|
||||
dimensionStart,
|
||||
dimensionEnd,
|
||||
offsetNormal,
|
||||
offsetDistance: 0,
|
||||
extensionStartGap: node.extensionStartGap,
|
||||
extensionOvershoot: node.extensionOvershoot,
|
||||
terminator: node.terminator,
|
||||
textPosition: node.textPosition,
|
||||
text,
|
||||
stroke,
|
||||
}
|
||||
}
|
||||
|
||||
function arcGeometry(
|
||||
center: FloorplanPoint,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
sweep: number,
|
||||
stroke: string,
|
||||
): FloorplanGeometry {
|
||||
const start = arcPoint(center, radius, startAngle)
|
||||
const end = arcPoint(center, radius, startAngle + sweep)
|
||||
return {
|
||||
kind: 'path',
|
||||
d: `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep >= 0 ? 1 : 0} ${end[0]} ${end[1]}`,
|
||||
...lineStyle(stroke),
|
||||
}
|
||||
}
|
||||
|
||||
function styledLine(
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
stroke: string,
|
||||
strokeDasharray?: string,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: start[0],
|
||||
y1: start[1],
|
||||
x2: end[0],
|
||||
y2: end[1],
|
||||
...lineStyle(stroke, strokeDasharray),
|
||||
}
|
||||
}
|
||||
|
||||
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
|
||||
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
|
||||
}
|
||||
|
||||
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
|
||||
return {
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: 0.9,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'butt',
|
||||
strokeLinejoin: 'miter',
|
||||
}
|
||||
}
|
||||
|
||||
function labelGeometry(
|
||||
point: FloorplanPoint,
|
||||
text: string,
|
||||
labelAngle: number,
|
||||
screenUpright = false,
|
||||
offsetPx = 0,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'dimension-label',
|
||||
cx: point[0],
|
||||
cy: point[1],
|
||||
text,
|
||||
angle: labelAngle,
|
||||
screenUpright,
|
||||
offsetPx,
|
||||
appearance: 'outlined',
|
||||
}
|
||||
}
|
||||
|
||||
function centerMark(
|
||||
center: FloorplanPoint,
|
||||
radius: number,
|
||||
stroke: string,
|
||||
force = false,
|
||||
): FloorplanGeometry[] {
|
||||
if (!force && radius <= EPSILON) return []
|
||||
const half = Math.min(0.22, Math.max(0.1, radius * 0.18))
|
||||
const gap = Math.min(0.045, half * 0.3)
|
||||
return [
|
||||
styledLine([center[0] - half, center[1]], [center[0] - gap, center[1]], stroke),
|
||||
styledLine([center[0] + gap, center[1]], [center[0] + half, center[1]], stroke),
|
||||
styledLine([center[0], center[1] - half], [center[0], center[1] - gap], stroke),
|
||||
styledLine([center[0], center[1] + gap], [center[0], center[1] + half], stroke),
|
||||
]
|
||||
}
|
||||
|
||||
function openArrow(
|
||||
tip: FloorplanPoint,
|
||||
toward: FloorplanPoint,
|
||||
stroke: string,
|
||||
): FloorplanGeometry[] {
|
||||
const direction = normalized(tip, toward)
|
||||
if (!direction) return []
|
||||
const length = 0.15
|
||||
const halfWidth = 0.055
|
||||
const base: FloorplanPoint = [tip[0] + direction[0] * length, tip[1] + direction[1] * length]
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
return [
|
||||
styledLine(tip, [base[0] + normal[0] * halfWidth, base[1] + normal[1] * halfWidth], stroke),
|
||||
styledLine(tip, [base[0] - normal[0] * halfWidth, base[1] - normal[1] * halfWidth], stroke),
|
||||
]
|
||||
}
|
||||
|
||||
function baselineHandle(point: FloorplanPoint): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'endpoint-handle',
|
||||
point,
|
||||
state: 'idle',
|
||||
variant: 'curve',
|
||||
affordance: 'move-construction-dimension-baseline',
|
||||
payload: null,
|
||||
}
|
||||
}
|
||||
|
||||
function anchorHandles(points: readonly MeasurementPoint[]): FloorplanGeometry[] {
|
||||
return witnessHandles(points.map((point): FloorplanPoint => [point[0], point[2]]))
|
||||
}
|
||||
|
||||
function witnessHandles(points: readonly FloorplanPoint[]): FloorplanGeometry[] {
|
||||
return points.map((point, witnessIndex) => ({
|
||||
kind: 'endpoint-handle',
|
||||
point,
|
||||
state: 'idle',
|
||||
affordance: 'move-construction-dimension-witness',
|
||||
payload: { witnessIndex },
|
||||
}))
|
||||
}
|
||||
|
||||
function hitLine(start: FloorplanPoint, end: FloorplanPoint): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'hit-line',
|
||||
x1: start[0],
|
||||
y1: start[1],
|
||||
x2: end[0],
|
||||
y2: end[1],
|
||||
strokeWidthPx: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function arcPoint(center: FloorplanPoint, radius: number, pointAngle: number): FloorplanPoint {
|
||||
return [center[0] + Math.cos(pointAngle) * radius, center[1] + Math.sin(pointAngle) * radius]
|
||||
}
|
||||
|
||||
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const magnitude = Math.hypot(dx, dy)
|
||||
return magnitude <= EPSILON ? null : [dx / magnitude, dy / magnitude]
|
||||
}
|
||||
|
||||
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.atan2(second[1] - first[1], second[0] - first[0])
|
||||
}
|
||||
|
||||
function formatDegrees(value: number): string {
|
||||
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanPoint,
|
||||
MeasurementPoint,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ConstructionDimensionSegmentLayout = {
|
||||
dimensionStart: FloorplanPoint
|
||||
dimensionEnd: FloorplanPoint
|
||||
value: number
|
||||
witnessStart: FloorplanPoint
|
||||
witnessEnd: FloorplanPoint
|
||||
}
|
||||
|
||||
export type ConstructionDimensionLayout = {
|
||||
dimensionPoints: FloorplanPoint[]
|
||||
direction: FloorplanPoint
|
||||
midpoint: FloorplanPoint
|
||||
normal: FloorplanPoint
|
||||
segments: ConstructionDimensionSegmentLayout[]
|
||||
witnessPoints: FloorplanPoint[]
|
||||
}
|
||||
|
||||
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
export type CircularConstructionDimensionLayout = {
|
||||
center: FloorplanPoint
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint | null
|
||||
radius: number
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
sweep: number
|
||||
chordLength: number
|
||||
arcLength: number
|
||||
}
|
||||
|
||||
export function resolveCircularConstructionDimensionLayout(
|
||||
mode: ConstructionDimensionMode,
|
||||
anchors: readonly MeasurementPoint[],
|
||||
): CircularConstructionDimensionLayout | null {
|
||||
if (anchors.length < 2) return null
|
||||
const first = project(anchors[0]!)
|
||||
const second = project(anchors[1]!)
|
||||
|
||||
if (mode === 'diameter') {
|
||||
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
|
||||
const radius = distance(first, second) / 2
|
||||
if (radius <= 1e-9) return null
|
||||
return {
|
||||
center,
|
||||
start: first,
|
||||
end: second,
|
||||
radius,
|
||||
startAngle: Math.atan2(first[1] - center[1], first[0] - center[0]),
|
||||
endAngle: Math.atan2(second[1] - center[1], second[0] - center[0]),
|
||||
sweep: Math.PI,
|
||||
chordLength: radius * 2,
|
||||
arcLength: Math.PI * radius,
|
||||
}
|
||||
}
|
||||
|
||||
const usesMiddleCenter = mode === 'arc-length' || mode === 'angular'
|
||||
const center = usesMiddleCenter ? second : first
|
||||
const start = usesMiddleCenter ? first : second
|
||||
const radius = distance(center, start)
|
||||
if (radius <= 1e-9) return null
|
||||
const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0])
|
||||
const endAnchor = anchors[2]
|
||||
const end = endAnchor ? project(endAnchor) : null
|
||||
const endAngle = end ? Math.atan2(end[1] - center[1], end[0] - center[0]) : startAngle
|
||||
const sweep = end ? normalizedSignedSweep(startAngle, endAngle) : 0
|
||||
return {
|
||||
center,
|
||||
start,
|
||||
end,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
sweep,
|
||||
chordLength: end ? distance(start, end) : radius,
|
||||
arcLength: Math.abs(sweep) * radius,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedSignedSweep(startAngle: number, endAngle: number): number {
|
||||
let sweep = endAngle - startAngle
|
||||
while (sweep > Math.PI) sweep -= Math.PI * 2
|
||||
while (sweep <= -Math.PI) sweep += Math.PI * 2
|
||||
return sweep
|
||||
}
|
||||
|
||||
function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
export function resolveConstructionDimensionLayout(
|
||||
node: Pick<ConstructionDimensionNode, 'baseline' | 'chainMode'>,
|
||||
anchors: readonly MeasurementPoint[],
|
||||
): ConstructionDimensionLayout {
|
||||
if (anchors.length < 2) {
|
||||
throw new Error('Construction dimension layout requires at least two anchors')
|
||||
}
|
||||
const magnitude = Math.hypot(node.baseline.direction[0], node.baseline.direction[1])
|
||||
const direction: FloorplanPoint = [
|
||||
node.baseline.direction[0] / magnitude,
|
||||
node.baseline.direction[1] / magnitude,
|
||||
]
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const witnessPoints = anchors.map(project)
|
||||
const dimensionPoints = witnessPoints.map((point): FloorplanPoint => {
|
||||
const deltaX = point[0] - node.baseline.origin[0]
|
||||
const deltaY = point[1] - node.baseline.origin[1]
|
||||
const distance = deltaX * direction[0] + deltaY * direction[1]
|
||||
return [
|
||||
node.baseline.origin[0] + distance * direction[0],
|
||||
node.baseline.origin[1] + distance * direction[1],
|
||||
]
|
||||
})
|
||||
const segmentIndexes =
|
||||
node.chainMode === 'continuous'
|
||||
? witnessPoints.slice(0, -1).map((_, index) => [index, index + 1] as const)
|
||||
: Array.from(
|
||||
{ length: Math.floor(witnessPoints.length / 2) },
|
||||
(_, index) => [index * 2, index * 2 + 1] as const,
|
||||
)
|
||||
const segments = segmentIndexes.map(([startIndex, endIndex]) => {
|
||||
const witnessStart = witnessPoints[startIndex]!
|
||||
const witnessEnd = witnessPoints[endIndex]!
|
||||
const dimensionStart = dimensionPoints[startIndex]!
|
||||
const dimensionEnd = dimensionPoints[endIndex]!
|
||||
return {
|
||||
dimensionStart,
|
||||
dimensionEnd,
|
||||
value: Math.abs(
|
||||
(witnessEnd[0] - witnessStart[0]) * direction[0] +
|
||||
(witnessEnd[1] - witnessStart[1]) * direction[1],
|
||||
),
|
||||
witnessStart,
|
||||
witnessEnd,
|
||||
}
|
||||
})
|
||||
const first = dimensionPoints[0]!
|
||||
const last = dimensionPoints.at(-1)!
|
||||
return {
|
||||
dimensionPoints,
|
||||
direction,
|
||||
midpoint: [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2],
|
||||
normal,
|
||||
segments,
|
||||
witnessPoints,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { constructionDimensionDefinition } from './definition'
|
||||
export { buildConstructionDimensionFloorplan } from './floorplan'
|
||||
export {
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionDatumPolicy,
|
||||
type ConstructionDimensionDrawingPresentation,
|
||||
type ConstructionDimensionImperialPrecision,
|
||||
type ConstructionDimensionMetricNotation,
|
||||
type ConstructionDimensionNode,
|
||||
type ConstructionDimensionTerminator,
|
||||
type ConstructionDimensionTextPosition,
|
||||
type ConstructionDrawingType,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingSuppressedSegments,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useDrawingView,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
const MODE_LABELS: Record<ConstructionDimensionNode['mode'], string> = {
|
||||
linear: 'Linear',
|
||||
radius: 'Radius',
|
||||
diameter: 'Diameter',
|
||||
'center-mark': 'Center mark',
|
||||
chord: 'Chord',
|
||||
'arc-length': 'Arc length',
|
||||
angular: 'Angular',
|
||||
coordinate: 'Coordinate',
|
||||
}
|
||||
|
||||
const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [
|
||||
{ label: 'Centerline', value: 'centerline' },
|
||||
{ label: 'Wall face', value: 'wall-face' },
|
||||
{ label: 'Structural face', value: 'structural-face' },
|
||||
{ label: 'Finish face', value: 'finish-face' },
|
||||
]
|
||||
|
||||
const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [
|
||||
{ label: 'Architectural tick', value: 'architectural-tick' },
|
||||
{ label: 'Filled arrow', value: 'filled-arrow' },
|
||||
{ label: 'Open arrow', value: 'open-arrow' },
|
||||
{ label: 'Dot', value: 'dot' },
|
||||
]
|
||||
|
||||
const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [
|
||||
{ label: 'Above line', value: 'above' },
|
||||
{ label: 'Centered on line', value: 'centered' },
|
||||
]
|
||||
|
||||
const IMPERIAL_PRECISION_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConstructionDimensionImperialPrecision
|
||||
}> = [
|
||||
{ label: 'Nearest inch', value: '1' },
|
||||
{ label: 'Nearest 1/2 inch', value: '1/2' },
|
||||
{ label: 'Nearest 1/4 inch', value: '1/4' },
|
||||
{ label: 'Nearest 1/8 inch', value: '1/8' },
|
||||
{ label: 'Nearest 1/16 inch', value: '1/16' },
|
||||
]
|
||||
|
||||
const METRIC_NOTATION_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConstructionDimensionMetricNotation
|
||||
}> = [
|
||||
{ label: 'Meters', value: 'meters' },
|
||||
{ label: 'Millimeters', value: 'millimeters' },
|
||||
]
|
||||
|
||||
export default function ConstructionDimensionPanel() {
|
||||
const selectedId = useViewer((state) => state.selection.selectedIds[0])
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const dimension = useScene((state) => {
|
||||
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined
|
||||
return node?.type === 'construction-dimension' ? node : null
|
||||
})
|
||||
const foundationControllers = useScene(
|
||||
useShallow((state) =>
|
||||
Object.values(state.nodes).filter(
|
||||
(candidate): candidate is ConstructionDimensionNode =>
|
||||
candidate.type === 'construction-dimension' &&
|
||||
candidate.id !== dimension?.id &&
|
||||
candidate.drawingType === 'foundation-plan',
|
||||
),
|
||||
),
|
||||
)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const deleteNode = useScene((state) => state.deleteNode)
|
||||
const activeDrawingType = useDrawingView((state) => state.drawingType)
|
||||
|
||||
if (!(dimension && selectedId)) return null
|
||||
const update = (patch: Partial<ConstructionDimensionNode>) => updateNode(dimension.id, patch)
|
||||
const supportsCenterMark = ['radius', 'diameter', 'arc-length', 'angular'].includes(
|
||||
dimension.mode,
|
||||
)
|
||||
const activeDrawingLabel =
|
||||
DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan'
|
||||
const activePresentation = resolveConstructionDimensionDrawingPresentation(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
)
|
||||
const activeDrawingOverride = resolveConstructionDimensionDrawingOverride(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
)
|
||||
const suppressedSegmentsText = formatSuppressedSegments(
|
||||
activeDrawingOverride?.suppressedSegmentIndexes ?? [],
|
||||
)
|
||||
const updateDrawingPresentation = (
|
||||
drawingType: ConstructionDrawingType,
|
||||
presentation: ConstructionDimensionDrawingPresentation,
|
||||
) => {
|
||||
const drawingOverrides = setConstructionDimensionDrawingPresentation(
|
||||
dimension,
|
||||
drawingType,
|
||||
presentation,
|
||||
)
|
||||
update({
|
||||
drawingOverrides,
|
||||
...(presentation === 'controlled' && !dimension.controllingDimensionId
|
||||
? { controllingDimensionId: foundationControllers[0]?.id ?? null }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
const updateSuppressedSegments = (value: string) => {
|
||||
update({
|
||||
drawingOverrides: setConstructionDimensionDrawingSuppressedSegments(
|
||||
dimension,
|
||||
activeDrawingType,
|
||||
parseSuppressedSegments(value),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/blueprint.webp"
|
||||
onClose={() => setSelection({ selectedIds: [] })}
|
||||
title="Construction Dimension"
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Dimension">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Mode</span>
|
||||
<span className="font-medium text-foreground">{MODE_LABELS[dimension.mode]}</span>
|
||||
</div>
|
||||
<SliderControl
|
||||
label="Feature count"
|
||||
max={999}
|
||||
min={1}
|
||||
onChange={(featureCount) => update({ featureCount })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={dimension.featureCount}
|
||||
/>
|
||||
{supportsCenterMark ? (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Center mark</span>
|
||||
<input
|
||||
checked={dimension.showCenterMark}
|
||||
onChange={(event) => update({ showCenterMark: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Drawing coordination">
|
||||
<SelectField
|
||||
label="Primary drawing"
|
||||
onChange={(drawingType) =>
|
||||
update({ drawingType: drawingType as ConstructionDrawingType })
|
||||
}
|
||||
options={DRAWING_TYPE_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={dimension.drawingType}
|
||||
/>
|
||||
<SelectField
|
||||
label={`${activeDrawingLabel} presentation`}
|
||||
onChange={(presentation) =>
|
||||
updateDrawingPresentation(
|
||||
activeDrawingType,
|
||||
presentation as ConstructionDimensionDrawingPresentation,
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ label: 'Shown', value: 'shown' },
|
||||
{ label: 'Omitted', value: 'omit' },
|
||||
...(activeDrawingType === 'floor-plan'
|
||||
? [{ label: 'Controlled by foundation', value: 'controlled' }]
|
||||
: []),
|
||||
]}
|
||||
value={activePresentation}
|
||||
/>
|
||||
{activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? (
|
||||
<SelectField
|
||||
disabled={foundationControllers.length === 0}
|
||||
label="Foundation controller"
|
||||
onChange={(controllingDimensionId) =>
|
||||
update({
|
||||
controllingDimensionId: controllingDimensionId as NonNullable<
|
||||
ConstructionDimensionNode['controllingDimensionId']
|
||||
>,
|
||||
})
|
||||
}
|
||||
options={foundationControllers.map((controller) => ({
|
||||
label: controller.name || 'Foundation dimension',
|
||||
value: controller.id,
|
||||
}))}
|
||||
placeholder="No foundation dimensions"
|
||||
value={dimension.controllingDimensionId ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Linked dimensions reuse the controller's associative anchors and update with it.
|
||||
</p>
|
||||
<TextField
|
||||
label={`${activeDrawingLabel} suppressed segments`}
|
||||
onCommit={updateSuppressedSegments}
|
||||
placeholder="e.g. 2, 4"
|
||||
value={suppressedSegmentsText}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Segment numbers are one-based and apply only in this drawing view.
|
||||
</p>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Notation">
|
||||
<TextField
|
||||
label="Prefix"
|
||||
onCommit={(prefix) => update({ prefix })}
|
||||
value={dimension.prefix}
|
||||
/>
|
||||
<TextField
|
||||
label="Suffix"
|
||||
onCommit={(suffix) => update({ suffix })}
|
||||
value={dimension.suffix}
|
||||
/>
|
||||
<TextField
|
||||
label="Text override"
|
||||
onCommit={(textOverride) => update({ textOverride: textOverride || null })}
|
||||
placeholder="Use measured value"
|
||||
value={dimension.textOverride ?? ''}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Standards">
|
||||
<SelectField
|
||||
label="Datum policy"
|
||||
onChange={(datumPolicy) =>
|
||||
update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy })
|
||||
}
|
||||
options={DATUM_POLICY_OPTIONS}
|
||||
value={dimension.datumPolicy}
|
||||
/>
|
||||
<SelectField
|
||||
label="Terminator"
|
||||
onChange={(terminator) =>
|
||||
update({ terminator: terminator as ConstructionDimensionTerminator })
|
||||
}
|
||||
options={TERMINATOR_OPTIONS}
|
||||
value={dimension.terminator}
|
||||
/>
|
||||
<SelectField
|
||||
label="Text position"
|
||||
onChange={(textPosition) =>
|
||||
update({ textPosition: textPosition as ConstructionDimensionTextPosition })
|
||||
}
|
||||
options={TEXT_POSITION_OPTIONS}
|
||||
value={dimension.textPosition}
|
||||
/>
|
||||
<SelectField
|
||||
label="Imperial precision"
|
||||
onChange={(imperialPrecision) =>
|
||||
update({
|
||||
imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision,
|
||||
})
|
||||
}
|
||||
options={IMPERIAL_PRECISION_OPTIONS}
|
||||
value={dimension.imperialPrecision}
|
||||
/>
|
||||
<SelectField
|
||||
label="Metric notation"
|
||||
onChange={(metricNotation) =>
|
||||
update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation })
|
||||
}
|
||||
options={METRIC_NOTATION_OPTIONS}
|
||||
value={dimension.metricNotation}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Extension gap"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(extensionStartGap) => update({ extensionStartGap })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
value={dimension.extensionStartGap}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Extension overshoot"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(extensionOvershoot) => update({ extensionOvershoot })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
value={dimension.extensionOvershoot}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:structure-delete')
|
||||
deleteNode(dimension.id)
|
||||
setSelection({ selectedIds: [] })
|
||||
}}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function parseSuppressedSegments(value: string): number[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[,\s]+/)
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((index) => Number.isInteger(index) && index > 0)
|
||||
.map((index) => index - 1),
|
||||
),
|
||||
].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
function formatSuppressedSegments(indexes: readonly number[]): string {
|
||||
return indexes.map((index) => index + 1).join(', ')
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{placeholder && options.length === 0 ? <option value="">{placeholder}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onCommit,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onCommit: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-border/70 bg-background px-2 py-1.5 text-foreground"
|
||||
defaultValue={value}
|
||||
key={value}
|
||||
onBlur={(event) => onCommit(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ConstructionDimensionNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const constructionDimensionParametrics: ParametricDescriptor<ConstructionDimensionNode> = {
|
||||
groups: [],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user