Merge remote-tracking branch 'origin/main' into feat/realtime-collaboration-sfx

# Conflicts:
#	packages/core/src/schema/nodes/level.test.ts
#	packages/core/src/schema/nodes/level.ts
#	packages/core/src/store/use-scene.ts
This commit is contained in:
Aymeric Rabot
2026-07-23 17:43:41 +02:00
367 changed files with 37993 additions and 3768 deletions
-1
View File
@@ -183,7 +183,6 @@ Systems are React components that run in the render loop (`useFrame`) to update
| System | Responsibility | | System | Responsibility |
|--------|---------------| |--------|---------------|
| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |
| `SlabSystem` | Generates floor geometry from polygons |
| `CeilingSystem` | Generates ceiling geometry | | `CeilingSystem` | Generates ceiling geometry |
| `RoofSystem` | Generates roof geometry | | `RoofSystem` | Generates roof geometry |
| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
+2
View File
@@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { BuildTab } from '@/components/build-tab' import { BuildTab } from '@/components/build-tab'
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
import { import {
CommunityViewerToolbarLeft, CommunityViewerToolbarLeft,
CommunityViewerToolbarRight, CommunityViewerToolbarRight,
@@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor'
export default function Home() { export default function Home() {
return ( return (
<div className="relative h-screen w-screen"> <div className="relative h-screen w-screen">
<FloorplanConstructionPreflight />
{PROJECT_ID === 'local-editor' && ( {PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2"> <div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur"> <div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
+48 -26
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import { nodeRegistry } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import {
getFloorplanNodeExtension,
MaterialPaintPanel,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes' import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -13,24 +18,6 @@ import {
} from '@/components/toolbar-tooltip' } from '@/components/toolbar-tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/**
* Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
* editor's `StructureTool` ids.
*/
type BuildToolKind =
| 'wall'
| 'fence'
| 'slab'
| 'ceiling'
| 'roof'
| 'stair'
| 'elevator'
| 'door'
| 'window'
| 'column'
| 'shelf'
| 'spawn'
/** /**
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
* group tile — its own sub-grid, like Roof's "Features". * group tile — its own sub-grid, like Roof's "Features".
@@ -53,7 +40,8 @@ type BuildType = {
/** Raster asset tile (legacy Build sidebar artwork). */ /** Raster asset tile (legacy Build sidebar artwork). */
iconSrc: string iconSrc: string
/** Present for structure-tool types (absent for paint mode and the MEP group). */ /** Present for structure-tool types (absent for paint mode and the MEP group). */
kind?: BuildToolKind kind?: string
paletteOrder?: number
/** Non-placement special mode. */ /** Non-placement special mode. */
mode?: 'material-paint' mode?: 'material-paint'
} }
@@ -67,7 +55,7 @@ type MepItem = {
} }
// Same icons + ordering as the community Build sidebar, minus presets. // Same icons + ordering as the community Build sidebar, minus presets.
const BUILD_TYPES: BuildType[] = [ const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' },
@@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
] ]
function collectBuildTypes(): BuildType[] {
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
...type,
paletteOrder:
nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10,
}))
for (const [kind, definition] of nodeRegistry.entries()) {
const presentation = definition.presentation
const extension = getFloorplanNodeExtension(definition)
if (
baseKinds.has(kind) ||
!extension?.tool ||
!presentation ||
presentation.hidden ||
presentation.paletteSection !== 'structure'
) {
continue
}
tools.push({
id: kind,
kind,
label: presentation.label,
iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp',
paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER,
})
}
tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0))
return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)]
}
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
// tools had in the community Build sidebar. // tools had in the community Build sidebar.
const MEP_ITEMS: MepItem[] = [ const MEP_ITEMS: MepItem[] = [
@@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [
* Activate a raw structure draw/cursor tool. Mirrors the editor's own * Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
*/ */
function activateBuildTool(kind: BuildToolKind | MepToolKind): void { function activateBuildTool(kind: string): void {
const ed = useEditor.getState() const ed = useEditor.getState()
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
if (preferredView) ed.setViewMode(preferredView)
ed.setPhase('structure') ed.setPhase('structure')
ed.setStructureLayer('elements') ed.setStructureLayer('elements')
ed.setCatalogCategory(null) ed.setCatalogCategory(null)
@@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void {
ed.setStructureLayer('elements') ed.setStructureLayer('elements')
ed.setCatalogCategory(null) ed.setCatalogCategory(null)
ed.setMode('build') ed.setMode('build')
ed.setTool(kind as Parameters<typeof ed.setTool>[0]) ed.setTool(kind)
} }
/** /**
@@ -165,6 +186,7 @@ export function BuildTab() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow) const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
const buildTypes = useMemo(collectBuildTypes, [])
// The fitting / follow tools are armed from a segment's panel, not a grid // The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays // tile — keep the segment tile lit so the panel (and the way back) stays
@@ -245,9 +267,9 @@ export function BuildTab() {
didInitRef.current = true didInitRef.current = true
const ed = useEditor.getState() const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind) const firstType = buildTypes.find((t) => t.kind)
if (firstType) handleTypeClick(firstType) if (firstType) handleTypeClick(firstType)
}, [handleTypeClick]) }, [buildTypes, handleTypeClick])
return ( return (
<div className="flex h-full flex-col gap-3 p-3"> <div className="flex h-full flex-col gap-3 p-3">
@@ -256,7 +278,7 @@ export function BuildTab() {
className="grid gap-1.5" className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
> >
{BUILD_TYPES.map((type) => { {buildTypes.map((type) => {
const active = isTypeActive(type) const active = isTypeActive(type)
return ( return (
<Tooltip key={type.id}> <Tooltip key={type.id}>
@@ -0,0 +1,70 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useFloorplanPreflight } from '@pascal-app/editor'
import {
buildClearanceAdvisories,
buildConstructionModuleAdvisories,
buildDimensionCompletenessAudit,
} from '@pascal-app/nodes'
import { useEffect, useMemo, useState } from 'react'
export function FloorplanConstructionPreflight() {
const nodes = useDebouncedSceneNodes()
const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled)
const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled)
const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues)
const issues = useMemo(() => {
const completeness = buildDimensionCompletenessAudit(nodes, {
includeAutomaticDimensions: true,
}).map((issue) => ({
id: issue.id,
kind: 'dimension-completeness' as const,
severity: issue.severity,
message: issue.message,
}))
const clearance = clearanceChecksEnabled
? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
id: issue.id,
kind: 'clearance-advisory' as const,
severity: issue.severity,
message: issue.message,
}))
: []
const modules = moduleChecksEnabled
? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
id: issue.id,
kind: 'module-advisory' as const,
severity: issue.severity,
message: issue.message,
}))
: []
return [...completeness, ...clearance, ...modules]
}, [clearanceChecksEnabled, moduleChecksEnabled, nodes])
useEffect(() => {
setAuditIssues(issues)
return () => setAuditIssues([])
}, [issues, setAuditIssues])
return null
}
function useDebouncedSceneNodes() {
const [nodes, setNodes] = useState(() => useScene.getState().nodes)
useEffect(() => {
let pending: ReturnType<typeof setTimeout> | undefined
const unsubscribe = useScene.subscribe((state) => {
if (pending) clearTimeout(pending)
pending = setTimeout(() => setNodes(state.nodes), 100)
})
return () => {
if (pending) clearTimeout(pending)
unsubscribe()
}
}, [])
return nodes
}
+180 -6
View File
@@ -2,6 +2,7 @@
import { Icon as IconifyIcon } from '@iconify/react' import { Icon as IconifyIcon } from '@iconify/react'
import { import {
DRAWING_TYPE_OPTIONS,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
@@ -10,7 +11,9 @@ import {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
useDrawingView,
useEditor, useEditor,
useFloorplanAnnotationVisibility,
useSidebarStore, useSidebarStore,
type ViewMode, type ViewMode,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -32,12 +35,16 @@ import {
EyeOff, EyeOff,
Footprints, Footprints,
Grid2X2, Grid2X2,
Layers3,
Magnet, Magnet,
PenLine, PenLine,
Ruler, Ruler,
ScanLine,
SlidersHorizontal, SlidersHorizontal,
Sparkles, Sparkles,
SquareUserRound,
SwatchBook, SwatchBook,
Tag,
} from 'lucide-react' } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { type ReactNode, useCallback } from 'react' import { type ReactNode, useCallback } from 'react'
@@ -133,6 +140,22 @@ const SHADING_OPTIONS = [
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
] as const ] as const
const FLOORPLAN_ANNOTATION_OPTIONS = [
{ id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler },
{ id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler },
{ id: 'measurements', name: 'Measurements', icon: ScanLine },
{ id: 'openingMarks', name: 'Door/window marks', icon: Tag },
{ id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 },
{ id: 'roomLabels', name: 'Room labels', icon: SquareUserRound },
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
] as const
const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
{ id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
{ id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' },
] as const
function ViewModeControl() { function ViewModeControl() {
const viewMode = useEditor((state) => state.viewMode) const viewMode = useEditor((state) => state.viewMode)
const setViewMode = useEditor((state) => state.setViewMode) const setViewMode = useEditor((state) => state.setViewMode)
@@ -165,6 +188,49 @@ function ViewModeControl() {
) )
} }
function DrawingTypeControl() {
const viewMode = useEditor((state) => state.viewMode)
const drawingType = useDrawingView((state) => state.drawingType)
const setDrawingType = useDrawingView((state) => state.setDrawingType)
if (viewMode === '3d') return null
const active =
DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0]
return (
<div className={TOOLBAR_CONTAINER}>
<DropdownMenu>
<ToolbarTooltip label="Select coordinated drawing">
<DropdownMenuTrigger asChild>
<button
aria-label={`Drawing type: ${active.label}`}
className="flex items-center gap-1.5 px-2.5 font-medium text-foreground/90 text-xs transition-colors hover:bg-white/8"
type="button"
>
<Layers3 className="h-3.5 w-3.5" />
<span>{active.label}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent
align="start"
className="w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
side="bottom"
sideOffset={8}
>
{DRAWING_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setDrawingType(option.id)}>
<Layers3 className="h-4 w-4" />
<span>{option.label}</span>
{drawingType === option.id ? <Check className="ml-auto h-4 w-4" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
function CollapseSidebarButton() { function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((state) => state.isCollapsed) const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
@@ -278,12 +344,15 @@ const EDGE_OPTIONS = [
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
function DisplayMenu() { function DisplayMenu() {
const viewMode = useEditor((state) => state.viewMode)
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid) const setShowGrid = useViewer((state) => state.setShowGrid)
const showMeasurements = useViewer((state) => state.showMeasurements) const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements) const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit) const setUnit = useViewer((state) => state.setUnit)
const metricNotation = useViewer((state) => state.metricNotation)
const setMetricNotation = useViewer((state) => state.setMetricNotation)
const cameraMode = useViewer((state) => state.cameraMode) const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode) const setCameraMode = useViewer((state) => state.setCameraMode)
const shading = useViewer((state) => state.shading) const shading = useViewer((state) => state.shading)
@@ -296,6 +365,14 @@ function DisplayMenu() {
const setShadows = useViewer((state) => state.setShadows) const setShadows = useViewer((state) => state.setShadows)
const magneticSnap = useEditor((state) => state.magneticSnap) const magneticSnap = useEditor((state) => state.magneticSnap)
const setMagneticSnap = useEditor((state) => state.setMagneticSnap) const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory)
const wallDimensionReference = useFloorplanAnnotationVisibility(
(state) => state.wallDimensionReference,
)
const setWallDimensionReference = useFloorplanAnnotationVisibility(
(state) => state.setWallDimensionReference,
)
const activeShading = const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
@@ -337,17 +414,82 @@ function DisplayMenu() {
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" /> <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
{viewMode !== '2d' ? (
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))} onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
> >
<Ruler className="h-4 w-4" /> <Ruler className="h-4 w-4" />
<span>Measurements</span> <span>{viewMode === 'split' ? '3D measurements' : 'Measurements'}</span>
{showMeasurements ? ( {showMeasurements ? (
<Eye className="ml-auto h-4 w-4 text-foreground" /> <Eye className="ml-auto h-4 w-4 text-foreground" />
) : ( ) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" /> <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
) : null}
{viewMode !== '3d' ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Layers3 className="h-4 w-4" />
<span>Floor plan annotations</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
const OptionIcon = option.icon
const visible = annotationVisibility[option.id]
return (
<DropdownMenuItem
key={option.id}
onSelect={(e) =>
keepOpen(e, () => setAnnotationCategory(option.id, !visible))
}
>
<OptionIcon className="h-4 w-4" />
<span>{option.name}</span>
{visible ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Ruler className="h-4 w-4" />
<span>Wall dimensions</span>
<span className="ml-auto text-muted-foreground text-xs">
{
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
(option) => option.id === wallDimensionReference,
)?.name
}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
onSelect={(event) =>
keepOpen(event, () => setWallDimensionReference(option.id))
}
>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{wallDimensionReference === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
) : null}
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}> <DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" /> <Magnet className="h-4 w-4" />
<span>Magnetic snap</span> <span>Magnetic snap</span>
@@ -377,17 +519,48 @@ function DisplayMenu() {
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuSub>
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} <DropdownMenuSubTrigger>
>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]"> <span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
{unit === 'metric' ? 'm' : 'ft'} {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'}
</span> </span>
<span>Units</span> <span>Units</span>
<span className="ml-auto text-muted-foreground text-xs"> <span className="ml-auto text-muted-foreground text-xs">
{unit === 'metric' ? 'Metric' : 'Imperial'} {unit === 'imperial'
? 'Feet & inches'
: metricNotation === 'millimeters'
? 'Millimeters'
: 'Meters'}
</span> </span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
<DropdownMenuItem onSelect={() => setMetricNotation('meters')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
m
</span>
<span>Meters</span>
{unit === 'metric' && metricNotation === 'meters' ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => setMetricNotation('millimeters')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
mm
</span>
<span>Millimeters</span>
{unit === 'metric' && metricNotation === 'millimeters' ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setUnit('imperial')}>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
ft
</span>
<span>Feet & inches</span>
{unit === 'imperial' ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() {
<> <>
<CollapseSidebarButton /> <CollapseSidebarButton />
<ViewModeControl /> <ViewModeControl />
<DrawingTypeControl />
</> </>
) )
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+63 -69
View File
@@ -98,7 +98,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -124,7 +124,7 @@
}, },
"packages/editor": { "packages/editor": {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -145,35 +145,37 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0", "@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0", "pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8", "three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.1", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.1", "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -201,7 +203,7 @@
}, },
"packages/ifc-converter": { "packages/ifc-converter": {
"name": "@pascal-app/ifc-converter", "name": "@pascal-app/ifc-converter",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@pascal-app/core": "*", "@pascal-app/core": "*",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
@@ -215,7 +217,7 @@
}, },
"packages/mcp": { "packages/mcp": {
"name": "@pascal-app/mcp", "name": "@pascal-app/mcp",
"version": "0.3.1", "version": "0.3.2",
"bin": { "bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js", "pascal-mcp": "./dist/bin/pascal-mcp.js",
}, },
@@ -225,22 +227,22 @@
"zod": "^4.3.5", "zod": "^4.3.5",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/node": "^22.19.20", "@types/node": "^22.19.20",
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
}, },
}, },
"packages/nodes": { "packages/nodes": {
"name": "@pascal-app/nodes", "name": "@pascal-app/nodes",
"version": "0.1.0", "version": "0.1.1",
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.9.0", "@pascal-app/core": "^0.9.2",
"@pascal-app/editor": "^0.9.0", "@pascal-app/editor": "^0.9.2",
"@pascal-app/viewer": "^0.9.0", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -249,9 +251,9 @@
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.0", "@pascal-app/core": "^0.9.2",
"@pascal-app/editor": "^0.9.0", "@pascal-app/editor": "^0.9.2",
"@pascal-app/viewer": "^0.9.0", "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"lucide-react": "^1", "lucide-react": "^1",
@@ -283,7 +285,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.9.1", "version": "0.9.2",
"dependencies": { "dependencies": {
"three-bvh-csg": "^0.0.18", "three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",
@@ -297,7 +299,7 @@
"typescript": "6.0.3", "typescript": "6.0.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.9.1", "@pascal-app/core": "^0.9.2",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
@@ -538,6 +540,10 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -852,6 +858,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
@@ -868,9 +876,7 @@
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
@@ -882,8 +888,6 @@
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -978,8 +982,6 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="],
@@ -988,12 +990,20 @@
"bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], "bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
"blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="],
"blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -1014,8 +1024,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
@@ -1030,6 +1038,8 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
@@ -1056,18 +1066,12 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -1100,9 +1104,9 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
@@ -1202,8 +1206,6 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -1232,7 +1234,7 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="], "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
@@ -1302,8 +1304,6 @@
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1330,8 +1330,6 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -1404,6 +1402,8 @@
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
"js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
@@ -1422,8 +1422,6 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -1460,6 +1458,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -1580,7 +1580,7 @@
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1598,7 +1598,7 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -1606,6 +1606,8 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
@@ -1632,8 +1634,6 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@@ -1660,8 +1660,6 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1674,9 +1672,9 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -1726,10 +1724,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
@@ -1768,12 +1762,6 @@
"suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
"svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="],
"svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
@@ -1782,8 +1770,6 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
@@ -1792,6 +1778,8 @@
"three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -1844,6 +1832,10 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
@@ -1860,8 +1852,6 @@
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -1962,6 +1952,8 @@
"agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1978,6 +1970,8 @@
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+64
View File
@@ -0,0 +1,64 @@
**Comparison Target**
- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`.
- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`.
- Viewport: 1280 × 720.
- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator.
- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions.
**Full-view Comparison Evidence**
- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text.
- The larger left perimeter door has no room-side width dimension in the source state.
- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading.
**Focused-region Comparison Evidence**
- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall.
- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`.
- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door.
- A same-state rendered focused comparison is blocked by the local loading state.
**Findings**
- [P0] Browser-rendered implementation evidence unavailable.
Location: local editor preview.
Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected.
Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted.
Fix: restore a working local scene preview and recapture the reported room in 2D.
**Required Fidelity Surfaces**
- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection.
- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection.
- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection.
- Image quality and asset fidelity: no image assets are involved.
- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included.
**Comparison History**
- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls.
- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls.
- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked.
**Implementation Evidence**
- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed.
- Nodes package build: passed.
- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`.
- Biome check: passed.
- Git diff whitespace check: passed.
- Browser console warnings/errors: none reported.
**Implementation Checklist**
- Restore the local editor preview.
- Reopen the reported room in 2D.
- Confirm each internal string has a visible parallel baseline, witness lines, and ticks.
- Confirm the large left-side door displays its room-side width dimension.
**Follow-up Polish**
- Reassess internal line contrast only after the corrected geometry can be seen in the target scene.
final result: blocked
+9
View File
@@ -9,10 +9,12 @@ import type {
CeilingNode, CeilingNode,
ChimneyNode, ChimneyNode,
ColumnNode, ColumnNode,
ConstructionDimensionNode,
CupolaNode, CupolaNode,
DoorNode, DoorNode,
DormerNode, DormerNode,
DownspoutNode, DownspoutNode,
DrawingSheetNode,
DuctFittingNode, DuctFittingNode,
DuctSegmentNode, DuctSegmentNode,
DuctTerminalNode, DuctTerminalNode,
@@ -42,6 +44,7 @@ import type {
SpawnNode, SpawnNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
StructuralGridNode,
TurbineVentNode, TurbineVentNode,
WallNode, WallNode,
WindowNode, WindowNode,
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode> export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode> export type CeilingEvent = NodeEvent<CeilingNode>
export type ColumnEvent = NodeEvent<ColumnNode> export type ColumnEvent = NodeEvent<ColumnNode>
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
export type RoofEvent = NodeEvent<RoofNode> export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode> export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode> export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode> export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode> export type ElevatorEvent = NodeEvent<ElevatorNode>
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
export type SkylightEvent = NodeEvent<SkylightNode> export type SkylightEvent = NodeEvent<SkylightNode>
export type DormerEvent = NodeEvent<DormerNode> export type DormerEvent = NodeEvent<DormerNode>
export type DownspoutEvent = NodeEvent<DownspoutNode> export type DownspoutEvent = NodeEvent<DownspoutNode>
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode> export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
export type DuctFittingEvent = NodeEvent<DuctFittingNode> export type DuctFittingEvent = NodeEvent<DuctFittingNode>
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode> export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
NodeEvents<'spawn', SpawnEvent> & NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> & NodeEvents<'column', ColumnEvent> &
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
NodeEvents<'roof', RoofEvent> & NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> & NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> & NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'structural-grid', StructuralGridEvent> &
NodeEvents<'window', WindowEvent> & NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> & NodeEvents<'scan', ScanEvent> &
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
NodeEvents<'skylight', SkylightEvent> & NodeEvents<'skylight', SkylightEvent> &
NodeEvents<'dormer', DormerEvent> & NodeEvents<'dormer', DormerEvent> &
NodeEvents<'downspout', DownspoutEvent> & NodeEvents<'downspout', DownspoutEvent> &
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> &
NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> &
NodeEvents<'duct-terminal', DuctTerminalEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> &
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */
const DECK_POLYGON: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
/** Ground floor slab under (and far beyond) the deck. */
const GROUND_POLYGON: Array<[number, number]> = [
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
/** Straight fence fully over the deck footprint. */
function fenceOnDeck(overrides: Partial<FenceSupportInput> = {}): FenceSupportInput {
return {
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.08,
parentId: LEVEL_ID,
...overrides,
}
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
function sceneWith(...slabs: SlabNode[]): Record<string, AnyNode> {
const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[]))
useScene.setState({ nodes })
for (const slab of slabs) addSlab(slab)
return nodes
}
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('resolveFenceSupportSlabPatch', () => {
test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('the pointer cap decides between stacked surfaces', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
// Aiming at the floor under the deck elects (and persists) the floor.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }),
).toEqual({ supportSlabId: 'slab_ground' })
// Aiming at the deck top keeps the deck.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }),
).toEqual({ supportSlabId: 'slab_deck' })
})
test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => {
// Unambiguous single candidate — but fences resolve an absent host to
// the level floor, so an elevated winner must be written or the fence
// renders buried under the deck.
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => {
const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
})
test('capped at bare ground under a deck-only overlap resolves to the floor default', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({
supportSlabId: undefined,
})
})
test('no slabs / off-slab fence persists nothing', () => {
const nodes = sceneWith()
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(
resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck),
).toEqual({ supportSlabId: undefined })
})
test('a spline fence elects through its path band segments', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
const spline = fenceOnDeck({
start: [0.5, 0.5],
end: [3.5, 2.5],
path: [
[0.5, 0.5],
[2, 1.5],
[3.5, 2.5],
],
})
expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a fence not parented to a level persists nothing', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({
supportSlabId: undefined,
})
})
})
@@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab
holes: [], holes: [],
holeMetadata: [], holeMetadata: [],
elevation, elevation,
thickness: Math.max(elevation, 0),
recessed: elevation < 0,
autoFromWalls: false, autoFromWalls: false,
} as SlabNode } as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
@@ -8,12 +8,29 @@ import type {
import type { AnyNode, AnyNodeId } from '../../schema' import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager' import { spatialGridManager } from './spatial-grid-manager'
/**
* Sentinel `supportSlabId` meaning "hosted by the level base (ground)".
* Persisted when a pointer-capped commit elects the ground while one or
* more slabs (e.g. an elevated deck) still overlap the footprint above the
* cap — without it, the uncapped per-frame election would lift the
* committed node back onto the deck.
*/
export const GROUND_SUPPORT_ID = 'ground'
export type FloorPlacedElevationArgs = { export type FloorPlacedElevationArgs = {
node: AnyNode node: AnyNode
nodes: Record<string, AnyNode> nodes: Record<string, AnyNode>
position: [number, number, number] position: [number, number, number]
rotation?: unknown rotation?: unknown
levelId?: string | null levelId?: string | null
/**
* Pointer-decided support cap (level-local Y): only slabs whose walking
* surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON`
* may be elected, and the persisted `supportSlabId` is bypassed — during
* a drag the pointer, not the stored host, decides the target surface.
* Omit (or pass null) for the uncapped committed-read behavior.
*/
maxElevation?: number | null
} }
function finiteSlabElevation(elevation: number): number { function finiteSlabElevation(elevation: number): number {
@@ -50,6 +67,7 @@ export function getFloorPlacedElevation({
position, position,
rotation, rotation,
levelId, levelId,
maxElevation,
}: FloorPlacedElevationArgs): number { }: FloorPlacedElevationArgs): number {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) return 0 if (!floorPlaced) return 0
@@ -66,8 +84,31 @@ export function getFloorPlacedElevation({
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
if (!resolvedLevelId) return 0 if (!resolvedLevelId) return 0
let maxElevation = Number.NEGATIVE_INFINITY const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
// A persisted support host pins the elevation while it still exists and
// overlaps a footprint — deterministic across stacked slabs. A stale
// host (deleted or reshaped away) silently falls through to the
// election below; this per-frame read path never writes the field.
// Skipped entirely under a pointer cap: the cursor, not the stored
// host, decides the target surface during a drag.
const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId
if (maxElevation == null && supportSlabId) {
if (supportSlabId === GROUND_SUPPORT_ID) return 0
for (const footprint of footprints) {
const hosted = spatialGridManager.getHostSlabElevationForFootprint(
resolvedLevelId,
supportSlabId,
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
)
if (hosted !== null) return finiteSlabElevation(hosted)
}
}
let elected = Number.NEGATIVE_INFINITY
for (const footprint of footprints) {
const footprintPosition = footprint.position ?? position const footprintPosition = footprint.position ?? position
const elevation = finiteSlabElevation( const elevation = finiteSlabElevation(
spatialGridManager.getSlabElevationForItem( spatialGridManager.getSlabElevationForItem(
@@ -75,14 +116,15 @@ export function getFloorPlacedElevation({
footprintPosition, footprintPosition,
footprint.dimensions, footprint.dimensions,
footprint.rotation, footprint.rotation,
maxElevation,
), ),
) )
if (elevation > maxElevation) { if (elevation > elected) {
maxElevation = elevation elected = elevation
} }
} }
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation return elected === Number.NEGATIVE_INFINITY ? 0 : elected
} }
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { resolveSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [-1, 1]. */
const DECK_POLYGON: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */
const GROUND_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function addDeckAndFloor() {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('pointer-capped slab support election', () => {
test('hit at the floor under the deck elects the floor, not the deck above', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
FLOOR_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('hit on the deck top still elects the deck', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('no cap keeps the historical max election', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => {
// Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not.
addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0.1, slabId: 'slab_within' })
spatialGridManager.clear()
addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0, slabId: null })
})
})
describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => {
test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => {
addDeckAndFloor()
// Camera in front of the deck (negative z), high up. Aiming at the
// floor point (0, FLOOR, 0) — a point that lies UNDER the deck in
// plan — crosses the deck's elevation plane before reaching the deck
// polygon, so only the floor is hit.
const origin: [number, number, number] = [0, 5, -10]
const toFloorUnderDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
0 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual(
{ elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] },
)
// Aiming at the deck's top surface: the deck plane crossing lands
// inside the deck polygon and is nearer along the ray than the floor.
const toDeckTop: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.5 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({
elevation: DECK_ELEVATION,
slabId: 'slab_deck',
point: [0, 0.5],
})
})
test('a ray through a deck hole falls through to the surface below', () => {
addSlab(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, {
holes: [
[
[-0.5, -0.5],
[0.5, -0.5],
[0.5, 0.5],
[-0.5, 0.5],
],
],
}),
)
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
// Straight down through the hole center.
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 0],
})
})
test('no slab crossing resolves the level base (with the base-plane point)', () => {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({
elevation: 0,
slabId: null,
point: [3, 3],
})
})
test('a ray that cannot reach any surface has no point', () => {
addDeckAndFloor()
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({
elevation: 0,
slabId: null,
point: null,
})
})
})
describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => {
// Manual repro this pins down: deck slab stacked above a floor slab,
// move an item over the deck near its far edge with an angled camera.
// The grid event plane rides at the ghost's LAST surface height, so the
// same screen ray produces hit points whose XZ differ by metres
// depending on which storey the plane rode at. The cap (ray → pointed
// surface) is plane-height independent, but electing at the RAW hit XZ
// is not: the floor-height hit is perspective-skewed past the deck, its
// footprint misses the deck polygon, and the capped election falls to
// the floor — dropping the ghost, which drops the plane, which keeps
// the hit skewed (a second self-consistent state). Transitions between
// the two states are the hop. Electing at the ray-derived `point`
// leaves a single fixed point per pointer ray.
const origin: [number, number, number] = [0, 5, -10]
/** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */
const aimAtDeck: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.8 - origin[2],
]
test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => {
addDeckAndFloor()
// The two grid hits the SAME screen ray produces — one per event-plane
// height (plane riding at the deck vs at the floor slab).
const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1]
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [
origin[0] + aimAtDeck[0] * t,
origin[1] + aimAtDeck[1] * t,
origin[2] + aimAtDeck[2] * t,
])
for (const hit of planeHits) {
const direction: [number, number, number] = [
hit[0] - origin[0],
hit[1] - origin[1],
hit[2] - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction)
expect(pointed.slabId).toBe('slab_deck')
expect(pointed.elevation).toBe(DECK_ELEVATION)
expect(pointed.point?.[0]).toBeCloseTo(0, 10)
expect(pointed.point?.[1]).toBeCloseTo(0.8, 10)
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[pointed.point![0], 0, pointed.point![1]],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
}
})
test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => {
addDeckAndFloor()
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const floorPlaneHit: [number, number, number] = [
origin[0] + aimAtDeck[0] * tFloor,
0,
origin[2] + aimAtDeck[2] * tFloor,
]
// The skew carries the hit metres past the deck's far edge (z = 1)…
expect(floorPlaneHit[2]).toBeGreaterThan(2)
// …so the same pointer ray, elected at the raw hit XZ, picks the
// FLOOR while the cap says the pointer is on the deck.
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
floorPlaneHit,
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('pointer past the deck edge: pointed point lands on the floor and elects it', () => {
addDeckAndFloor()
// Aimed at a floor point far enough out that the deck-plane crossing
// falls outside the deck polygon (the floor there is actually visible).
const aimPastDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
4 - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck)
expect(pointed).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 4],
})
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 4],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
})
describe('getFloorPlacedElevation under a pointer cap', () => {
test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: DECK_ELEVATION,
}),
).toBeCloseTo(DECK_ELEVATION)
// Uncapped read keeps the historical max election.
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(DECK_ELEVATION)
})
test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
})
test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
})
describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => {
test('a commit under the deck persists the elected lower slab', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_floor',
})
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
// Uncapped commits keep the historical rule (max winner on ambiguity).
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a commit on bare ground under the deck persists the ground sentinel', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({
supportSlabId: GROUND_SUPPORT_ID,
})
// Aiming at the deck top with only the deck overlapping stays
// unambiguous — no host persisted, same as the uncapped rule.
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
test('a single floor slab under the cap stays unpersisted (unambiguous)', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
})
@@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry' import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import { getWallPlaneTop } from '../../services/storey'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' import {
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallSlabSupport,
} from '../../systems/slab/slab-support'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top'
import { getFloorPlacedFootprints } from './floor-placed-elevation' import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
export {
computeWallSlabElevation,
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallOverlapInput,
type WallSlabSupport,
type WallSlabSupportSegment,
wallOverlapsPolygon,
} from '../../systems/slab/slab-support'
// ============================================================================ // ============================================================================
// GEOMETRY HELPERS // GEOMETRY HELPERS
// ============================================================================ // ============================================================================
/**
* Point-in-polygon test using ray casting algorithm.
*/
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0],
zi = polygon[i]![1]
const xj = polygon[j]![0],
zj = polygon[j]![1]
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
/** /**
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
*/ */
@@ -295,512 +294,29 @@ export function itemOverlapsPolygon(
return false return false
} }
function pointSegmentDistance( /** One slab overlapping a queried footprint, as seen by support election. */
px: number, export type SlabSupportCandidate = {
pz: number, slabId: string
ax: number,
az: number,
bx: number,
bz: number,
): number {
const dx = bx - ax
const dz = bz - az
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
}
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
// boundary: the answer flips depending on which side of the polygon the edge
// is on. Interval classification below therefore treats "within this distance
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
// edge (the common case — auto-slab polygons derive from wall centerlines)
// classify identically on every side of the slab.
const ON_BOUNDARY_EPSILON = 1e-4
function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean {
const n = polygon.length
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i]!
const [bx, bz] = polygon[(i + 1) % n]!
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
}
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
for (let i = 0; i < n; i++) {
const [px, pz] = polygon[i]!
const [qx, qz] = polygon[(i + 1) % n]!
const ex = qx - px
const ez = qz - pz
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
const t = ((px - ax) * ez - (pz - az) * ex) / denom
const s = ((px - ax) * dz - (pz - az) * dx) / denom
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
}
ts.sort((a, b) => a - b)
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
if (t1 - t0 < 1e-9) continue
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
function polylineInsideLength(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
): number {
return intervalsLength(polylineInsideIntervals(points, polygon))
}
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
thickness?: number
}
// Minimum length of wall that must lie on/inside a slab polygon before the
// wall counts as overlapping it. Point contact (a perpendicular wall butting
// into a room's edge) clips to ~zero length and never reaches this, so such
// walls don't follow the slab's elevation.
const WALL_SLAB_MIN_OVERLAP = 0.05
/**
* Centerline of the wall plus its two face lines (centerline offset by
* ±halfThickness). The face lines catch walls whose centerline sits on or
* just outside the slab boundary but whose body reaches onto the slab —
* e.g. slab polygons drawn to the room's interior faces.
*/
function wallTestPolylines(
start: [number, number],
end: [number, number],
curveOffset: number,
halfThickness: number,
): Array<Array<{ x: number; y: number }>> {
const wallLike = { start, end, curveOffset }
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
const count = 16
const center: Array<{ x: number; y: number }> = []
const left: Array<{ x: number; y: number }> = []
const right: Array<{ x: number; y: number }> = []
for (let i = 0; i <= count; i++) {
const frame = getWallCurveFrameAt(wallLike, i / count)
center.push(frame.point)
left.push({
x: frame.point.x + frame.normal.x * halfThickness,
y: frame.point.y + frame.normal.y * halfThickness,
})
right.push({
x: frame.point.x - frame.normal.x * halfThickness,
y: frame.point.y - frame.normal.y * halfThickness,
})
}
return halfThickness > 0 ? [center, left, right] : [center]
}
const center = [
{ x: start[0], y: start[1] },
{ x: end[0], y: end[1] },
]
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const len = Math.hypot(dx, dz)
if (len < 1e-10 || halfThickness <= 0) return [center]
const nx = (-dz / len) * halfThickness
const nz = (dx / len) * halfThickness
return [
center,
[
{ x: start[0] + nx, y: start[1] + nz },
{ x: end[0] + nx, y: end[1] + nz },
],
[
{ x: start[0] - nx, y: start[1] - nz },
{ x: end[0] - nx, y: end[1] - nz },
],
]
}
/**
* Test whether a wall overlaps a slab polygon along a segment of its length.
*
* The wall's centerline and both face lines are clipped against the polygon;
* the wall overlaps when the longest clipped inside-or-on-boundary length
* exceeds a threshold (5cm, halved for very short walls). Because interval
* midpoints classify "on the boundary" as inside explicitly (never by
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
* identically on every side of the slab.
*
* A wall that only touches the polygon at a point — a perpendicular wall
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
* length and does NOT overlap.
*/
export function wallOverlapsPolygon(
startOrWall: [number, number] | WallOverlapInput,
endOrPolygon: [number, number] | Array<[number, number]>,
polygonArg?: Array<[number, number]>,
): boolean {
// Two call shapes:
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
let start: [number, number]
let end: [number, number]
let polygon: Array<[number, number]>
let curveOffset = 0
let thickness = DEFAULT_WALL_THICKNESS
if (Array.isArray(startOrWall)) {
start = startOrWall as [number, number]
end = endOrPolygon as [number, number]
polygon = polygonArg as Array<[number, number]>
} else {
start = startOrWall.start
end = startOrWall.end
curveOffset = startOrWall.curveOffset ?? 0
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
polygon = endOrPolygon as Array<[number, number]>
}
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
for (const line of polylines) {
overlap = Math.max(overlap, polylineInsideLength(line, polygon))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
elevation: number elevation: number
} }
export function computeWallSlabSupport( export type ItemSlabSupport = {
wallLike: WallOverlapInput, elevation: number
slabs: readonly SlabNode[], /** The winning slab, or null when no slab overlaps the footprint. */
levelWalls: WallNode[], slabId: string | null
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] }
const groups: ElevationGroup[] = []
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, perPolyline: polylines.map(() => []) }
groups.push(group)
}
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of evaluatedGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, baseElevation, baseSegments }
} }
export function computeWallSlabElevation( export type PointedSupportSurface = ItemSlabSupport & {
wallLike: WallOverlapInput, /**
slabs: readonly SlabNode[], * Level-local XZ where the ray meets the pointed surface's plane, or
levelWalls: WallNode[], * null when the ray never reaches it (grazing / aimed above the base).
): number { * This is the plan point the pointer actually indicates: unlike a grid
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation * event-plane hit — whose XZ shifts with whatever height the event
* plane currently rides at — it depends only on the ray and the
* aimed-at surface, so election/preview at this point cannot flip when
* the event plane changes storey.
*/
point: [number, number] | null
} }
export class SpatialGridManager { export class SpatialGridManager {
@@ -842,7 +358,24 @@ export class SpatialGridManager {
private getWallHeight(wallId: string): number { private getWallHeight(wallId: string): number {
const wall = this.walls.get(wallId) const wall = this.walls.get(wallId)
return wall?.height ?? 2.5 // Default wall height if (!wall) return 0
if (wall.height != null) return wall.height
const nodes = useScene.getState().nodes
const levelId = resolveNodeLevelId(wall, nodes)
const support = this.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(
wall,
getWallPlaneTop(wall, levelId, nodes),
support.elevation,
)
} }
private getCeilingGrid(ceilingId: string): SpatialGrid { private getCeilingGrid(ceilingId: string): SpatialGrid {
@@ -859,15 +392,74 @@ export class SpatialGridManager {
return this.slabsByLevel.get(levelId)! return this.slabsByLevel.get(levelId)!
} }
/**
* Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item
* support queries run per frame and the projection scans the level's
* walls + sibling slabs, so the result is cached per slab id and
* dropped for the whole level whenever a slab or wall on that level
* flows through the manager's create/update/delete handlers.
*/
private readonly renderedSlabPolygons = new Map<string, Array<[number, number]>>()
private invalidateRenderedSlabPolygons(levelId: string) {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return
for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId)
}
private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> {
const cached = this.renderedSlabPolygons.get(slab.id)
if (cached) return cached
const siblingSlabs: SlabNode[] = []
for (const other of this.getSlabMap(levelId).values()) {
if (other.id !== slab.id) siblingSlabs.push(other)
}
const polygon = getRenderableSlabPolygon(slab, {
walls: this.getLevelWallNodes(levelId),
siblingSlabs,
})
this.renderedSlabPolygons.set(slab.id, polygon)
return polygon
}
/**
* Support test shared by election, candidate listing, and persisted-host
* validation: the footprint overlaps the slab's RENDERED polygon (what
* users see — matching the wall election in `computeWallSlabSupport`),
* with the center-point hole veto kept against the stored holes (holes
* are data, never render-offset).
*/
private slabSupportsFootprint(
levelId: string,
slab: SlabNode,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): boolean {
if (slab.polygon.length < 3) return false
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false
const [cx, , cz] = position
for (const hole of slab.holes || []) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false
}
return true
}
// Called when nodes change // Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) { handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === 'slab') { if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode) this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') { } else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode) this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') { } else if (node.type === 'wall') {
const wall = node as WallNode const wall = node as WallNode
this.walls.set(wall.id, wall) this.walls.set(wall.id, wall)
// Rendered slab polygons adopt wall bands — a new wall can extend them.
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') { } else if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -920,11 +512,13 @@ export class SpatialGridManager {
handleNodeUpdated(node: AnyNode, levelId: string) { handleNodeUpdated(node: AnyNode, levelId: string) {
if (node.type === 'slab') { if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode) this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') { } else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode) this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') { } else if (node.type === 'wall') {
const wall = node as WallNode const wall = node as WallNode
this.walls.set(wall.id, wall) this.walls.set(wall.id, wall)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') { } else if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -982,12 +576,16 @@ export class SpatialGridManager {
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === 'slab') { if (nodeType === 'slab') {
// Invalidate before removal so the deleted slab's own cache entry
// (still keyed in the level map here) is dropped with its siblings'.
this.invalidateRenderedSlabPolygons(levelId)
this.getSlabMap(levelId).delete(nodeId) this.getSlabMap(levelId).delete(nodeId)
} else if (nodeType === 'ceiling') { } else if (nodeType === 'ceiling') {
this.ceilings.delete(nodeId) this.ceilings.delete(nodeId)
this.ceilingGrids.delete(nodeId) this.ceilingGrids.delete(nodeId)
} else if (nodeType === 'wall') { } else if (nodeType === 'wall') {
this.walls.delete(nodeId) this.walls.delete(nodeId)
this.invalidateRenderedSlabPolygons(levelId)
// Remove all items attached to this wall from the spatial grid // Remove all items attached to this wall from the spatial grid
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
return removedItemIds // Caller can use this to delete the items from scene return removedItemIds // Caller can use this to delete the items from scene
@@ -1201,45 +799,162 @@ export class SpatialGridManager {
/** /**
* Get the slab elevation for an item using its full footprint (bounding box). * Get the slab elevation for an item using its full footprint (bounding box).
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes). * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests)
* Returns the highest overlapping slab elevation, or 0 if none. * that only need the number.
*/ */
getSlabElevationForItem( getSlabElevationForItem(
levelId: string, levelId: string,
position: [number, number, number], position: [number, number, number],
dimensions: [number, number, number], dimensions: [number, number, number],
rotation: [number, number, number], rotation: [number, number, number],
maxElevation?: number | null,
): number { ): number {
const slabMap = this.slabsByLevel.get(levelId) return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation)
if (!slabMap) return 0 .elevation
}
let maxElevation = Number.NEGATIVE_INFINITY /**
* Elect the supporting slab for a footprint: the highest-elevation slab
* whose RENDERED polygon the footprint overlaps (center-point hole veto
* applies). Returns `{ elevation: 0, slabId: null }` when nothing
* overlaps.
*
* `maxElevation` is the pointer-decided cap: when set, only slabs whose
* walking surface sits at or below `maxElevation +
* SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface
* the cursor ray actually hit never captures the election.
*/
getSlabSupportForItem(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
maxElevation?: number | null,
): ItemSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return { elevation: 0, slabId: null }
let winningElevation = Number.NEGATIVE_INFINITY
let winnerId: string | null = null
for (const slab of slabMap.values()) { for (const slab of slabMap.values()) {
if ( const elevation = slab.elevation ?? 0.05
slab.polygon.length >= 3 && if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01) if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
) { if (elevation > winningElevation) {
// Check if item is entirely within a hole (if so, ignore this slab) winningElevation = elevation
// We consider it entirely in a hole if the item center is in the hole winnerId = slab.id
}
}
return winnerId === null
? { elevation: 0, slabId: null }
: { elevation: winningElevation, slabId: winnerId }
}
/**
* The walking surface the pointer actually points at: the nearest slab
* plane the ray crosses INSIDE that slab's rendered polygon (hole veto
* applies), or the level base (`elevation: 0, slabId: null`) when it
* crosses none. Ray origin/direction are level-local. Deliberately a
* point test, not a footprint test — it answers "which surface is under
* the cursor", which then caps the footprint election so a deck hanging
* above the aimed-at floor never lifts the placement. `point` is the
* ray's crossing of that surface's plane — the stable plan point
* callers should elect/preview at (see {@link PointedSupportSurface}).
*/
getPointedSupportSurface(
levelId: string,
rayOrigin: [number, number, number],
rayDirection: [number, number, number],
): PointedSupportSurface {
const slabMap = this.slabsByLevel.get(levelId)
const [ox, oy, oz] = rayOrigin
const [dx, dy, dz] = rayDirection
if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null }
let best: { t: number; elevation: number; slabId: string } | null = null
if (slabMap) {
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
const elevation = slab.elevation ?? 0.05
const t = (elevation - oy) / dy
if (t <= 0) continue
if (best && t >= best.t) continue
const x = ox + dx * t
const z = oz + dz * t
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue
let inHole = false let inHole = false
const [cx, , cz] = position for (const hole of slab.holes || []) {
const holes = slab.holes || [] if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
inHole = true inHole = true
break break
} }
} }
if (inHole) continue
best = { t, elevation, slabId: slab.id }
}
}
if (best) {
return {
elevation: best.elevation,
slabId: best.slabId,
point: [ox + dx * best.t, oz + dz * best.t],
}
}
const tBase = -oy / dy
return {
elevation: 0,
slabId: null,
point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null,
}
}
if (!inHole) { /**
const elevation = slab.elevation ?? 0.05 * All slabs supporting a footprint, one entry per overlapping slab
if (elevation > maxElevation) { * (highest elevation first; slab id breaks ties deterministically).
maxElevation = elevation * Commit-side ambiguity check: persist a `supportSlabId` only when the
* candidates carry ≥ 2 distinct elevations.
*/
getSupportCandidatesForFootprint(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): SlabSupportCandidate[] {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return []
const candidates: SlabSupportCandidate[] = []
for (const slab of slabMap.values()) {
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 })
} }
candidates.sort(
(a, b) =>
b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0),
)
return candidates
} }
}
} /**
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation * Elevation of a persisted support host for a footprint, or null when
* the slab no longer exists on the level or no longer overlaps the
* footprint (same overlap test as election). Deliberately read-only: a
* host reshaped away is NOT cleared — callers fall back to election and
* the stale reference resumes hosting if the slab's polygon returns.
* Slab deletion is the only writer (`deleteNodesAction` strips it).
*/
getHostSlabElevationForFootprint(
levelId: string,
slabId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): number | null {
const slab = this.slabsByLevel.get(levelId)?.get(slabId)
if (!slab) return null
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null
return slab.elevation ?? 0.05
} }
/** /**
@@ -1255,8 +970,10 @@ export class SpatialGridManager {
end: [number, number], end: [number, number],
curveOffset = 0, curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS, thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
): number { ): number {
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId)
.elevation
} }
getSlabSupportForWall( getSlabSupportForWall(
@@ -1265,11 +982,14 @@ export class SpatialGridManager {
end: [number, number], end: [number, number],
curveOffset = 0, curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS, thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport { ): WallSlabSupport {
const slabMap = this.slabsByLevel.get(levelId) const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) { if (!slabMap) {
return { return {
elevation: 0, elevation: 0,
electedSlabId: null,
baseElevation: 0, baseElevation: 0,
baseSegments: [{ start: 0, end: 1, elevation: 0 }], baseSegments: [{ start: 0, end: 1, elevation: 0 }],
} }
@@ -1279,6 +999,8 @@ export class SpatialGridManager {
{ start, end, curveOffset, thickness }, { start, end, curveOffset, thickness },
[...slabMap.values()], [...slabMap.values()],
this.getLevelWallNodes(levelId), this.getLevelWallNodes(levelId),
preferredSlabId,
maxElevation,
) )
} }
@@ -1387,6 +1109,7 @@ export class SpatialGridManager {
} }
clearLevel(levelId: string) { clearLevel(levelId: string) {
this.invalidateRenderedSlabPolygons(levelId)
this.floorGrids.delete(levelId) this.floorGrids.delete(levelId)
this.wallGrids.delete(levelId) this.wallGrids.delete(levelId)
this.slabsByLevel.delete(levelId) this.slabsByLevel.delete(levelId)
@@ -1400,8 +1123,33 @@ export class SpatialGridManager {
this.ceilingGrids.clear() this.ceilingGrids.clear()
this.ceilings.clear() this.ceilings.clear()
this.itemCeilingMap.clear() this.itemCeilingMap.clear()
this.renderedSlabPolygons.clear()
} }
} }
// Singleton instance // Singleton instance
export const spatialGridManager = new SpatialGridManager() export const spatialGridManager = new SpatialGridManager()
/**
* Effective (extruded) height of a wall resolved from a nodes record:
* {@link resolveWallEffectiveHeight} over the covering-clamped plane top
* (`getWallPlaneTop`) and the singleton manager's slab election — so the
* value always agrees with the rendered wall. One shared resolver for the
* editor overlays (measurement label, action menu, side handles) that used
* to copy this derivation locally.
*/
export function getWallEffectiveHeightForNodes(
wall: WallNode,
nodes: Record<string, AnyNode>,
): number {
const levelId = resolveNodeLevelId(wall, nodes)
const support = spatialGridManager.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation)
}
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import {
initSpatialGridSync,
markCoveringDependentsBelow,
markLevelHeightDependents,
} from './spatial-grid-sync'
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode {
return {
id,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: ordinal,
height,
} as AnyNode
}
function makeChild(id: string, type: string, parentId: string): AnyNode {
return {
id,
type,
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
start: [0, 1],
end: [4, 1],
thickness: 0.1,
polygon: SQUARE,
holes: [],
} as unknown as AnyNode
}
function makeSlab(id: string, parentId: string, overrides: Partial<AnyNode> = {}): AnyNode {
return {
id,
type: 'slab',
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
polygon: SQUARE,
holes: [],
holeMetadata: [],
elevation: 0.05,
thickness: 0.05,
autoFromWalls: false,
...overrides,
} as AnyNode
}
function nodesFor(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
function dirtyIds(): string[] {
return [...useScene.getState().dirtyNodes].sort()
}
describe('spatial-grid sync dirty rules (vertical model)', () => {
let stopSync = () => {}
// Two orphan levels sharing the legacy stack: level_0 (below) carries a
// wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab.
const wall = makeChild('wall_a', 'wall', 'level_0')
const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0')
const stair = makeChild('stair_a', 'stair', 'level_0')
const fence = makeChild('fence_a', 'fence', 'level_0')
const zone = makeChild('zone_a', 'zone', 'level_0')
const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 })
const level0 = makeLevel('level_0', 0, 2.5, [
'wall_a',
'ceiling_a',
'stair_a',
'fence_a',
'zone_a',
])
const level1 = makeLevel('level_1', 1, 2.5, ['slab_up'])
function setScene(nodes: Record<AnyNodeId, AnyNode>) {
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes,
readOnly: false,
rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[],
} as never)
clearSceneHistory()
}
beforeEach(() => {
spatialGridManager.clear()
setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab))
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
level_0: { ...level0, height: 3 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('a slab thickness change marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('a slab recessed toggle marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, recessed: true } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('creating a slab on the level above marks the level below, deleting it too', () => {
const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 })
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_new: added,
level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record<string, AnyNode>
useScene.setState({
nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
})
})
describe('spatial-grid sync dirty rules (deck-attached stairs)', () => {
let stopSync = () => {}
const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 })
const attachedStair = {
...makeChild('stair_deck', 'stair', 'level_0'),
deckSlabId: 'slab_deck',
} as AnyNode
const otherStair = makeChild('stair_other', 'stair', 'level_0')
const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other'])
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(deckLevel, deck, attachedStair, otherStair),
readOnly: false,
rootNodeIds: ['level_0'] as AnyNodeId[],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a deck elevation marks its attached stair dirty, not other stairs', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: { ...deck, elevation: 1.6 } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false)
})
test('a deck polygon-only change leaves the attached stair alone', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: {
...deck,
polygon: [
[0, 0],
[5, 0],
[5, 5],
[0, 5],
],
} as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false)
})
})
describe('sync dirty helpers (pure)', () => {
const collect = () => {
const marked: string[] = []
return { marked, markDirty: (id: AnyNodeId) => marked.push(id) }
}
test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => {
const level = makeLevel('level_0', 0, 2.5, [
'wall_a',
'stair_a',
'ceiling_a',
'fence_a',
'zone_a',
'missing',
])
const nodes = nodesFor(
level,
makeChild('wall_a', 'wall', 'level_0'),
makeChild('stair_a', 'stair', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('fence_a', 'fence', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markLevelHeightDependents(level as never, nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']),
makeLevel('level_1', 1, 2.5, []),
makeChild('wall_a', 'wall', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_1', nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'wall_a'])
})
test('markCoveringDependentsBelow is a no-op for the lowest level', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a']),
makeChild('wall_a', 'wall', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_0', nodes, markDirty)
expect(marked).toEqual([])
})
})
@@ -1,6 +1,7 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry' import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema'
import { getLevelBelow } from '../../services/storey'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation' import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { import {
@@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is added, mark overlapping items/walls dirty // When a slab is added, mark overlapping items/walls dirty
if (node.type === 'slab') { if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
} }
} }
} }
@@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is removed, mark items/walls that were on it dirty (using current state) // When a slab is removed, mark items/walls that were on it dirty (using current state)
if (node.type === 'slab') { if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
} }
} }
} }
@@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void {
} }
} }
} else if (node.type === 'slab' && prev.type === 'slab') { } else if (node.type === 'slab' && prev.type === 'slab') {
if ( const supportChanged =
node.polygon !== prev.polygon || node.polygon !== prev.polygon ||
node.elevation !== prev.elevation || node.elevation !== prev.elevation ||
node.holes !== prev.holes node.holes !== prev.holes
) { if (supportChanged) {
const levelId = resolveLevelId(node, state.nodes) const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId) spatialGridManager.handleNodeUpdated(node, levelId)
@@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void {
markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
} }
if (node.elevation !== prev.elevation) {
markDeckAttachedStairs(node.id, state.nodes, markDirty)
}
// The covering bound over the level below also moves with thickness
// (underside = elevation thickness) and recessed (pools never
// cover), which same-level support ignores.
if (
supportChanged ||
node.thickness !== prev.thickness ||
node.recessed !== prev.recessed
) {
markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty)
}
} else if (node.type === 'level' && prev.type === 'level') {
if (node.height !== prev.height) {
markLevelHeightDependents(node as LevelNode, state.nodes, markDirty)
}
} else if (node.type === 'wall' && prev.type === 'wall') {
if (
node.start !== prev.start ||
node.end !== prev.end ||
node.curveOffset !== prev.curveOffset ||
node.thickness !== prev.thickness
) {
// Rendered slab polygons adopt wall bands, so a wall reshape
// must reach the manager to refresh its wall map and drop the
// level's rendered-polygon cache.
spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes))
}
} }
} }
}) })
@@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]) return a.length === b.length && a.every((v, i) => v === b[i])
} }
/**
* A level's stored height moved: plane-bound walls follow the new plane,
* stair rise re-derives, and ceilings/fences re-resolve their clamp — mark
* them all so their systems rebuild. Restacking the level containers alone
* leaves their geometry stale.
*/
export function markLevelHeightDependents(
level: LevelNode,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const childId of level.children) {
const child = nodes[childId]
if (!child) continue
if (
child.type === 'wall' ||
child.type === 'stair' ||
child.type === 'ceiling' ||
child.type === 'fence'
) {
markDirty(child.id)
}
}
}
/**
* A deck slab's walking surface moved: stairs attached to it via
* `deckSlabId` derive their rise from that elevation, so their geometry
* (and rise-derived affordances) must rebuild.
*/
export function markDeckAttachedStairs(
slabId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const node of Object.values(nodes)) {
if (node.type === 'stair' && node.deckSlabId === slabId) {
markDirty(node.id)
}
}
}
/**
* A slab on `slabLevelId` was created/deleted or changed shape/placement:
* the covering bound (slab underside) over the level BELOW moved, so that
* level's plane-bound walls and clamped ceilings must rebuild.
*/
export function markCoveringDependentsBelow(
slabLevelId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
const below = getLevelBelow(slabLevelId, nodes)
if (!below) return
for (const childId of below.children) {
const child = nodes[childId]
if (child?.type === 'wall' || child?.type === 'ceiling') {
markDirty(child.id)
}
}
}
/** /**
* Mark all floor items and walls that may be affected by a slab change as dirty. * Mark all floor items and walls that may be affected by a slab change as dirty.
*/ */
@@ -190,10 +284,11 @@ function markNodesOverlappingSlab(
if (slab.polygon.length < 3) return if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes) const slabLevelId = resolveLevelId(slab, nodes)
// Walls follow the slab's RENDERED footprint (band-adopted edges reach // Walls AND floor-placed nodes follow the slab's RENDERED footprint
// the wall's outer face), so the dirty gate must test the same polygon // (band-adopted edges reach the wall's outer face), so the dirty gate
// `getSlabElevationForWall` will re-evaluate — a stored polygon that // must test the same polygon the support queries re-evaluate — a stored
// stops short of the wall body would otherwise never re-elevate it. // polygon that stops short of the wall body would otherwise never
// re-elevate nodes sitting over the adopted band.
const levelWalls: WallNode[] = [] const levelWalls: WallNode[] = []
const siblingSlabs: SlabNode[] = [] const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
@@ -249,7 +344,7 @@ function markNodesOverlappingSlab(
footprint.position ?? position, footprint.position ?? position,
footprint.dimensions, footprint.dimensions,
footprint.rotation, footprint.rotation,
slab.polygon, renderedPolygon,
0.01, 0.01,
) )
) { ) {
@@ -0,0 +1,217 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation'
import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager'
export type SupportSlabPatch = { supportSlabId: string | undefined }
export type SupportSlabPatchOptions = {
/**
* Pointer-decided support cap (level-local Y) — see
* `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host
* reproduces the CAPPED election: the elected lower slab wins over a
* deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the
* ground is elected while capped-out slabs still overlap the footprint.
*/
maxElevation?: number | null
}
export function resolveSupportSlabPatch(
node: AnyNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) {
return { supportSlabId: undefined }
}
const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null
const parent = parentId ? nodes[parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes })
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
let cappedOut = false
for (const footprint of footprints) {
const position = footprint.position ?? (node as { position?: unknown }).position
if (!Array.isArray(position) || position.length !== 3) continue
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
if (maxElevation != null && support.slabId === null && candidates.length > 0) {
cappedOut = true
}
}
if (winner !== null) {
return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined }
}
// Capped election chose the ground while overlapping slabs sit above the
// cap: persist the ground host, or the uncapped per-frame election would
// lift the committed node back onto the deck.
return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined }
}
export function resolveWallSupportSlabPatch(
wall: WallNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = wall.parentId ? nodes[wall.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
// Winner under the pointer cap (when given): a deck hanging above the
// aimed-at surface can't capture the elected base, so a wall drawn at the
// floor underneath it persists the floor slab the user actually targeted.
const support = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
null,
options?.maxElevation,
)
const candidateElevations = new Set<number>()
for (const node of Object.values(nodes)) {
if (node.type !== 'slab' || node.parentId !== parent.id) continue
const candidate = node as SlabNode
const preferred = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
candidate.id,
)
if (preferred.electedSlabId === candidate.id) {
candidateElevations.add(candidate.elevation)
}
}
return {
supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined,
}
}
/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */
export type FenceSupportInput = Pick<
FenceNode,
'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId'
>
/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */
const FENCE_CURVE_SUPPORT_SAMPLES = 16
/** Fallback fence thickness (schema default) when the node carries none. */
const DEFAULT_FENCE_THICKNESS = 0.08
/** Minimum band depth so the footprint survives the election's polygon inset. */
const MIN_FENCE_SUPPORT_BAND = 0.05
function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> {
if (fence.path && fence.path.length >= 2) {
return fence.path.map((point) => [point[0], point[1]])
}
const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 }
if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) {
const points: Array<[number, number]> = []
for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) {
const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES)
points.push([frame.point.x, frame.point.y])
}
return points
}
return [
[fence.start[0], fence.start[1]],
[fence.end[0], fence.end[1]],
]
}
/**
* Support-host patch for a fence: elect the slab the fence line stands on
* and persist it as `supportSlabId` (the fence lift resolves absent =
* level floor — see `packages/nodes/src/fence/lift.ts`).
*
* The centerline (chord, sampled arc, or spline path) is turned into thin
* band footprints and run through the same candidate machinery items use.
* `options.maxElevation` is the pointer-decided cap: aiming at the floor
* under a deck elects the floor, aiming at the deck top elects the deck.
*
* Persist rule: the items ambiguity rule (stacked candidates disagree)
* PLUS the elevated-host case — a winner sitting meaningfully above the
* level floor must be persisted even when unambiguous (a balcony deck with
* nothing underneath), or the commit loses the election entirely since
* fences run no per-frame election. A single default ground slab (its top
* within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so
* plain fences keep sitting at the level base. A capped-out election (all
* overlapping slabs above the aimed-at ground) also resolves to the floor
* via the same absent-host default. Pure; exported for tests.
*/
export function resolveFenceSupportSlabPatch(
fence: FenceSupportInput,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = fence.parentId ? nodes[fence.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND)
const points = fenceCenterlinePoints(fence)
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
for (let i = 1; i < points.length; i++) {
const [ax, az] = points[i - 1]!
const [bx, bz] = points[i]!
const length = Math.hypot(bx - ax, bz - az)
if (length < 1e-6) continue
const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2]
const dimensions: [number, number, number] = [length, 1, band]
// getItemFootprint's rotation convention: local +X maps to
// (cos yRot, sin yRot) in XZ, so the segment angle aligns the band.
const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0]
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position,
dimensions,
rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position,
dimensions,
rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
}
if (winner === null) return { supportSlabId: undefined }
const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON
return { supportSlabId: persist ? winner.slabId : undefined }
}
@@ -0,0 +1,628 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, AnyNodeId, SlabNode } from '../../schema'
import { WallNode } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top'
import { getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { initSpatialGridSync } from './spatial-grid-sync'
import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
const SQUARE: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(children: string[] = []): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
describe('persisted support hosts (items)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('no-host election over stacked slabs keeps returning the highest elevation', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a persisted host wins over the election, whichever slab it names', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node: hostedLow,
nodes: nodesFor(level, hostedLow),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.2)
expect(
getFloorPlacedElevation({
node: hostedHigh,
nodes: nodesFor(level, hostedHigh),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a host reshaped away falls back without clearing the field, and resumes on return', () => {
registerFloorPlacedItem()
const host = makeSlab('slab_low', SQUARE, 0.2)
addSlab(host)
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const args = {
node,
nodes: nodesFor(level, node),
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
// Reshape the host away from the item's footprint.
const movedAway: Array<[number, number]> = [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
]
spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8)
expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low')
// Reshape it back — the stale reference resumes hosting.
spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
})
test('getSlabSupportForItem surfaces the winning slab id', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0.8, slabId: 'slab_high' })
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0, slabId: null })
})
test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
addSlab(
makeSlab(
'slab_far',
[
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
0.5,
),
)
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([
{ slabId: 'slab_high', elevation: 0.8 },
{ slabId: 'slab_low', elevation: 0.2 },
])
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[20, 0, 20],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([])
})
test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => {
registerFloorPlacedItem()
const low = makeSlab('slab_low', SQUARE, 0.2)
const high = makeSlab('slab_high', SQUARE, 0.8)
addSlab(low)
addSlab(high)
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode)
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' })
spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID)
expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({
supportSlabId: undefined,
})
})
test('item support follows the RENDERED slab polygon (wall band adoption)', () => {
registerFloorPlacedItem()
// Room slab drawn on the wall centerlines; the rendered polygon
// extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls).
const roomPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }),
]
const level = makeLevel(walls.map((wall) => wall.id))
const node = makeFloorNode()
useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) })
// Grounded raised floor (thickness = elevation): band adoption only
// applies to grounded slabs — a floating deck keeps its drawn polygon.
addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 }))
// Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a
// 0.01 overlap inset) but inside the rendered band edge at x = 4.05.
const elevation = spatialGridManager.getSlabElevationForItem(
LEVEL_ID,
[4.3, 0, 1.5],
[0.6, 1, 0.6],
[0, 0, 0],
)
expect(elevation).toBeCloseTo(0.4)
// The manager sees wall changes: removing the walls drops the adopted
// band, so the same footprint stops electing the slab.
for (const wall of walls) {
spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID)
}
useScene.setState({ nodes: nodesFor(makeLevel(), node) })
expect(
spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]),
).toBe(0)
})
})
describe('persisted support hosts (walls, via the manager)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('preferred slab pins the elected elevation; invalid preference falls back', () => {
const polygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
addSlab(makeSlab('slab_low', polygon, 0.1))
addSlab(makeSlab('slab_high', polygon, 0.6))
const start: [number, number] = [0, 1.5]
const end: [number, number] = [4, 1.5]
const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end)
expect(elected.elevation).toBeCloseTo(0.6)
expect(elected.electedSlabId).toBe('slab_high')
const preferred = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_low',
)
expect(preferred.elevation).toBeCloseTo(0.1)
expect(preferred.electedSlabId).toBe('slab_low')
const fallback = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_missing',
)
expect(fallback.elevation).toBeCloseTo(0.6)
expect(fallback.electedSlabId).toBe('slab_high')
})
test('resolveWallSupportSlabPatch persists the winner over two elevations', () => {
const low = makeSlab(
'slab_low',
[
[-2, -1],
[0, -1],
[0, 1],
[-2, 1],
],
0.2,
)
const high = makeSlab(
'slab_high',
[
[0, -1],
[2, -1],
[2, 1],
[0, 1],
],
0.8,
)
const wall = WallNode.parse({
id: 'wall_test',
parentId: LEVEL_ID,
start: [-2, 0],
end: [2, 0],
thickness: 0.1,
})
const level = makeLevel([low.id, high.id, wall.id])
const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(low)
addSlab(high)
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({
supportSlabId: 'slab_high',
})
})
// Elevated deck stacked over a ground floor slab — the "wall on a deck"
// fixture (both slabs cover the wall band; the deck sits above).
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDeckOverFloorFixture() {
const deck = makeSlab(
'slab_deck',
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
DECK_ELEVATION,
)
const ground = makeSlab(
'slab_ground',
[
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
],
FLOOR_ELEVATION,
)
const wall = WallNode.parse({
id: 'wall_on_deck',
parentId: LEVEL_ID,
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.1,
})
const level = makeLevel([deck.id, ground.id, wall.id])
const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(deck)
addSlab(ground)
return { wall, nodes }
}
test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const support = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
)
expect(support.electedSlabId).toBe('slab_deck')
expect(support.elevation).toBeCloseTo(DECK_ELEVATION)
// Wall-top inversion: no stored height → the top stays at the storey
// plane, so the extruded body is the plane minus the deck base.
const storeyHeight = 2.7
expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight)
expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo(
storeyHeight - DECK_ELEVATION,
)
// Commit persists the deck deterministically (two candidate elevations).
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const capped = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
null,
FLOOR_ELEVATION,
)
expect(capped.electedSlabId).toBe('slab_ground')
expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION)
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_ground',
})
// Aiming at the deck top keeps the deck.
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
})
})
describe('deleteNodesAction strips supportSlabId references', () => {
let stopSync = () => {}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
registerFloorPlacedItem()
const slabLow = makeSlab('slab_low', SQUARE, 0.2)
const slabHigh = makeSlab('slab_high', SQUARE, 0.8)
const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const level = makeLevel(['slab_low', 'slab_high', item.id])
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item),
readOnly: false,
rootNodeIds: [LEVEL_ID as AnyNodeId],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
function itemElevation(): number {
const nodes = useScene.getState().nodes
const item = nodes['item_test' as AnyNodeId]!
return getFloorPlacedElevation({
node: item,
nodes,
position: [0, 0, 0],
rotation: [0, 0, 0],
})
}
test('deleting the host slab clears the reference and re-elects; undo restores both', () => {
expect(itemElevation()).toBeCloseTo(0.2)
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined()
expect(
(afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId,
).toBeUndefined()
expect(itemElevation()).toBeCloseTo(0.8)
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe(
'slab_low',
)
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting a non-host slab leaves the reference alone', () => {
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string })
.supportSlabId,
).toBe('slab_low')
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: {
...useScene.getState().nodes,
stair_test: stair,
[LEVEL_ID]: {
...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!,
children: ['slab_low', 'slab_high', 'item_test', 'stair_test'],
} as AnyNode,
} as never,
})
clearSceneHistory()
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(
(afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBeUndefined()
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe(
'slab_low',
)
})
test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: { ...useScene.getState().nodes, stair_test: stair } as never,
})
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBe('slab_low')
})
})
@@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]), parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]), parseWall([0, 4], [0, 0]),
] ]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
const bottom = walls[0]! const bottom = walls[0]!
expect( expect(
@@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => {
).toBeCloseTo(0.1) ).toBeCloseTo(0.1)
}) })
it('elects a floating deck for a wall standing on its drawn footprint', () => {
// Wall ON a deck: no band adoption needed — the wall body lies inside
// the deck's drawn polygon, which is exactly what a floating slab
// renders.
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
const wallOnDeck = parseWall([1, 2], [3, 2])
expect(
computeWallSlabElevation(
{ start: [1, 2], end: [3, 2], thickness: 0.1 },
[deck],
[wallOnDeck],
),
).toBeCloseTo(1.5)
})
it('a wall in the adoption band beside a floating deck does not stand on it', () => {
// Centerline 6cm below the deck's bottom edge — inside the adoption
// band (half-thickness + 0.06) but the body never reaches the drawn
// footprint. A grounded slab adopts the band and carries the wall; the
// deck keeps its drawn polygon and offers no support.
const bandWall = parseWall([0, -0.06], [4, -0.06])
const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness }
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0)
const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1)
})
it('lifts a wall whose body a legacy stored polygon falls short of', () => { it('lifts a wall whose body a legacy stored polygon falls short of', () => {
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
// 1cm short of even the inner faces, so the STORED polygon never // 1cm short of even the inner faces, so the STORED polygon never
@@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]), parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]), parseWall([0, 4], [0, 0]),
] ]
// Grounded (thickness = elevation): band adoption only applies to
// grounded room floors under the vertical model.
const slab = SlabNode.parse({ const slab = SlabNode.parse({
polygon: [ polygon: [
[0.06, 0.06], [0.06, 0.06],
@@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => {
[0.06, 3.94], [0.06, 3.94],
], ],
elevation: 0.1, elevation: 0.1,
thickness: 0.1,
}) })
const bottom = walls[0]! const bottom = walls[0]!
@@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => {
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []), computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
).toEqual({ ).toEqual({
elevation: 0.6, elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.6, baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
}) })
@@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => {
), ),
).toEqual({ ).toEqual({
elevation: 0.6, elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.05, baseElevation: 0.05,
baseSegments: [ baseSegments: [
{ start: 0, end: 2 / 3, elevation: 0.6 }, { start: 0, end: 2 / 3, elevation: 0.6 },
@@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => {
it('keeps a shared wall on the higher slab that carries the full wall band', () => { it('keeps a shared wall on the higher slab that carries the full wall band', () => {
const sharedWall = parseWall([4, 0], [4, 4]) const sharedWall = parseWall([4, 0], [4, 4])
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
// Raised room floor: grounded (thickness = elevation) so the band-carry
// rule applies — a floating deck would keep its drawn polygon instead.
const high = SlabNode.parse({ const high = SlabNode.parse({
polygon: [ polygon: [
[4, 0], [4, 0],
@@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => {
[4, 4], [4, 4],
], ],
elevation: 0.6, elevation: 0.6,
thickness: 0.6,
}) })
expect( expect(
@@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => {
), ),
).toEqual({ ).toEqual({
elevation: 0.6, elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.6, baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
}) })
@@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => {
parseWall([8, 1.5], [8, 4.5]), parseWall([8, 1.5], [8, 4.5]),
parseWall([8, 4.5], [4, 4.5]), parseWall([8, 4.5], [4, 4.5]),
] ]
// Grounded raised room floor (see the shared-wall test above).
const high = SlabNode.parse({ const high = SlabNode.parse({
polygon: [ polygon: [
[0, 0], [0, 0],
@@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => {
[0, 3], [0, 3],
], ],
elevation: 0.6, elevation: 0.6,
thickness: 0.6,
}) })
const low = SlabNode.parse({ const low = SlabNode.parse({
polygon: [ polygon: [
@@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => {
), ),
).toEqual({ ).toEqual({
elevation: 0.6, elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.05, baseElevation: 0.05,
baseSegments: [ baseSegments: [
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 }, { start: 0, end: 3.05 / 4.5, elevation: 0.6 },
+35 -3
View File
@@ -9,8 +9,10 @@ export type {
CeilingEvent, CeilingEvent,
ChimneyEvent, ChimneyEvent,
ColumnEvent, ColumnEvent,
ConstructionDimensionEvent,
DoorEvent, DoorEvent,
DormerEvent, DormerEvent,
DrawingSheetEvent,
ElevatorEvent, ElevatorEvent,
EventSuffix, EventSuffix,
FenceEvent, FenceEvent,
@@ -34,6 +36,7 @@ export type {
SpawnEvent, SpawnEvent,
StairEvent, StairEvent,
StairSegmentEvent, StairSegmentEvent,
StructuralGridEvent,
WallEvent, WallEvent,
WindowEvent, WindowEvent,
ZoneEvent, ZoneEvent,
@@ -46,12 +49,16 @@ export {
} from './hooks/scene-registry/scene-registry' } from './hooks/scene-registry/scene-registry'
export { export {
type FloorPlacedElevationArgs, type FloorPlacedElevationArgs,
GROUND_SUPPORT_ID,
getFloorPlacedElevation, getFloorPlacedElevation,
getFloorPlacedFootprints, getFloorPlacedFootprints,
getFloorStackedPosition, getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation' } from './hooks/spatial-grid/floor-placed-elevation'
export { export {
getWallEffectiveHeightForNodes,
type PointedSupportSurface,
pointInPolygon, pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
spatialGridManager, spatialGridManager,
type WallSlabSupportSegment, type WallSlabSupportSegment,
} from './hooks/spatial-grid/spatial-grid-manager' } from './hooks/spatial-grid/spatial-grid-manager'
@@ -61,6 +68,14 @@ export {
resolveBuildingForLevel, resolveBuildingForLevel,
resolveLevelId, resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export {
type FenceSupportInput,
resolveFenceSupportSlabPatch,
resolveSupportSlabPatch,
resolveWallSupportSlabPatch,
type SupportSlabPatch,
type SupportSlabPatchOptions,
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export { export {
@@ -76,6 +91,7 @@ export {
closestMeasurementFeatureBinding, closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE, MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback, measurementAnchorFallback,
measurementAnchorReferenceNodeIds,
measurementAngle, measurementAngle,
measurementArea, measurementArea,
measurementAreaVector, measurementAreaVector,
@@ -86,6 +102,7 @@ export {
measurementPerimeter, measurementPerimeter,
measurementPrismVolume, measurementPrismVolume,
measurementReferenceNodeIds, measurementReferenceNodeIds,
remapMeasurementAnchors,
remapMeasurementReferences, remapMeasurementReferences,
} from './lib/measurement-geometry' } from './lib/measurement-geometry'
export { export {
@@ -123,7 +140,6 @@ export {
planAutoCeilingsForLevel, planAutoCeilingsForLevel,
planAutoSlabsForLevel, planAutoSlabsForLevel,
planAutoZonesForLevel, planAutoZonesForLevel,
projectAutoSlabsForPlan,
resolveAutoZonePolygon, resolveAutoZonePolygon,
resumeSpaceDetection, resumeSpaceDetection,
type Space, type Space,
@@ -146,7 +162,9 @@ export {
} from './lib/zone-quantities' } from './lib/zone-quantities'
export { export {
getCatalogMaterialById, getCatalogMaterialById,
getDynamicLibraryMaterials,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getLibraryMaterialsVersion,
getMaterialPresetByRef, getMaterialPresetByRef,
getMaterialsForCategory, getMaterialsForCategory,
getSceneMaterialIdFromRef, getSceneMaterialIdFromRef,
@@ -157,12 +175,16 @@ export {
type MaterialCatalogItem, type MaterialCatalogItem,
type MaterialCategory, type MaterialCategory,
type MaterialRef, type MaterialRef,
type MaterialSource,
type MaterialSurface, type MaterialSurface,
type ParsedMaterialRef, type ParsedMaterialRef,
parseMaterialRef, parseMaterialRef,
registerLibraryMaterials,
SCENE_MATERIAL_REF_PREFIX, SCENE_MATERIAL_REF_PREFIX,
subscribeLibraryMaterials,
toLibraryMaterialRef, toLibraryMaterialRef,
toSceneMaterialRef, toSceneMaterialRef,
unregisterLibraryMaterials,
} from './material-library' } from './material-library'
export type { export type {
FloorPlacedFootprint, FloorPlacedFootprint,
@@ -248,9 +270,7 @@ export {
} from './systems/elevator/elevator-runtime' } from './systems/elevator/elevator-runtime'
export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system'
export { export {
DEFAULT_ELEVATOR_LEVEL_HEIGHT,
type ElevatorLevelEntry, type ElevatorLevelEntry,
getElevatorLevelHeight,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorLevels, resolveElevatorLevels,
resolveElevatorServiceLevelIds, resolveElevatorServiceLevelIds,
@@ -269,13 +289,20 @@ export {
isSplineFence, isSplineFence,
sampleFenceSpline, sampleFenceSpline,
} from './systems/fence/fence-spline' } from './systems/fence/fence-spline'
export {
clampSlabElevationForWalls,
getSlabElevationUpperBound,
type SlabElevationClamp,
} from './systems/slab/slab-support'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { resolveStairTotalRise } from './systems/stair/stair-rise'
export { export {
getClampedWallCurveOffset, getClampedWallCurveOffset,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallArcData,
getWallChordFrame, getWallChordFrame,
getWallCurveFrameAt, getWallCurveFrameAt,
getWallCurveLength, getWallCurveLength,
@@ -313,6 +340,11 @@ export {
type WallMoveLinkedWallTargetPlan, type WallMoveLinkedWallTargetPlan,
type WallPlanPoint, type WallPlanPoint,
} from './systems/wall/wall-move' } from './systems/wall/wall-move'
export {
MIN_WALL_HEIGHT,
resolveWallEffectiveHeight,
resolveWallTop,
} from './systems/wall/wall-top'
export type { SceneGraph } from './utils/clone-scene-graph' export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types' export { isObject } from './utils/types'
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types' import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement' import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
import { import {
areMeasurementPointsCoplanar, areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding, closestMeasurementFeatureBinding,
measurementAnchorReferenceNodeIds,
measurementAngle, measurementAngle,
measurementArea, measurementArea,
measurementAreaVector, measurementAreaVector,
@@ -12,6 +13,7 @@ import {
measurementNormal, measurementNormal,
measurementPerimeter, measurementPerimeter,
measurementPrismVolume, measurementPrismVolume,
remapMeasurementAnchors,
} from './measurement-geometry' } from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => { const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
}) })
test('remaps and collects references for arbitrary anchor strings', () => {
const anchors: MeasurementAnchor[] = [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [4, 0, 0],
},
]
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
const remapped = remapMeasurementAnchors(
anchors,
new Map([
['wall_a', 'wall_a_copy'],
['wall_b', 'wall_b_copy'],
]),
)
const first = remapped[0]!
const last = remapped[2]!
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
})
}) })
+40 -10
View File
@@ -1,4 +1,5 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types' import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
import type { import type {
MeasurementAnchor, MeasurementAnchor,
MeasurementPayload, MeasurementPayload,
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
measurement: MeasurementPayload, measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>, idMap: ReadonlyMap<string, string>,
): MeasurementPayload { ): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => { const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
if (Array.isArray(anchor)) return anchor remapMeasurementAnchors([anchor], idMap)[0]!
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
switch (measurement.kind) { switch (measurement.kind) {
case 'distance': case 'distance':
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
} }
} }
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { export function remapMeasurementAnchors(
const anchors = anchors: readonly MeasurementAnchor[],
measurement.kind === 'distance' || measurement.kind === 'angle' idMap: ReadonlyMap<string, string>,
? measurement.points ): MeasurementAnchor[] {
: measurement.base return anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
})
}
export function remapConstructionDimensionReferences(
dimension: ConstructionDimensionNode,
idMap: ReadonlyMap<string, string>,
): ConstructionDimensionNode {
const controllingDimensionId = dimension.controllingDimensionId
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
dimension.controllingDimensionId)
: null
return {
...dimension,
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
controllingDimensionId,
}
}
export function measurementAnchorReferenceNodeIds(
anchors: readonly MeasurementAnchor[],
): AnyNodeId[] {
const ids = new Set<string>() const ids = new Set<string>()
for (const anchor of anchors) { for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId) if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
return [...ids] as AnyNodeId[] return [...ids] as AnyNodeId[]
} }
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
return measurementAnchorReferenceNodeIds(anchors)
}
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint { export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0] if (points.length < 3) return [0, 0, 0]
+94 -5
View File
@@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1)
return WallNode.parse({ start, end, thickness }) return WallNode.parse({ start, end, thickness })
} }
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) { function slabOf(
return SlabNode.parse( polygon: Array<[number, number]>,
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation }, autoFromWalls = true,
) elevation?: number,
thickness?: number,
) {
return SlabNode.parse({
polygon,
autoFromWalls,
...(elevation === undefined ? {} : { elevation }),
// Raised ROOM FLOOR fixtures pass thickness = elevation so the slab
// stays grounded (underside 0) — adoption/seam rules only apply to
// grounded slabs; the schema-default 0.05 thickness would make an
// elevated fixture a floating deck.
...(thickness === undefined ? {} : { thickness }),
})
} }
function xs(polygon: Array<[number, number]>) { function xs(polygon: Array<[number, number]>) {
@@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => {
], ],
false, false,
0.34, 0.34,
0.34,
) )
const low = slabOf( const low = slabOf(
[ [
@@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => {
], ],
false, false,
0.4, 0.4,
0.4,
) )
const legacyLow = slabOf( const legacyLow = slabOf(
[ [
@@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => {
}) })
test('stacked slabs are not mistaken for rooms across a wall', () => { test('stacked slabs are not mistaken for rooms across a wall', () => {
// The platform is a grounded raised floor (thickness = elevation); the
// floating-deck variant of this shape is covered by the adoption-gate
// tests below.
const floor = slabOf(roomA, false, 0.05) const floor = slabOf(roomA, false, 0.05)
const platform = slabOf(roomA, false, 0.4) const platform = slabOf(roomA, false, 0.4, 0.4)
const walls = [ const walls = [
wallOf([0, 0], [4, 0]), wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]), wallOf([4, 0], [4, 3]),
@@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => {
], ],
false, false,
0.3, 0.3,
0.3,
) )
const stepLow = slabOf( const stepLow = slabOf(
[ [
@@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => {
], ],
true, true,
0.4, 0.4,
0.4,
) )
const low = slabOf( const low = slabOf(
[ [
@@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => {
}) })
}) })
describe('grounded adoption gate', () => {
// Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS
// tile with the walls standing on them. It applies only to grounded
// slabs (underside ≈ 0) and recessed pools; a floating deck keeps its
// drawn polygon exactly.
test('a floating deck near walls keeps its drawn polygon exactly', () => {
// Same footprint as roomA — every edge inside a wall adoption band —
// but floating at 1.5m: no edge may extend to a wall face.
const deck = slabOf(roomA, false, 1.5, 0.05)
const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] })
expect(poly).toEqual(roomA)
})
test('boundary case: underside 0.005 still counts as grounded and adopts', () => {
const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05)
const poly = getRenderableSlabPolygon(nearlyGrounded, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a slab floated just past the epsilon stops adopting', () => {
// Underside 0.02 > 0.01 epsilon — already a deck.
const justFloating = slabOf(roomA, false, 0.07, 0.05)
const poly = getRenderableSlabPolygon(justFloating, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(0)
})
test('a recessed pool keeps band adoption (unchanged)', () => {
// Recessed slabs are sunk into the ground, never floating — their
// negative elevation encodes depth, so the gate must not strip the
// wall-face extension a sunken room floor relies on.
const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true })
const poly = getRenderableSlabPolygon(pool, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a grounded floor ignores a floating deck sibling as a seam target', () => {
// Deck butted across the x=4 wall band: were it a room floor, the
// grounded (lower) floor would terminate at its own wall face (3.95).
// As a deck it is no seam partner — the floor adopts the wall's outer
// face (4.05) as if alone, and the deck itself stays as drawn.
const floor = slabOf(roomA, false, 0.05)
const deck = slabOf(roomB, false, 1.5, 0.05)
const walls = [wallOf([4, 0], [4, 3])]
const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] })
const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] })
expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05)
expect(deckPoly).toEqual(roomB)
})
})
describe('snapSlabEdgeToWallBand', () => { describe('snapSlabEdgeToWallBand', () => {
test('an edge inside the band snaps onto the wall centerline', () => { test('an edge inside the band snaps onto the wall centerline', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])]) const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])])
+32 -1
View File
@@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint'
* render offsets. * render offsets.
* - FREE — no neighbour, no wall. Rendered exactly as drawn. * - FREE — no neighbour, no wall. Rendered exactly as drawn.
* *
* The whole machinery exists to make ROOM FLOORS tile with the walls
* standing on them, so it only applies to GROUNDED slabs (underside on
* the level plane) and recessed pools. A floating deck keeps its drawn
* polygon exactly — it must not grow into a wall it happens to float
* beside — and is symmetrically ignored as a seam target by its
* grounded siblings.
*
* Sub-edges of one edge with different projections are joined by a * Sub-edges of one edge with different projections are joined by a
* perpendicular STEP connector at the breakpoint. Breakpoints sit on * perpendicular STEP connector at the breakpoint. Breakpoints sit on
* candidate span boundaries — wall junctions — so the step's vertical * candidate span boundaries — wall junctions — so the step's vertical
@@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02
const CURVED_WALL_SAMPLE_SEGMENTS = 32 const CURVED_WALL_SAMPLE_SEGMENTS = 32
const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 const SLAB_SEAM_ELEVATION_EPSILON = 1e-4
const DEFAULT_SLAB_ELEVATION = 0.05 const DEFAULT_SLAB_ELEVATION = 0.05
const DEFAULT_SLAB_THICKNESS = 0.05
/**
* A non-recessed slab whose underside (`elevation thickness`) rises
* above the level plane by more than this is a floating deck: it keeps
* its drawn polygon (no wall adoption, no seam projection) and grounded
* siblings don't seam toward it.
*/
const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01
/** Prevent near-parallel offset lines from producing unbounded corner spikes. */ /** Prevent near-parallel offset lines from producing unbounded corner spikes. */
const MAX_CORNER_MITER_RATIO = 10 const MAX_CORNER_MITER_RATIO = 10
/**
* Floating deck test — see the module header. Recessed pools are never
* floating: their negative elevation encodes depth, not placement.
*/
function isFloatingSlab(slab: SlabNode): boolean {
if (slab.recessed) return false
const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION
const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS
return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON
}
export type SlabPolygonContext = { export type SlabPolygonContext = {
/** Walls on the slab's level. */ /** Walls on the slab's level. */
walls: WallNode[] walls: WallNode[]
@@ -138,7 +164,7 @@ export function getRenderableSlabPolygon(
context: SlabPolygonContext, context: SlabPolygonContext,
): Array<[number, number]> { ): Array<[number, number]> {
const polygon = slabNode.polygon const polygon = slabNode.polygon
if (polygon.length < 3) { if (polygon.length < 3 || isFloatingSlab(slabNode)) {
return polygon.map(([x, z]) => [x, z] as [number, number]) return polygon.map(([x, z]) => [x, z] as [number, number])
} }
@@ -368,6 +394,11 @@ function computeEdgeSubSpans(
const neighborSegments: NeighborSegment[] = [] const neighborSegments: NeighborSegment[] = []
for (const sibling of context.siblingSlabs) { for (const sibling of context.siblingSlabs) {
// A floating deck keeps its drawn polygon, so it can't be a seam
// partner: projecting toward it would move this slab's edge while the
// deck's stays put (asymmetric seam), and the higher/lower band rules
// only describe room floors meeting under a wall.
if (isFloatingSlab(sibling)) continue
const siblingPolygon = sibling.polygon const siblingPolygon = sibling.polygon
if (siblingPolygon.length < 2) continue if (siblingPolygon.length < 2) continue
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
+252 -32
View File
@@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resolveCeilingHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import { import {
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync,
planAutoCeilingsForLevel, planAutoCeilingsForLevel,
planAutoSlabsForLevel, planAutoSlabsForLevel,
planAutoZonesForLevel, planAutoZonesForLevel,
@@ -38,16 +42,35 @@ function slab(elevation: number) {
} }
describe('planAutoCeilingsForLevel', () => { describe('planAutoCeilingsForLevel', () => {
test('creates auto ceilings at the top of the room walls', () => { test('creates auto ceilings height-less so they follow the level top', () => {
const created = planAutoCeilingsForLevel([roomPolygon()], [], { const created = planAutoCeilingsForLevel([roomPolygon()], [], {
walls: squareWalls(), storeyHeight: 2.7,
slabs: [slab(0.05)],
}).create[0] }).create[0]
expect(created?.height).toBeCloseTo(2.55) expect(created).toBeDefined()
// Follows-mode: no stored height — the effective height derives from
// the clamp bound at read time via resolveCeilingHeight.
expect('height' in created!).toBe(false)
expect(created?.autoFromWalls).toBe(true)
}) })
test('updates existing auto ceiling height when the slab elevation changes', () => { test('never writes a height onto a matched auto ceiling', () => {
const ceiling = CeilingNode.parse({
polygon: square,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
storeyHeight: 3,
})
// Same polygon, follows-mode height — nothing to update.
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
test('a leftover explicit height on a matched auto ceiling is not rewritten', () => {
const ceiling = CeilingNode.parse({ const ceiling = CeilingNode.parse({
polygon: square, polygon: square,
height: 2.55, height: 2.55,
@@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => {
}) })
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(), storeyHeight: 3,
slabs: [slab(0.4)],
}) })
expect(plan.update).toHaveLength(1) // The sync no longer re-derives auto heights; a user-set explicit
expect(plan.update[0]?.id).toBe(ceiling.id) // height survives (still under the bound, so no clamp either).
expect(plan.update[0]?.data.polygon).toBeUndefined() expect(plan.update).toHaveLength(0)
expect(plan.update[0]?.data.height).toBeCloseTo(2.9)
})
test('updates existing auto ceiling height when wall height changes', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(3),
slabs: [slab(0.05)],
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.data.height).toBeCloseTo(3.05)
}) })
test('does not replace a manual ceiling with an auto ceiling', () => { test('does not replace a manual ceiling with an auto ceiling', () => {
@@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => {
autoFromWalls: false, autoFromWalls: false,
}) })
// Storey plane above the stored 2.5 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], {
walls: squareWalls(), storeyHeight: 2.7,
slabs: [slab(0.4)],
}) })
expect(plan.create).toHaveLength(0) expect(plan.create).toHaveLength(0)
@@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => {
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data }) const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data })
expect(demoted.autoFromWalls).toBe(false) expect(demoted.autoFromWalls).toBe(false)
// Storey plane above the stored 2.55 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], { const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], {
walls: squareWalls(), storeyHeight: 2.7,
slabs: [slab(0.05)],
}) })
expect(plan.create).toHaveLength(0) expect(plan.create).toHaveLength(0)
@@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => {
}) })
}) })
// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper
// level's plane) covers the queried level below, so the clamp bound is
// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck).
function stackedDeckNodes(): Record<AnyNodeId, AnyNode> {
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const list: AnyNode[] = [
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_1',
level: 1,
height: 2.5,
parentId: 'building_a',
children: ['slab_deck'],
}),
deck,
]
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('stage 3-B ceiling clamp bound', () => {
test('height-less auto ceilings resolve under the covering-slab bound at read time', () => {
const nodes = stackedDeckNodes()
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
}).create[0]
expect(created).toBeDefined()
expect('height' in created!).toBe(false)
// Follows-mode: the effective height is the deck-limited bound.
expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19)
})
test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.49)
})
test('never raises a manual ceiling sitting below the bound', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(0)
})
test('skips follows-mode manual ceilings (never converts them to explicit)', () => {
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.update).toHaveLength(0)
})
test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => {
// Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck
// bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01.
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.height).toBeCloseTo(2.19)
})
})
// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped
// scene store (getState/subscribe/temporal) whose write methods mutate the
// nodes record and re-notify, and an editor store carrying `spaces`.
function createSceneStoreStub(initialNodes: Record<string, AnyNode>) {
const listeners = new Set<(state: unknown) => void>()
const state: Record<string, unknown> & { nodes: Record<string, AnyNode> } = {
nodes: initialNodes,
}
const notify = () => {
for (const listener of [...listeners]) listener(state)
}
state.updateNodes = (updates: Array<{ id: string; data: Record<string, unknown> }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { id, data } of updates) {
const existing = next[id]
if (existing) next[id] = { ...existing, ...data } as AnyNode
}
state.nodes = next
notify()
}
state.deleteNodes = (ids: string[]) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const id of ids) delete next[id]
state.nodes = next
notify()
}
state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { node, parentId } of entries) {
next[node.id] = { ...node, parentId } as AnyNode
const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined
if (parent) {
next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode
}
}
state.nodes = next
notify()
}
return {
getState: () => state,
subscribe: (listener: (state: unknown) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
temporal: { getState: () => ({ pause() {}, resume() {} }) },
setNodes(next: Record<string, AnyNode>) {
state.nodes = next
notify()
},
}
}
function createEditorStoreStub() {
const state = {
spaces: {} as Record<string, unknown>,
setSpaces(next: Record<string, unknown>) {
state.spaces = next
},
}
return { getState: () => state }
}
describe('reactive ceiling re-clamp through the detection sync', () => {
test('a flush deck created on the level above clamps the existing manual ceiling below', () => {
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }),
WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }),
WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }),
WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }),
]
const manualCeiling = CeilingNode.parse({
id: 'ceiling_main',
parentId: 'level_0',
polygon: square,
height: 2.49,
autoFromWalls: false,
})
const initialNodes = Object.fromEntries(
[
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({
id: 'level_0',
level: 0,
height: 2.5,
parentId: 'building_a',
children: [...walls.map((wall) => wall.id), 'ceiling_main'],
}),
LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }),
...walls,
manualCeiling,
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const sceneStore = createSceneStoreStub(initialNodes)
const editorStore = createEditorStoreStub()
const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore)
try {
// Scenario gate 11's reactive half: the deck lands on the level
// ABOVE, so only the covering-underside part of level_0's structure
// snapshot changes — the sync must still re-run and clamp down.
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const current = sceneStore.getState().nodes
const levelAbove = current.level_1 as AnyNode
sceneStore.setNodes({
...current,
slab_deck: deck,
level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode,
})
const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode
expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01)
} finally {
unsubscribe()
}
})
})
describe('detectSpacesForLevel', () => { describe('detectSpacesForLevel', () => {
const areaOf = (polygon: Array<{ x: number; y: number }>) => { const areaOf = (polygon: Array<{ x: number; y: number }>) => {
let area = 0 let area = 0
+112 -112
View File
@@ -2,12 +2,21 @@ import {
type AnyNodeId, type AnyNodeId,
CeilingNode, CeilingNode,
type CeilingNode as CeilingNodeType, type CeilingNode as CeilingNodeType,
type LevelNode,
SlabNode, SlabNode,
type SlabNode as SlabNodeType, type SlabNode as SlabNodeType,
type WallNode, type WallNode,
ZoneNode, ZoneNode,
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '../schema' } from '../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
import {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
getCeilingClampBound,
getLevelElevations,
getStoredLevelHeight,
} from '../services/storey'
import { import {
getSceneHistoryPauseDepth, getSceneHistoryPauseDepth,
pauseSceneHistory, pauseSceneHistory,
@@ -56,10 +65,6 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf> bbox: ReturnType<typeof bboxOf>
} }
type DetectedCeilingRoom = DetectedRoom & {
ceilingHeight: number
}
export type AutoSlabSyncPlan = { export type AutoSlabSyncPlan = {
create: SlabNodeType[] create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }> update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
@@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = {
} }
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6 const CEILING_HEIGHT_EPSILON = 1e-6
const ROOM_CURVE_TOLERANCE = 0.04 const ROOM_CURVE_TOLERANCE = 0.04
const MAX_CURVE_SUBDIVISION_DEPTH = 6 const MAX_CURVE_SUBDIVISION_DEPTH = 6
@@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6
const COVERAGE_SAMPLE_STEPS = 12 const COVERAGE_SAMPLE_STEPS = 12
// Auto ceilings are created height-less (follows-mode: they track the
// clamp bound live through `resolveCeilingHeight`), so the planner needs
// no wall/slab inputs anymore — only the bound for the explicit-height
// reactive re-clamp below.
export type AutoCeilingPlanningContext = { export type AutoCeilingPlanningContext = {
walls?: WallNode[] /** Stored storey height of the level being planned (floor-to-floor). */
slabs?: SlabNodeType[] storeyHeight?: number
/**
* Stage 3-B clamp-bound resolver for a polygon on the planned level:
* `min(storey plane, lowest covering-slab underside from the level
* above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent
* (pure-planner callers without a nodes record), the bound degrades to
* the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`.
*/
ceilingClampBound?: (polygon: Array<[number, number]>) => number
} }
function pointFromTuple(point: [number, number]): Point2D { function pointFromTuple(point: [number, number]): Point2D {
@@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) {
return matchingPoints.length >= 2 return matchingPoints.length >= 2
} }
function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { /**
if (slab.polygon.length < 3) return false * The clamp bound for a ceiling polygon under this planning context —
const slabPolygon = slab.polygon.map(pointFromTuple) * the context's cross-level resolver when provided, else the plane-only
if (!pointInPolygon(point, slabPolygon)) return false * `storeyHeight - CEILING_CLAMP_MARGIN` degradation.
*/
for (const hole of slab.holes ?? []) { function resolveCeilingClampBound(
if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { polygon: Array<[number, number]>,
return false context: AutoCeilingPlanningContext,
}
}
return true
}
function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
return true
}
return pointIsOnSlab(polygonCentroid(roomPolygon), slab)
}
function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) {
let maxElevation = 0
for (const slab of slabs) {
if (!slabSupportsRoom(roomPolygon, slab)) continue
maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION)
}
return maxElevation
}
function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) {
let maxHeight = 0
for (const wall of walls) {
if (!wallBoundsRoom(wall, roomPolygon)) continue
const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT
if (Number.isFinite(height)) {
maxHeight = Math.max(maxHeight, height)
}
}
return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT
}
function resolveAutoCeilingHeight(
roomPolygon: Point2D[],
context: AutoCeilingPlanningContext = {},
) { ) {
return ( if (context.ceilingClampBound) return context.ceilingClampBound(polygon)
resolveRoomSlabElevation(roomPolygon, context.slabs) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN
resolveRoomWallHeight(roomPolygon, context.walls)
)
} }
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) { function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
@@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) {
wall.end[0].toFixed(4), wall.end[0].toFixed(4),
wall.end[1].toFixed(4), wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4), (wall.thickness ?? 0.2).toFixed(4),
(wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), // Plane-bound (no stored height) is a distinct state, not a default
// value: it resolves to the storey plane, so it must not alias an
// explicit height of the same magnitude in the trigger signature.
wall.height == null ? 'plane' : wall.height.toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4), getClampedWallCurveOffset(wall).toFixed(4),
].join('|') ].join('|')
} }
@@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) {
].join('|') ].join('|')
} }
// Slabs and ceilings stay out of the trigger signature: including generated // Slab/ceiling POLYGONS stay out of the trigger signature: including
// surfaces caused delete/recreate feedback. Zones are included only so a newly // generated footprints caused delete/recreate feedback. Zones are included
// traced room footprint can adopt its enclosing walls without waiting for the // only so a newly traced room footprint can adopt its enclosing walls
// next remodel. // without waiting for the next remodel. Slab ELEVATIONS and the level's
// stored storey height ARE included — both feed the explicit-ceiling
// re-clamp bound (the storey plane), and neither is rewritten by
// the sync, so regeneration triggers when they change without feedback.
// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation
// thickness, recessed pools excluded): a deck created, lowered, or
// thickened above must re-run the sync below so ceilings re-clamp under
// it. Same polygon exclusion applies — the level-above's own auto sync
// rewrites its slab footprints, and hashing them here would re-trigger
// this level on every remodel above.
function levelStructureSnapshots(nodes: Record<string, any>) { function levelStructureSnapshots(nodes: Record<string, any>) {
const wallsByLevel = new Map<string, WallNode[]>() const wallsByLevel = new Map<string, WallNode[]>()
const zonesByLevel = new Map<string, ZoneNodeType[]>() const zonesByLevel = new Map<string, ZoneNodeType[]>()
const slabElevationsByLevel = new Map<string, string[]>()
const coveringUndersidesByLevel = new Map<string, string[]>()
for (const node of Object.values(nodes)) { for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
@@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record<string, any>) {
const zones = zonesByLevel.get(levelId) ?? [] const zones = zonesByLevel.get(levelId) ?? []
zones.push(ZoneNode.parse(node)) zones.push(ZoneNode.parse(node))
zonesByLevel.set(levelId, zones) zonesByLevel.set(levelId, zones)
} else if ((node as any).type === 'slab') {
const elevations = slabElevationsByLevel.get(levelId) ?? []
elevations.push(
`${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`,
)
slabElevationsByLevel.set(levelId, elevations)
if ((node as any).recessed !== true) {
const undersides = coveringUndersidesByLevel.get(levelId) ?? []
const elevation = ((node as any).elevation as number | undefined) ?? 0.05
const thickness = ((node as any).thickness as number | undefined) ?? 0.05
undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`)
coveringUndersidesByLevel.set(levelId, undersides)
}
} }
} }
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, any>)
const snapshots = new Map<string, string>() const snapshots = new Map<string, string>()
const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()])
for (const levelId of levelIds) { for (const levelId of levelIds) {
const walls = wallsByLevel.get(levelId) ?? [] const walls = wallsByLevel.get(levelId) ?? []
const zones = zonesByLevel.get(levelId) ?? [] const zones = zonesByLevel.get(levelId) ?? []
const level = nodes[levelId]
const storeyKey =
level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : ''
const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';')
const aboveId = findLevelAboveId(levelId, levelElevations)
const aboveSlabKey = aboveId
? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';')
: ''
snapshots.set( snapshots.set(
levelId, levelId,
`${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`, `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`,
) )
} }
@@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel(
return plan return plan
} }
export function projectAutoSlabsForPlan(
existingSlabs: SlabNodeType[],
plan: AutoSlabSyncPlan,
): SlabNodeType[] {
const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]))
for (const id of plan.delete) {
slabsById.delete(id)
}
for (const update of plan.update) {
const slab = slabsById.get(update.id)
if (!slab) continue
slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }))
}
for (const slab of plan.create) {
slabsById.set(slab.id, slab)
}
return [...slabsById.values()]
}
export function planAutoCeilingsForLevel( export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[], existingCeilings: CeilingNodeType[],
@@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel(
) )
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple)) const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple))
const detectedAll: DetectedCeilingRoom[] = roomPolygons const detectedAll: DetectedRoom[] = roomPolygons
.map((poly) => ({ .map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple, pointFromTuple,
@@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel(
centroid: polygonCentroid(room.poly), centroid: polygonCentroid(room.poly),
area: Math.abs(polygonArea(room.poly)), area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly), bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
})) }))
const detected = detectedAll.filter( const detected = detectedAll.filter(
@@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set<string>() const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>() const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>() const updatesById = new Map<string, { polygon: [number, number][] }>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>() const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) { for (const entry of existingAutoMeta) {
@@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(existing.ceiling.id) matchedCeilingIds.add(existing.ceiling.id)
updatesById.set(existing.ceiling.id, { updatesById.set(existing.ceiling.id, {
polygon: room.poly.map(pointToTuple), polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
}) })
}) })
@@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(bestMatch.entry.ceiling.id) matchedCeilingIds.add(bestMatch.entry.ceiling.id)
updatesById.set(bestMatch.entry.ceiling.id, { updatesById.set(bestMatch.entry.ceiling.id, {
polygon: room.poly.map(pointToTuple), polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
}) })
} }
@@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel(
} }
} }
// Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
// created, moved, or thickened on the level above can leave an EXISTING
// manual explicit-height ceiling poking into its solid. Clamp explicit
// heights down to the bound; never raise them — a user-lowered ceiling
// is intent, only an over-bound one is a conflict. Follows-mode
// ceilings (absent height) derive under the bound by construction and
// are skipped, so the clamp can never convert one to an explicit
// height.
const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => {
if (ceiling.height == null) return []
const bound = resolveCeilingClampBound(ceiling.polygon, context)
if (!Number.isFinite(bound)) return []
return ceiling.height > bound + CEILING_HEIGHT_EPSILON
? [{ id: ceiling.id, data: { height: bound } }]
: []
})
const ceilingsToUpdate = [ const ceilingsToUpdate = [
// Auto ceilings only track their room's POLYGON here — their height is
// follows-mode (absent) and derives from the level top at read time.
...existingAuto ...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id)) .filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => { .flatMap((ceiling) => {
const update = updatesById.get(ceiling.id) const update = updatesById.get(ceiling.id)
if (!update) return [] if (!update) return []
if (sameTuplePolygon(ceiling.polygon, update.polygon)) return []
const data: Partial<CeilingNodeType> = {} return [{ id: ceiling.id, data: { polygon: update.polygon } }]
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
}), }),
...ceilingDemotions, ...ceilingDemotions,
...manualClamps,
] ]
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
@@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel(
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
plannedCeilingsForNaming.push({ name }) plannedCeilingsForNaming.push({ name })
// Height-less on purpose: auto ceilings follow the level top (the
// clamp bound) through `resolveCeilingHeight` instead of baking a
// derived height that would go stale on level-height edits.
ceilingsToCreate.push( ceilingsToCreate.push(
CeilingNode.parse({ CeilingNode.parse({
name, name,
polygon: room.poly.map(pointToTuple), polygon: room.poly.map(pointToTuple),
holes: [], holes: [],
height: room.ceilingHeight,
autoFromWalls: true, autoFromWalls: true,
}), }),
) )
@@ -1402,14 +1395,21 @@ function runSpaceDetection(
} }
const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) const levelNode = nodes[levelId]
const storeyHeight =
levelNode?.type === 'level'
? getStoredLevelHeight(levelNode as LevelNode)
: DEFAULT_LEVEL_HEIGHT
syncAutoCeilingsForLevel( syncAutoCeilingsForLevel(
levelId, levelId,
roomPolygons, roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore, sceneStore,
{ walls, slabs: projectedSlabs }, {
storeyHeight,
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
},
) )
const zonePlan = planAutoZonesForLevel( const zonePlan = planAutoZonesForLevel(
spaces, spaces,
@@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record<string, AnyNode> {
function roomNodes() { function roomNodes() {
const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon }) const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon })
const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon }) const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon }) const ceiling = CeilingNode.parse({
id: 'ceiling_room',
parentId: 'level_main',
polygon,
height: 2.5,
})
const walls = polygon.map((start, index) => const walls = polygon.map((start, index) =>
WallNode.parse({ WallNode.parse({
id: `wall_${index}`, id: `wall_${index}`,
+15 -4
View File
@@ -1,6 +1,11 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNodeId } from '../schema/types'
import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height'
import { getWallPlaneTop } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { sampleWallCenterline } from '../systems/wall/wall-curve' import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../systems/wall/wall-top'
import { detectSpacesForLevel, type Space } from './space-detection' import { detectSpacesForLevel, type Space } from './space-detection'
type Point2D = readonly [number, number] type Point2D = readonly [number, number]
@@ -466,13 +471,19 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport( export function deriveZoneQuantityReport(
zone: ZoneNode, zone: ZoneNode,
sceneNodes: Record<string, AnyNode>, sceneNodes: Readonly<Record<string, AnyNode>>,
): ZoneQuantityReport { ): ZoneQuantityReport {
const levelId = zone.parentId const levelId = zone.parentId
const levelNodes = levelId const levelNodes = levelId
? Object.values(sceneNodes).filter((node) => node.parentId === levelId) ? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
: [] : []
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall') const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab')
const wallEffectiveHeight = (wall: WallNode) => {
const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId)
const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT
return resolveWallEffectiveHeight(wall, planeTop, support.elevation)
}
const edgeLengths = zone.polygon.map((start, index) => { const edgeLengths = zone.polygon.map((start, index) => {
const end = zone.polygon[(index + 1) % zone.polygon.length] const end = zone.polygon[(index + 1) % zone.polygon.length]
return end ? pointDistance(start, end) : 0 return end ? pointDistance(start, end) : 0
@@ -488,7 +499,7 @@ export function deriveZoneQuantityReport(
const ceilingCoverage = proveSurfaceCoverage( const ceilingCoverage = proveSurfaceCoverage(
zone, zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'), levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
(node) => node.height, (node) => resolveCeilingHeight(node, sceneNodes as Record<AnyNodeId, AnyNode>),
{ singular: 'ceiling', plural: 'Ceilings', datum: 'heights' }, { singular: 'ceiling', plural: 'Ceilings', datum: 'heights' },
) )
@@ -517,7 +528,7 @@ export function deriveZoneQuantityReport(
? { ? {
status: 'available' as const, status: 'available' as const,
value: wallSpans!.reduce( value: wallSpans!.reduce(
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT), (sum, span) => sum + span.length * wallEffectiveHeight(span.wall),
0, 0,
), ),
note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.', note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
+58 -2
View File
@@ -4,10 +4,14 @@ import {
MaterialTarget as MaterialTargetSchema, MaterialTarget as MaterialTargetSchema,
} from './schema/material' } from './schema/material'
export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace'
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
id: string id: string
label: string label: string
category: MaterialCategory category: MaterialCategory
/** Origin of the entry. Absent = 'pascal' (all static catalog entries). */
source?: MaterialSource
/** /**
* Where this finish is appropriate. Absent = universal (e.g. flat colors). * Where this finish is appropriate. Absent = universal (e.g. flat colors).
* The paint picker may filter by the slot being painted; v1 shows everything. * The paint picker may filter by the slot being painted; v1 shows everything.
@@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [
'roofing', 'roofing',
'ground', 'ground',
'glass', 'glass',
'other',
] as const ] as const
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
@@ -4149,13 +4154,64 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
}, },
] ]
const STATIC_CATALOG_IDS = new Set(MATERIAL_CATALOG.map((item) => item.id))
// Embedder-registered library materials (user/community/workspace). Core stays
// passive: hosts push entries in; nothing here fetches. Static catalog entries
// win on id collision so a registration can never shadow a built-in.
const dynamicLibraryMaterials = new Map<string, MaterialCatalogItem>()
const dynamicLibraryListeners = new Set<() => void>()
let dynamicLibraryVersion = 0
function notifyDynamicLibraryChange(): void {
dynamicLibraryVersion += 1
for (const listener of [...dynamicLibraryListeners]) {
listener()
}
}
export function registerLibraryMaterials(items: MaterialCatalogItem[]): void {
if (items.length === 0) return
for (const item of items) {
dynamicLibraryMaterials.set(item.id, item)
}
notifyDynamicLibraryChange()
}
export function unregisterLibraryMaterials(ids: string[]): void {
let changed = false
for (const id of ids) {
changed = dynamicLibraryMaterials.delete(id) || changed
}
if (changed) notifyDynamicLibraryChange()
}
export function getDynamicLibraryMaterials(): MaterialCatalogItem[] {
return [...dynamicLibraryMaterials.values()]
}
export function subscribeLibraryMaterials(listener: () => void): () => void {
dynamicLibraryListeners.add(listener)
return () => {
dynamicLibraryListeners.delete(listener)
}
}
export function getLibraryMaterialsVersion(): number {
return dynamicLibraryVersion
}
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.category === category) const items = MATERIAL_CATALOG.filter((item) => item.category === category)
for (const item of dynamicLibraryMaterials.values()) {
if (item.category === category && !STATIC_CATALOG_IDS.has(item.id)) items.push(item)
}
return items
} }
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
if (!id) return undefined if (!id) return undefined
return MATERIAL_CATALOG.find((item) => item.id === id) return MATERIAL_CATALOG.find((item) => item.id === id) ?? dynamicLibraryMaterials.get(id)
} }
export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
+3
View File
@@ -65,6 +65,8 @@ export type {
Capabilities, Capabilities,
CapabilityCtx, CapabilityCtx,
CuttableConfig, CuttableConfig,
DimensionTerminator,
DimensionTextPosition,
DistributionRole, DistributionRole,
DragAction, DragAction,
DuplicableConfig, DuplicableConfig,
@@ -88,6 +90,7 @@ export type {
FloorplanPoint, FloorplanPoint,
FloorplanStyle, FloorplanStyle,
GeometryContext, GeometryContext,
GroupMoveSnapArgs,
HostableConfig, HostableConfig,
IconRef, IconRef,
Issue, Issue,
+110 -1
View File
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
) { ) {
const anchor = clonedMeasurement.measurement.points[0] const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false) expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) { if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
} }
} }
}) })
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
parentId: 'level_1',
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[1, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
fallback: [2, 0, 0],
},
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const result = cloneNodesInto([wall, dimension], {
rootId: 'wall_1' as AnyNodeId,
})
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
expect(clonedDimension?.type).toBe('construction-dimension')
if (clonedDimension?.type === 'construction-dimension') {
const anchor = clonedDimension.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
const lastAnchor = clonedDimension.anchors[2]
expect(Array.isArray(lastAnchor)).toBe(false)
if (lastAnchor && !Array.isArray(lastAnchor)) {
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps a construction dimension foundation controller when both are cloned', () => {
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
drawingType: 'foundation-plan',
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
controllingDimensionId: controller.id,
})
const result = cloneNodesInto([controller, dependent], {
rootId: controller.id as AnyNodeId,
})
const clonedDependent = result.nodes.find(
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
)
expect(clonedDependent?.type).toBe('construction-dimension')
if (clonedDependent?.type === 'construction-dimension') {
expect(clonedDependent.controllingDimensionId).toBe(
result.idMap.get(
controller.id as AnyNodeId,
) as typeof clonedDependent.controllingDimensionId,
)
}
})
test('regenerates drawing-sheet identities while preserving external level references', () => {
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
const cloned = nodes[0]
expect(cloned?.type).toBe('drawing-sheet')
if (cloned?.type === 'drawing-sheet') {
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
}
})
test('parents the cloned root under opts.parentId when supplied', () => { test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' }) const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], { const { nodes } = cloneNodesInto([orig], {
+12 -2
View File
@@ -1,5 +1,9 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry' import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import { generateId } from '../schema/base' import { generateId } from '../schema/base'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement // Generic, opinion-free primitives the host app composes to implement
@@ -141,7 +145,7 @@ export function cloneNodesInto(
const out: AnyNode[] = [] const out: AnyNode[] = []
let root: AnyNode | null = null let root: AnyNode | null = null
for (const original of nodes) { for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)! const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId ;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved // parentId: root's parentId becomes opts.parentId (or preserved
@@ -169,6 +173,12 @@ export function cloneNodesInto(
if (cloned.type === 'measurement') { if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
} }
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
if (original.id === opts.rootId) { if (original.id === opts.rootId) {
if (opts.position) { if (opts.position) {
+62 -1
View File
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs. * `scene:` refs.
*/ */
materials?: Record<SceneMaterialId, SceneMaterial> materials?: Record<SceneMaterialId, SceneMaterial>
/** Opaque host/plugin context. Core never interprets extension values. */
extensions?: Readonly<Record<string, unknown>>
/** /**
* Optional view state — only populated for `def.floorplan` builders. The * Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary * 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
export type FloorplanPoint = readonly [x: number, y: number] export type FloorplanPoint = readonly [x: number, y: number]
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
export type DimensionTextPosition = 'above' | 'centered'
export type FloorplanStyle = { export type FloorplanStyle = {
stroke?: string stroke?: string
fill?: string fill?: string
strokeWidth?: number strokeWidth?: number
strokeDasharray?: string strokeDasharray?: string
opacity?: number opacity?: number
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
/** /**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps * as a constant screen-pixel width regardless of viewport zoom. Maps
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
* of the floor-plan's scene rotation (default 90°). * of the floor-plan's scene rotation (default 90°).
*/ */
upright?: boolean upright?: boolean
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
} }
/** /**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated * Bitmap overlay — captured top-down asset thumbnail, AI-generated
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
children: FloorplanGeometry[] children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */ /** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number } transform?: { translate?: FloorplanPoint; rotate?: number }
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
} }
/** /**
* Hatched fill overlay — same polygon shape as the kind's main fill but * Hatched fill overlay — same polygon shape as the kind's main fill but
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
kind: 'dimension' kind: 'dimension'
start: FloorplanPoint start: FloorplanPoint
end: FloorplanPoint end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
/** Outward-pointing unit normal — the dimension line offsets along this. */ /** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */ /** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number offsetDistance: number
/** How far past the offset point the extension line continues. */ /** How far past the offset point the extension line continues. */
extensionOvershoot: number extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position relative to the baseline. Defaults above the line. */
textPosition?: DimensionTextPosition
text: string text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */ /** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string stroke?: string
} }
| {
kind: 'dimension-string'
segments: readonly {
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}[]
/** Outward-pointing unit normal shared by every segment in the string. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from each measured origin to its dimension line. */
offsetDistance: number
/** How far past each offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position shared by every segment. Defaults above the line. */
textPosition?: DimensionTextPosition
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
// ─── FloorplanAffordance ───────────────────────────────────────────── // ─── FloorplanAffordance ─────────────────────────────────────────────
// //
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schemaVersion: number schemaVersion: number
schema: S schema: S
category: NodeCategory category: NodeCategory
/** Opaque host/plugin contributions. Core stores but never interprets them. */
extensions?: Readonly<Record<string, unknown>>
surfaceRole?: SurfaceRole surfaceRole?: SurfaceRole
/** /**
* Show a floor direction-triangle while placing/moving — the kind has a * Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
portConnectivityFollow?: boolean portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'> defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities capabilities: Capabilities
relations?: Relations relations?: Relations
+65 -3
View File
@@ -51,8 +51,33 @@ export {
ColumnStyle, ColumnStyle,
ColumnSupportStyle, ColumnSupportStyle,
} from './nodes/column' } from './nodes/column'
export {
CONSTRUCTION_DRAWING_TYPES,
ConstructionDimensionBaseline,
ConstructionDimensionChainMode,
ConstructionDimensionDatumPolicy,
ConstructionDimensionDrawingOverride,
ConstructionDimensionDrawingPresentation,
ConstructionDimensionImperialPrecision,
ConstructionDimensionMetricNotation,
ConstructionDimensionMode,
ConstructionDimensionNode,
ConstructionDimensionTerminator,
ConstructionDimensionTextPosition,
ConstructionDrawingType,
constructionDimensionRequiredAnchorCount,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './nodes/construction-dimension'
export { CupolaNode } from './nodes/cupola' export { CupolaNode } from './nodes/cupola'
export { DoorNode, DoorSegment } from './nodes/door' export {
DoorNode,
DoorSegment,
OpeningConstructionType,
OpeningDimensionReference,
} from './nodes/door'
export { export {
DormerNode, DormerNode,
type DormerSurfaceMaterialRole, type DormerSurfaceMaterialRole,
@@ -60,6 +85,25 @@ export {
getEffectiveDormerSurfaceMaterial, getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer' } from './nodes/dormer'
export { DownspoutNode } from './nodes/downspout' export { DownspoutNode } from './nodes/downspout'
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
remapDrawingSheetReferences,
} from './nodes/drawing-sheet'
export { DuctFittingNode } from './nodes/duct-fitting' export { DuctFittingNode } from './nodes/duct-fitting'
export { DuctSegmentNode } from './nodes/duct-segment' export { DuctSegmentNode } from './nodes/duct-segment'
export { DuctTerminalNode } from './nodes/duct-terminal' export { DuctTerminalNode } from './nodes/duct-terminal'
@@ -184,7 +228,7 @@ export {
SkylightType, SkylightType,
type SkylightTypePreset, type SkylightTypePreset,
} from './nodes/skylight' } from './nodes/skylight'
export { SlabNode } from './nodes/slab' export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab'
export { export {
SolarPanelMaterialRole, SolarPanelMaterialRole,
SolarPanelNode, SolarPanelNode,
@@ -200,9 +244,13 @@ export {
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent' export { TurbineVentNode } from './nodes/turbine-vent'
export type { export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallBandSurfaceSlotId, WallBandSurfaceSlotId,
WallFaceBand, WallFaceBand,
WallFaceBandConfig, WallFaceBandConfig,
@@ -215,11 +263,18 @@ export {
buildEnabledWallFaceBandPatch, buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch, buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallBandSlotId, getWallBandSlotId,
getWallDatumEligibleLayers,
getWallFaceBandConfig, getWallFaceBandConfig,
getWallFaceBandForHeight, getWallFaceBandForHeight,
getWallSurfaceMaterialSignature, getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot, getWallSurfaceSideFromBandSlot,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT, WALL_CROWN_DEFAULT,
@@ -230,11 +285,18 @@ export {
WALL_SLOT_DEFAULT, WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS, WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS, WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode, WallNode,
WallTreatmentSide, WallTreatmentSide,
WallTrimProfile, WallTrimProfile,
} from './nodes/wall' } from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window' export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
WindowType,
} from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
+5 -2
View File
@@ -1,13 +1,16 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { DrawingSheetNode } from './drawing-sheet'
import { ElevatorNode } from './elevator' import { ElevatorNode } from './elevator'
import { LevelNode } from './level' import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({ export const BuildingNode = BaseNode.extend({
id: objectId('building'), id: objectId('building'),
type: nodeType('building'), type: nodeType('building'),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), children: z
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
.default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe( }).describe(
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building Building node - used to represent a building
- position: position in site coordinate system - position: position in site coordinate system
- rotation: rotation in site coordinate system - rotation: rotation in site coordinate system
- children: array of level nodes and building-level systems such as elevators - children: array of level nodes, building-level systems such as elevators, and drawing sheets
`, `,
) )
@@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = { const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
width: z.number().min(0.05).max(3).default(0.5), width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.5), depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72), carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
+7 -1
View File
@@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]),
height: z.number().default(2.5), // Height in meters // Height in meters. Absent = the ceiling follows the level top: its
// effective height is the same bound its write-clamp uses —
// min(storey plane, lowest covering-slab underside over the polygon)
// CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an
// explicit custom height, still write-clamped under that bound.
height: z.number().optional(),
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
dedent` dedent`
@@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({
- polygon: array of [x, z] points defining the ceiling boundary - polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling - holes: array of polygons representing holes in the ceiling
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- height: explicit height in meters; absent = follows the level top automatically
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`, `,
) )
+2
View File
@@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({
type: nodeType('column'), type: nodeType('column'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
style: ColumnStyle.default('plain'), style: ColumnStyle.default('plain'),
crossSection: ColumnCrossSection.default('round'), crossSection: ColumnCrossSection.default('round'),
height: z.number().positive().default(2.5), height: z.number().positive().default(2.5),
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
ConstructionDimensionNode,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './construction-dimension'
describe('ConstructionDimensionNode', () => {
test('creates valid free-anchor defaults', () => {
const node = ConstructionDimensionNode.parse({})
expect(node.type).toBe('construction-dimension')
expect(node.id).toMatch(/^construction-dimension_/)
expect(node.anchors).toEqual([
[0, 0, 0],
[1, 0, 0],
])
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
expect(node.chainMode).toBe('point-to-point')
expect(node).toMatchObject({
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
})
})
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[3, 0, 0],
],
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({
baseline: { origin: [0, 0], direction: [0, 0] },
}).success,
).toBe(false)
})
test('accepts continuous strings with three or more anchors', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
chainMode: 'continuous',
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
.success,
).toBe(false)
})
test('accepts curved and circular notation settings', () => {
expect(
ConstructionDimensionNode.safeParse({
mode: 'diameter',
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
}).success,
).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
})
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
const node = ConstructionDimensionNode.parse({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(node).toMatchObject({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
})
test('coordinates one associative dimension across persistent drawing types', () => {
const node = ConstructionDimensionNode.parse({
drawingType: 'foundation-plan',
drawingOverrides: [
{ drawingType: 'floor-plan', presentation: 'controlled' },
{ drawingType: 'roof-plan', presentation: 'shown' },
],
controllingDimensionId: 'construction-dimension_foundation',
})
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
})
test('stores only drawing presentations that differ from the primary defaults', () => {
const node = ConstructionDimensionNode.parse({})
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
expect(shown).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
])
expect(
setConstructionDimensionDrawingPresentation(
{ ...node, drawingOverrides: shown },
'roof-plan',
'omit',
),
).toEqual([])
})
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
const node = ConstructionDimensionNode.parse({})
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
node,
'floor-plan',
[3, 1, 1, -1],
)
expect(drawingOverrides).toEqual([
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1, 3],
},
])
expect(
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
?.suppressedSegmentIndexes,
).toEqual([1, 3])
expect(
setConstructionDimensionDrawingSuppressedSegments(
{ ...node, drawingOverrides },
'floor-plan',
[],
),
).toEqual([])
})
})
@@ -0,0 +1,205 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MeasurementAnchor } from './measurement'
const FiniteCoordinate = z.number().finite()
export const ConstructionDimensionBaseline = z
.object({
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
})
.superRefine((baseline, ctx) => {
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['direction'],
message: 'Construction dimension baseline direction must be non-zero',
})
}
})
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
export const ConstructionDimensionMode = z.enum([
'linear',
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
])
export const ConstructionDrawingType = z.enum([
'floor-plan',
'foundation-plan',
'reflected-ceiling-plan',
'roof-plan',
'site-plan',
])
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
export const ConstructionDimensionDrawingOverride = z.object({
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
})
export const ConstructionDimensionDatumPolicy = z.enum([
'centerline',
'wall-face',
'structural-face',
'finish-face',
])
export const ConstructionDimensionTerminator = z.enum([
'architectural-tick',
'filled-arrow',
'open-arrow',
'dot',
])
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
export const ConstructionDimensionNode = BaseNode.extend({
id: objectId('construction-dimension'),
type: nodeType('construction-dimension'),
anchors: z
.array(MeasurementAnchor)
.min(2)
.default([
[0, 0, 0],
[1, 0, 0],
]),
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
mode: ConstructionDimensionMode.default('linear'),
featureCount: z.number().int().min(1).max(999).default(1),
showCenterMark: z.boolean().default(true),
prefix: z.string().max(40).default(''),
suffix: z.string().max(40).default(''),
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
textPosition: ConstructionDimensionTextPosition.default('above'),
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
}).describe(
dedent`
Construction dimension node - an associative floor-plan construction dimension
- anchors: two or more free or semantic feature anchors that supply the witness origins
- baseline.origin: a point on the independently placed dimension line
- baseline.direction: the fixed plan direction used to project the witness origins
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
- showCenterMark: displays the resolved circle/angle center where applicable
- prefix/suffix/textOverride: document notation overrides without changing geometry
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
- drawingType: the primary persistent drawing that owns the dimension
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
`,
)
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
export type ConstructionDimensionDrawingPresentation = z.infer<
typeof ConstructionDimensionDrawingPresentation
>
export type ConstructionDimensionDrawingOverride = z.infer<
typeof ConstructionDimensionDrawingOverride
>
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
export type ConstructionDimensionImperialPrecision = z.infer<
typeof ConstructionDimensionImperialPrecision
>
export type ConstructionDimensionMetricNotation = z.infer<
typeof ConstructionDimensionMetricNotation
>
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
export function resolveConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingPresentation {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
}
export function resolveConstructionDimensionDrawingOverride(
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingOverride | null {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override ?? null
}
export function setConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = {
drawingType,
presentation,
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
}
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function setConstructionDimensionDrawingSuppressedSegments(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
suppressedSegmentIndexes: readonly number[],
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const presentation = existing?.presentation ?? defaultPresentation
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
}
function isDefaultConstructionDimensionDrawingOverride(
override: ConstructionDimensionDrawingOverride,
defaultPresentation: ConstructionDimensionDrawingPresentation,
): boolean {
return (
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
)
}
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
(left, right) => left - right,
)
}
+23
View File
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment> export type DoorSegment = z.infer<typeof DoorSegment>
export const DoorCategory = z.enum(['interior', 'garage']) export const DoorCategory = z.enum(['interior', 'garage'])
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
export const OpeningDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export const DoorType = z.enum([ export const DoorType = z.enum([
'hinged', 'hinged',
'double', 'double',
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
export type DoorCategory = z.infer<typeof DoorCategory> export type DoorCategory = z.infer<typeof DoorCategory>
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
export type DoorType = z.infer<typeof DoorType> export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle> export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9), width: z.number().default(0.9),
height: z.number().default(2.1), height: z.number().default(2.1),
// Construction-document identity. `mark` overrides the deterministic
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
// because they are manufacturer/framing inputs, not safe derivations from
// the nominal modeled size.
mark: z.string().trim().max(16).optional(),
constructionType: OpeningConstructionType.default('framed'),
dimensionReference: OpeningDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Door family // Door family
doorCategory: DoorCategory.default('interior'), doorCategory: DoorCategory.default('interior'),
doorType: DoorType.default('hinged'), doorType: DoorType.default('hinged'),
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode } from './building'
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
describe('DrawingSheetNode', () => {
test('creates persistent sheet defaults', () => {
const sheet = DrawingSheetNode.parse({})
expect(sheet.type).toBe('drawing-sheet')
expect(sheet.id).toMatch(/^drawing-sheet_/)
expect(sheet).toMatchObject({
sheetNumber: 'A1.0',
sheetTitle: 'Floor Plan',
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
annotationProfile: 'architectural-default',
placedViews: [],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [],
keyedNoteInstances: [],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
titleBlock: {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
},
})
})
test('stores placed views, notes, schedules, and title-block metadata', () => {
const sheet = DrawingSheetNode.parse({
sheetNumber: 'A2.1',
sheetTitle: 'Enlarged Plans',
paperSize: 'custom',
customPaperWidth: 24,
customPaperHeight: 36,
placedViews: [
{
id: 'drawing-view_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
levelId: 'level_main',
scale: '1/4"=1\'-0"',
viewport: { x: 1, y: 1, width: 12, height: 8 },
},
],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-slab-1',
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
},
{
id: 'keyed-note-instance_patch-slab-2',
definitionId: 'keyed-note_patch-slab',
position: [5, 4],
},
],
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
],
schedules: [
{
id: 'sheet-schedule_room',
scheduleType: 'room',
title: 'Room Schedule',
region: { x: 15, y: 1, width: 6, height: 5 },
},
],
titleBlock: {
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
},
})
expect(sheet.placedViews[0]).toMatchObject({
drawingType: 'floor-plan',
levelId: 'level_main',
annotationProfile: 'architectural-default',
showNorthArrow: true,
showGraphicScale: true,
})
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
expect(sheet.generalNoteSets[0]).toMatchObject({
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ text: 'COORDINATE WITH OWNER.' }],
})
expect(sheet.keyedNoteLegend[0]).toEqual({
key: 'A',
text: 'ALIGN WITH EXISTING WALL.',
})
expect(sheet.keyedNoteDefinitions[0]).toEqual({
id: 'keyed-note_patch-slab',
key: 'A',
text: 'PATCH EXISTING SLAB.',
})
expect(sheet.keyedNoteInstances).toHaveLength(2)
expect(sheet.keyedNoteInstances[0]).toMatchObject({
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
})
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
expect(sheet.documentMarkers).toHaveLength(2)
expect(sheet.documentMarkers[0]).toMatchObject({
kind: 'wall-tag',
label: 'W1',
position: [2, 3],
})
expect(sheet.documentMarkers[1]).toMatchObject({
kind: 'revision-cloud',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
})
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
expect(sheet.titleBlock).toMatchObject({
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
drawnBy: '',
})
})
test('can live under a building instead of a level', () => {
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
'level_main',
sheet.id,
])
})
test('remaps sheet-local identities and their references together', () => {
const sheet = DrawingSheetNode.parse({
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a1',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
},
],
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
schedules: [{ id: 'sheet-schedule_a' }],
})
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
sheet.generalNoteSets[0]?.notes[0]?.id,
)
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
})
})
@@ -0,0 +1,263 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, generateId, nodeType, objectId } from '../base'
import { ConstructionDrawingType } from './construction-dimension'
const PositiveFinite = z.number().finite().positive()
const SheetCoordinate = z.number().finite().min(0)
export const DrawingSheetPaperSize = z.enum([
'letter',
'tabloid',
'arch-a',
'arch-b',
'arch-c',
'a4',
'a3',
'custom',
])
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
export const DrawingSheetScale = z.enum([
'1:20',
'1:25',
'1:50',
'1:75',
'1:100',
'1/8"=1\'-0"',
'1/4"=1\'-0"',
'1/2"=1\'-0"',
'1"=1\'-0"',
])
export const DrawingSheetAnnotationProfile = z.enum([
'architectural-default',
'presentation',
'permit',
])
export const DrawingSheetRect = z.object({
x: SheetCoordinate.default(0),
y: SheetCoordinate.default(0),
width: PositiveFinite.default(1),
height: PositiveFinite.default(1),
})
export const DrawingSheetPlacedView = z.object({
id: objectId('drawing-view'),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingNumber: z.string().trim().min(1).max(24).default('1'),
title: z.string().trim().min(1).max(80).default('Floor Plan'),
levelId: objectId('level').nullable().default(null),
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
showNorthArrow: z.boolean().default(true),
showGraphicScale: z.boolean().default(true),
})
export const DrawingSheetGeneralNote = z.object({
id: objectId('sheet-note'),
number: z.number().int().positive().default(1),
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
})
export const DrawingSheetGeneralNoteSet = z.object({
id: objectId('sheet-note-set'),
name: z.string().trim().min(1).max(80).default('General Notes'),
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
})
export const DrawingSheetKeyedNote = z.object({
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteDefinition = z.object({
id: objectId('keyed-note'),
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteInstance = z.object({
id: objectId('keyed-note-instance'),
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
})
export const DrawingSheetDocumentMarkerKind = z.enum([
'wall-tag',
'glazing-tag',
'assembly-tag',
'section-callout',
'elevation-callout',
'detail-reference',
'delta-marker',
'revision-cloud',
])
export const DrawingSheetDocumentMarker = z.object({
id: objectId('sheet-marker'),
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
label: z.string().trim().min(1).max(32).default('1'),
title: z.string().trim().max(120).default(''),
sheetReference: z.string().trim().max(24).default(''),
drawingReference: z.string().trim().max(24).default(''),
revisionId: z.string().trim().max(16).default(''),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
points: z
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
.max(64)
.default([]),
})
export const DrawingSheetSchedulePlacement = z.object({
id: objectId('sheet-schedule'),
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
title: z.string().trim().min(1).max(80).default('Room Schedule'),
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
})
export const DrawingSheetTitleBlock = z.object({
projectName: z.string().trim().max(120).default(''),
projectNumber: z.string().trim().max(40).default(''),
clientName: z.string().trim().max(120).default(''),
drawnBy: z.string().trim().max(40).default(''),
checkedBy: z.string().trim().max(40).default(''),
issueDate: z.string().trim().max(40).default(''),
revision: z.string().trim().max(20).default(''),
})
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
}
export const DrawingSheetNode = BaseNode.extend({
id: objectId('drawing-sheet'),
type: nodeType('drawing-sheet'),
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
paperSize: DrawingSheetPaperSize.default('arch-b'),
orientation: DrawingSheetOrientation.default('landscape'),
customPaperWidth: PositiveFinite.nullable().default(null),
customPaperHeight: PositiveFinite.nullable().default(null),
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
}).describe(
dedent`
Drawing sheet node - persistent construction-document sheet metadata
- sheetNumber/sheetTitle: sheet identity in the drawing set
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
- schedules/titleBlock: sheet-level documentation content and title-block metadata
`,
)
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
/**
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
* External scene references are preserved when they are not present in
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
*/
export function remapDrawingSheetReferences(
sheet: DrawingSheetNode,
sceneIdMap: ReadonlyMap<string, string>,
): DrawingSheetNode {
const placedViewIds = new Map(
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
)
const noteSetIds = new Map(
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
)
const noteIds = new Map(
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
(note) => [note.id, generateId('sheet-note')] as const,
),
)
const keyedDefinitionIds = new Map(
sheet.keyedNoteDefinitions.map(
(definition) => [definition.id, generateId('keyed-note')] as const,
),
)
return {
...sheet,
placedViews: sheet.placedViews.map((view) => ({
...view,
id: placedViewIds.get(view.id)!,
levelId: view.levelId
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
: null,
})),
generalNoteSetIds: sheet.generalNoteSetIds.map(
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
),
generalNoteSets: sheet.generalNoteSets.map((set) => ({
...set,
id: noteSetIds.get(set.id)!,
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
})),
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
...definition,
id: keyedDefinitionIds.get(definition.id)!,
})),
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
...instance,
id: generateId('keyed-note-instance'),
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
instance.definitionId) as typeof instance.definitionId,
placedViewId: instance.placedViewId
? ((placedViewIds.get(instance.placedViewId) ??
instance.placedViewId) as typeof instance.placedViewId)
: null,
})),
documentMarkers: sheet.documentMarkers.map((marker) => ({
...marker,
id: generateId('sheet-marker'),
placedViewId: marker.placedViewId
? ((placedViewIds.get(marker.placedViewId) ??
marker.placedViewId) as typeof marker.placedViewId)
: null,
})),
schedules: sheet.schedules.map((schedule) => ({
...schedule,
id: generateId('sheet-schedule'),
})),
}
}
@@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians. // Yaw in radians.
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'),
// Which surface the terminal mounts on. Drives face orientation and // Which surface the terminal mounts on. Drives face orientation and
// which way the collar (and its port) points. // which way the collar (and its port) points.
+4
View File
@@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
height: z.number().default(1.8), height: z.number().default(1.8),
thickness: z.number().default(0.08), thickness: z.number().default(0.08),
// Persisted slab-support host — the fence sits on that slab's walking
// surface (see ItemNode.supportSlabId for the host rules).
supportSlabId: z.string().optional(),
curveOffset: z.number().optional(), curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22), baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2), postSpacing: z.number().default(2),
@@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({
- path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
- height/thickness: overall fence dimensions in meters - height/thickness: overall fence dimensions in meters
- supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation)
- curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set)
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
@@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians. // Yaw in radians.
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'),
// Cabinet dimensions in meters. Defaults match a typical upflow // Cabinet dimensions in meters. Defaults match a typical upflow
// furnace cabinet (~22" × 28" footprint, ~43" tall). // furnace cabinet (~22" × 28" footprint, ~43" tall).
+14
View File
@@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({
roofSegmentId: z.string().optional(), roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Persisted floor-support host (canonical doc — the same field on other
// floor-placed kinds and walls follows these rules). Written at
// placement/commit ONLY when overlapping slabs disagree on elevation
// (ambiguity); absent/null means "elect the support fresh on every
// read", which is the historical behavior. Read paths PREFER this slab
// while it still exists and still overlaps the node's footprint, and
// silently fall back to election otherwise. Deleting the host slab
// strips the field (deleteNodesAction); a host merely reshaped away is
// deliberately kept so hosting resumes if the slab's polygon returns.
// The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the
// level base — written when a pointer-capped commit elected the ground
// while a slab (e.g. an elevated deck) still overlapped the footprint.
supportSlabId: z.string().optional(),
// Denormalized references to collections this node belongs to // Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(), collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -56,4 +56,9 @@ describe('LevelNode', () => {
expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child']) expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'])
}) })
test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
expect('height' in LevelNode.parse({})).toBe(false)
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
})
}) })
+11
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import type { CeilingNode } from './ceiling' import type { CeilingNode } from './ceiling'
import type { ColumnNode } from './column' import type { ColumnNode } from './column'
import type { ConstructionDimensionNode } from './construction-dimension'
import type { DuctFittingNode } from './duct-fitting' import type { DuctFittingNode } from './duct-fitting'
import type { DuctSegmentNode } from './duct-segment' import type { DuctSegmentNode } from './duct-segment'
import type { DuctTerminalNode } from './duct-terminal' import type { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import type { ShelfNode } from './shelf'
import type { SlabNode } from './slab' import type { SlabNode } from './slab'
import type { SpawnNode } from './spawn' import type { SpawnNode } from './spawn'
import type { StairNode } from './stair' import type { StairNode } from './stair'
import type { StructuralGridNode } from './structural-grid'
import type { WallNode } from './wall' import type { WallNode } from './wall'
import type { ZoneNode } from './zone' import type { ZoneNode } from './zone'
@@ -29,6 +31,8 @@ type CoreLevelChildId =
| WallNode['id'] | WallNode['id']
| FenceNode['id'] | FenceNode['id']
| ColumnNode['id'] | ColumnNode['id']
| ConstructionDimensionNode['id']
| StructuralGridNode['id']
| ItemNode['id'] | ItemNode['id']
| ZoneNode['id'] | ZoneNode['id']
| SlabNode['id'] | SlabNode['id']
@@ -60,11 +64,18 @@ export const LevelNode = BaseNode.extend({
children: z.array(LevelChildId).default([]), children: z.array(LevelChildId).default([]),
// Specific props // Specific props
level: z.number().default(0), level: z.number().default(0),
/**
* Stored storey height in meters (floor-to-floor). No zod default on
* purpose: absence marks unmigrated legacy data and gates the load-time
* migration; a schema default would materialize silently through .parse().
*/
height: z.number().optional(),
}).describe( }).describe(
dedent` dedent`
Level node - used to represent a level in the building Level node - used to represent a level in the building
- children: array of architectural, equipment, and MEP distribution nodes - children: array of architectural, equipment, and MEP distribution nodes
- level: level number - level: level number
- height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data
`, `,
) )
+2
View File
@@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({
children: z.array(ItemNode.shape.id).default([]), children: z.array(ItemNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
// Dimensions (meters). Schema-level defaults intentionally reproduce // Dimensions (meters). Schema-level defaults intentionally reproduce
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced // the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
+11 -2
View File
@@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { SurfaceHoleMetadata } from './surface-hole-metadata' import { SurfaceHoleMetadata } from './surface-hole-metadata'
// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's
// 0.01 underside offset. Applies to edits only; migration writes legacy
// intervals verbatim (including degenerate zero-thickness slabs).
export const MIN_SLAB_THICKNESS = 0.02
export const SlabNode = BaseNode.extend({ export const SlabNode = BaseNode.extend({
id: objectId('slab'), id: objectId('slab'),
type: nodeType('slab'), type: nodeType('slab'),
@@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]),
elevation: z.number().default(0.05), // Elevation in meters elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane
thickness: z.number().default(0.05), // Grows downward from the surface
recessed: z.boolean().default(false),
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
dedent` dedent`
@@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({
- polygon: array of [x, z] points defining the slab boundary - polygon: array of [x, z] points defining the slab boundary
- holes: array of [x, z] polygons representing cutouts in the slab - holes: array of [x, z] polygons representing cutouts in the slab
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- elevation: elevation in meters - elevation: the walking surface (slab top), in meters above the level plane
- thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation]
- recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane
- autoFromWalls: whether the slab is automatically generated from a closed wall loop - autoFromWalls: whether the slab is automatically generated from a closed wall loop
`, `,
) )
+2
View File
@@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({
type: nodeType('spawn'), type: nodeType('spawn'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
}) })
export type SpawnNode = z.infer<typeof SpawnNode> export type SpawnNode = z.infer<typeof SpawnNode>
+8 -1
View File
@@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
stairType: StairType.default('straight'), stairType: StairType.default('straight'),
fromLevelId: z.string().nullable().default(null), fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null),
// Destination deck (a slab id). When set, the stair's rise follows that
// slab's elevation live. An explicit `totalRise` still wins when BOTH are
// set (edge case — the panel clears the custom rise when attaching).
deckSlabId: z.string().optional(),
slabOpeningMode: StairSlabOpeningMode.default('none'), slabOpeningMode: StairSlabOpeningMode.default('none'),
openingOffset: z.number().default(0), openingOffset: z.number().default(0),
width: z.number().default(1.0), width: z.number().default(1.0),
totalRise: z.number().default(2.5), totalRise: z.number().optional(),
stepCount: z.number().default(10), stepCount: z.number().default(10),
thickness: z.number().default(0.25), thickness: z.number().default(0.25),
fillToFloor: z.boolean().default(true), fillToFloor: z.boolean().default(true),
@@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({
- rotation: rotation around Y axis - rotation: rotation around Y axis
- stairType: straight (segment-based), curved (arc-based), or spiral - stairType: straight (segment-based), curved (arc-based), or spiral
- fromLevelId / toLevelId: source and destination levels used for auto slab cutouts - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts
- deckSlabId: destination deck (slab) — the rise derives from its elevation while set
- slabOpeningMode: whether a destination-level slab opening is generated for this stair - slabOpeningMode: whether a destination-level slab opening is generated for this stair
- openingOffset: extra opening expansion applied after the cutout polygon is computed - openingOffset: extra opening expansion applied after the cutout polygon is computed
- width: stair width - width: stair width
@@ -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>
+226 -10
View File
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
import { import {
buildEnabledWallFaceBandPatch, buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch, buildWallFaceBandCountPatch,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
getWallFaceBandConfig, getWallFaceBandConfig,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT, WALL_CROWN_DEFAULT,
@@ -13,7 +19,8 @@ import {
WALL_SKIRTING_SLOT_DEFAULT, WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS, WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig, WallFaceBandConfig,
type WallNode, WallNode,
type WallNode as WallNodeType,
WallTrimConfig, WallTrimConfig,
} from './wall' } from './wall'
@@ -41,7 +48,8 @@ describe('wall face bands', () => {
}) })
expect( expect(
getWallFaceBandConfig({ getWallFaceBandConfig(
{
height: 2.5, height: 2.5,
faceBands: { faceBands: {
enabled: true, enabled: true,
@@ -50,7 +58,9 @@ describe('wall face bands', () => {
middleHeight: 0.61, middleHeight: 0.61,
upperHeight: 0.61, upperHeight: 0.61,
}, },
}), },
2.5,
),
).toMatchObject({ ).toMatchObject({
count: 3, count: 3,
lowerTop: 0.84, lowerTop: 0.84,
@@ -60,7 +70,8 @@ describe('wall face bands', () => {
test('four bands adds an upper split below the final top band', () => { test('four bands adds an upper split below the final top band', () => {
expect( expect(
getWallFaceBandConfig({ getWallFaceBandConfig(
{
height: 2.5, height: 2.5,
faceBands: { faceBands: {
enabled: true, enabled: true,
@@ -69,7 +80,9 @@ describe('wall face bands', () => {
middleHeight: 0.6, middleHeight: 0.6,
upperHeight: 0.7, upperHeight: 0.7,
}, },
}), },
2.5,
),
).toMatchObject({ ).toMatchObject({
count: 4, count: 4,
lowerTop: 0.5, lowerTop: 0.5,
@@ -93,7 +106,7 @@ describe('wall face bands', () => {
lowerInterior: 'library:stale-lower', lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle', middleExterior: 'library:stale-middle',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>) } as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({ expect(patch.faceBands).toEqual({
enabled: true, enabled: true,
@@ -129,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish', exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top', topInterior: 'library:stale-top',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
3, 3,
) )
@@ -153,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle', middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper', upperExterior: 'library:stale-upper',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>) } as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({ expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
@@ -181,7 +194,7 @@ describe('wall face bands', () => {
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
3, 3,
) )
@@ -213,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior', upperExterior: 'library:painted-top-exterior',
}, },
} as Pick<WallNode, 'faceBands' | 'slots'>, } as Pick<WallNodeType, 'faceBands' | 'slots'>,
4, 4,
) )
@@ -254,3 +267,206 @@ describe('wall trim profiles', () => {
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
}) })
}) })
describe('wall assembly layers', () => {
test('defaults to legacy thickness when no assembly layers are modeled', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(wall.assemblyLayers).toEqual([])
expect(getWallAssemblyThickness(wall)).toBe(0.14)
})
test('stores role, side, thickness, material reference, and datum eligibility', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face', 'finish-face'],
},
],
})
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
'interior-gwb',
'brick-veneer',
])
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
{ id: 'stud-core', role: 'structure', side: 'core' },
])
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
interior: -0.061,
exterior: 0.135,
})
})
test('resolves stable datum references for legacy single-thickness walls', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
{
id: 'wall:structural-face:interior',
datum: 'structural-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:structural-face:exterior',
datum: 'structural-face',
side: 'exterior',
offset: 0.07,
},
{
id: 'wall:finish-face:interior',
datum: 'finish-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:finish-face:exterior',
datum: 'finish-face',
side: 'exterior',
offset: 0.07,
},
])
})
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-sheathing',
role: 'exterior-sheathing',
side: 'exterior',
thickness: 0.012,
materialRef: 'library:sheathing',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const references = resolveWallAssemblyDatumReferences(wall)
expect(references).toContainEqual({
id: 'wall:centerline:center',
datum: 'centerline',
side: 'center',
offset: 0,
})
expect(references).toContainEqual({
id: 'wall:structural-face:interior:stud-core',
datum: 'structural-face',
side: 'interior',
layerId: 'stud-core',
offset: -0.045,
})
expect(references).toContainEqual({
id: 'wall:structural-face:exterior:stud-core',
datum: 'structural-face',
side: 'exterior',
layerId: 'stud-core',
offset: 0.045,
})
expect(references).toContainEqual({
id: 'wall:finish-face:interior:interior-gwb',
datum: 'finish-face',
side: 'interior',
layerId: 'interior-gwb',
offset: -0.061,
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
),
).toMatchObject({
datum: 'finish-face',
side: 'exterior',
layerId: 'exterior-sheathing',
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
)?.offset,
).toBeCloseTo(0.057)
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
})
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
?.offset,
).toBeCloseTo(0.147)
expect(
resolveWallAssemblyDatumReference(
wall,
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
offset: 0.147,
})
})
})
+269 -3
View File
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallAssemblyLayerRole = z.enum([
'structure',
'interior-finish',
'exterior-sheathing',
'exterior-finish',
'masonry-veneer',
'air-space',
'concrete-block',
'structural-masonry',
'solid-concrete',
'furring',
])
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
export const WallDimensionDatum = z.enum([
'centerline',
'structural-face',
'finish-face',
'veneer-face',
])
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
export const WallAssemblyLayer = z.object({
id: z.string().trim().min(1).max(80).default('structure'),
role: WallAssemblyLayerRole.default('structure'),
side: z.enum(['core', 'interior', 'exterior']).default('core'),
thickness: z.number().finite().positive().default(0.1),
materialRef: z.string().trim().max(120).default(''),
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
})
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
export type WallAssemblyDatumReference = {
id: string
datum: WallDimensionDatum
side: WallAssemblyDatumSide
layerId?: string
offset: number
}
export const WallNode = BaseNode.extend({ export const WallNode = BaseNode.extend({
id: objectId('wall'), id: objectId('wall'),
type: nodeType('wall'), type: nodeType('wall'),
@@ -149,8 +191,11 @@ export const WallNode = BaseNode.extend({
// in a follow-up once migrated scenes are the norm. // in a follow-up once migrated scenes are the norm.
slots: z.record(z.string(), z.string()).optional(), slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(), curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
faceBands: WallFaceBandConfig.optional(), faceBands: WallFaceBandConfig.optional(),
skirting: WallTrimConfig.optional(), skirting: WallTrimConfig.optional(),
crown: WallTrimConfig.optional(), crown: WallTrimConfig.optional(),
@@ -165,6 +210,7 @@ export const WallNode = BaseNode.extend({
dedent` dedent`
Wall node - used to represent a wall in the building Wall node - used to represent a wall in the building
- thickness: thickness in meters - thickness: thickness in meters
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
- height: height in meters - height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc - curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system - start: start point of the wall in level coordinate system
@@ -188,6 +234,222 @@ export type WallBandSurfaceSlotId =
| 'upperExterior' | 'upperExterior'
| 'topExterior' | 'topExterior'
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
return wall.assemblyLayers ?? []
}
export function getWallAssemblyThickness(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): number {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return wall.thickness ?? 0.1
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
}
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
interior: number
exterior: number
} {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return { interior: -halfThickness, exterior: halfThickness }
}
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const interiorFinishThickness = layers
.filter((layer) => layer.side === 'interior')
.reduce((sum, layer) => sum + layer.thickness, 0)
const exteriorFinishThickness = layers
.filter((layer) => layer.side === 'exterior')
.reduce((sum, layer) => sum + layer.thickness, 0)
return {
interior: -coreThickness / 2 - interiorFinishThickness,
exterior: coreThickness / 2 + exteriorFinishThickness,
}
}
export function getWallDatumEligibleLayers(
wall: Pick<WallNode, 'assemblyLayers'>,
datum: WallDimensionDatum,
): WallAssemblyLayer[] {
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
}
export function getWallAssemblyDatumReferenceId(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
layerId?: string,
): string {
return ['wall', datum, side, layerId].filter(Boolean).join(':')
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function getWallAssemblyLayerSpans(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyLayerSpan[] {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const coreInteriorFace = -coreThickness / 2
const coreExteriorFace = coreThickness / 2
const spans: WallAssemblyLayerSpan[] = []
let coreOffset = coreInteriorFace
for (const layer of coreLayers) {
const interiorOffset = coreOffset
const exteriorOffset = coreOffset + layer.thickness
spans.push({ layer, interiorOffset, exteriorOffset })
coreOffset = exteriorOffset
}
let interiorOffset = coreInteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
const exteriorOffset = interiorOffset
const nextInteriorOffset = exteriorOffset - layer.thickness
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
interiorOffset = nextInteriorOffset
}
let exteriorOffset = coreExteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
const interiorFaceOffset = exteriorOffset
const nextExteriorOffset = interiorFaceOffset + layer.thickness
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
exteriorOffset = nextExteriorOffset
}
return spans
}
function createWallAssemblyDatumReference(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
offset: number,
layerId?: string,
): WallAssemblyDatumReference {
return {
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
datum,
side,
...(layerId ? { layerId } : {}),
offset,
}
}
export function resolveWallAssemblyDatumReferences(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyDatumReference[] {
const layers = wall.assemblyLayers ?? []
const references: WallAssemblyDatumReference[] = [
createWallAssemblyDatumReference('centerline', 'center', 0),
]
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return [
...references,
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
]
}
const spans = getWallAssemblyLayerSpans(wall)
for (const span of spans) {
if (span.layer.datumEligible.includes('structural-face')) {
if (span.layer.side === 'core') {
references.push(
createWallAssemblyDatumReference(
'structural-face',
'interior',
span.interiorOffset,
span.layer.id,
),
createWallAssemblyDatumReference(
'structural-face',
'exterior',
span.exteriorOffset,
span.layer.id,
),
)
} else {
const side = span.layer.side
references.push(
createWallAssemblyDatumReference(
'structural-face',
side,
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
span.layer.id,
),
)
}
}
if (span.layer.datumEligible.includes('finish-face')) {
const side = span.layer.side === 'core' ? 'center' : span.layer.side
const offset =
span.layer.side === 'interior'
? span.interiorOffset
: span.layer.side === 'exterior'
? span.exteriorOffset
: (span.interiorOffset + span.exteriorOffset) / 2
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
}
if (span.layer.datumEligible.includes('veneer-face')) {
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
}
}
if (!references.some((reference) => reference.datum === 'structural-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
)
}
if (!references.some((reference) => reference.datum === 'finish-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
)
}
return references
}
export function resolveWallAssemblyDatumReference(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
referenceId: string,
): WallAssemblyDatumReference | null {
return (
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
null
)
}
// Declared default appearance for an unpainted wall face in colored mode — // Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
// slot declaration (nodes) and the material resolver (viewer) share one value. // slot declaration (nodes) and the material resolver (viewer) share one value.
@@ -198,8 +460,11 @@ export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
} }
export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands'>) { export function getWallFaceBandConfig(
const wallHeight = wall.height ?? 2.5 wall: Pick<WallNode, 'height' | 'faceBands'>,
effectiveWallHeight: number,
) {
const wallHeight = Math.max(0, effectiveWallHeight)
const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) }
const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1
const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0
@@ -223,8 +488,9 @@ export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands
export function getWallFaceBandForHeight( export function getWallFaceBandForHeight(
wall: Pick<WallNode, 'height' | 'faceBands'>, wall: Pick<WallNode, 'height' | 'faceBands'>,
y: number, y: number,
effectiveWallHeight: number,
): WallFaceBand { ): WallFaceBand {
const bands = getWallFaceBandConfig(wall) const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
if (!bands.enabled) return 'upper' if (!bands.enabled) return 'upper'
if (y < bands.lowerTop) return 'lower' if (y < bands.lowerTop) return 'lower'
if (y < bands.middleTop) return 'middle' if (y < bands.middleTop) return 'middle'
+22
View File
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
]) ])
export type WindowType = z.infer<typeof WindowType> export type WindowType = z.infer<typeof WindowType>
export const WindowConstructionType = z.enum(['framed', 'masonry'])
export const WindowDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
export const WindowNode = BaseNode.extend({ export const WindowNode = BaseNode.extend({
id: objectId('window'), id: objectId('window'),
type: nodeType('window'), type: nodeType('window'),
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5), width: z.number().default(1.5),
height: z.number().default(1.5), height: z.number().default(1.5),
// Construction-document identity and optional manufacturer rough opening.
// Legacy scenes omit these fields and continue to parse unchanged.
mark: z.string().trim().max(16).optional(),
constructionType: WindowConstructionType.default('framed'),
dimensionReference: WindowDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Opening mode - when set to "opening", the window is only a shaped cutout // Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'), openingKind: z.enum(['window', 'opening']).default('window'),
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { ZoneNode } from './zone'
describe('ZoneNode architectural room data', () => {
test('keeps legacy zones generic while supplying room-safe defaults', () => {
const zone = ZoneNode.parse({
id: 'zone_legacy',
name: 'Landscape area',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
})
expect(zone).toMatchObject({
spaceRole: 'generic',
roomNumber: '',
enclosureStatus: 'auto',
floorFinish: '',
wallFinish: '',
ceilingFinish: '',
ceilingHeight: 2.7,
occupancy: '',
clearDimensionPolicy: 'none',
})
})
test('persists a complete architectural room profile', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
spaceRole: 'room',
roomNumber: '101',
enclosureStatus: 'enclosed',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 3,
occupancy: 'Business',
clearDimensionPolicy: 'inside-faces',
})
expect(room.spaceRole).toBe('room')
expect(room.roomNumber).toBe('101')
expect(room.clearDimensionPolicy).toBe('inside-faces')
})
})
+15
View File
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
// stored polygon remains a fallback for missing or temporarily open walls. // stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]), boundaryWallIds: z.array(objectId('wall')).default([]),
// Generic zones remain available for sites and analysis. Architectural
// room documentation is opt-in so legacy zone behavior is unchanged.
spaceRole: z.enum(['generic', 'room']).default('generic'),
roomNumber: z.string().trim().max(32).default(''),
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
floorFinish: z.string().trim().max(120).default(''),
wallFinish: z.string().trim().max(120).default(''),
ceilingFinish: z.string().trim().max(120).default(''),
ceilingHeight: z.number().min(0.1).default(2.7),
occupancy: z.string().trim().max(80).default(''),
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
// Visual styling // Visual styling
color: z.string().default('#3b82f6'), // Default blue color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}), metadata: z.json().optional().default({}),
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
- polygon: array of [x, z] points defining the zone boundary - polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop - autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure - boundaryWallIds: wall ids that prove the procedural enclosure
- spaceRole: generic site/analysis zone or architectural room
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
- enclosureStatus: auto-detected, explicitly enclosed, or open
- clearDimensionPolicy: optional room clear-dimension datum preference
- color: hex color for visual styling - color: hex color for visual styling
- metadata: zone metadata (optional) - metadata: zone metadata (optional)
`, `,
+6
View File
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling' import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney' import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column' import { ColumnNode } from './nodes/column'
import { ConstructionDimensionNode } from './nodes/construction-dimension'
import { CupolaNode } from './nodes/cupola' import { CupolaNode } from './nodes/cupola'
import { DoorNode } from './nodes/door' import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer' import { DormerNode } from './nodes/dormer'
import { DownspoutNode } from './nodes/downspout' import { DownspoutNode } from './nodes/downspout'
import { DrawingSheetNode } from './nodes/drawing-sheet'
import { DuctFittingNode } from './nodes/duct-fitting' import { DuctFittingNode } from './nodes/duct-fitting'
import { DuctSegmentNode } from './nodes/duct-segment' import { DuctSegmentNode } from './nodes/duct-segment'
import { DuctTerminalNode } from './nodes/duct-terminal' import { DuctTerminalNode } from './nodes/duct-terminal'
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn' import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair' import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment' import { StairSegmentNode } from './nodes/stair-segment'
import { StructuralGridNode } from './nodes/structural-grid'
import { TurbineVentNode } from './nodes/turbine-vent' import { TurbineVentNode } from './nodes/turbine-vent'
import { WallNode } from './nodes/wall' import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window' import { WindowNode } from './nodes/window'
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ElevatorNode, ElevatorNode,
LevelNode, LevelNode,
ColumnNode, ColumnNode,
ConstructionDimensionNode,
StructuralGridNode,
WallNode, WallNode,
FenceNode, FenceNode,
CabinetNode, CabinetNode,
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode, SkylightNode,
DormerNode, DormerNode,
DownspoutNode, DownspoutNode,
DrawingSheetNode,
DuctSegmentNode, DuctSegmentNode,
DuctFittingNode, DuctFittingNode,
DuctTerminalNode, DuctTerminalNode,
@@ -0,0 +1,89 @@
// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4).
// Auto slab polygon derives from wall centerlines, putting wall samples exactly on
// the polygon boundary — the shape that exposed ray-cast side dependence.
export const wallPlaneTopBoundaryRepro = {
building_rr4rx7weux2fpdbh: {
id: 'building_rr4rx7weux2fpdbh',
type: 'building',
object: 'node',
visible: true,
children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'],
metadata: {},
parentId: null,
position: [0, 0, 0],
rotation: [0, 0, 0],
},
level_pomuk0sbwec15mf3: {
id: 'level_pomuk0sbwec15mf3',
type: 'level',
level: 0,
height: 2.7,
object: 'node',
visible: true,
children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'],
metadata: {},
parentId: null,
},
level_5msog1z8hy2lyvxr: {
id: 'level_5msog1z8hy2lyvxr',
type: 'level',
level: 1,
height: 2.5,
object: 'node',
visible: true,
children: ['slab_j3i4ebjg4nsu8xk7'],
metadata: {},
parentId: 'building_rr4rx7weux2fpdbh',
},
wall_39bnnq29h824ryy0: {
id: 'wall_39bnnq29h824ryy0',
end: [-1, 4],
name: 'Wall 1',
type: 'wall',
start: [3, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
wall_on4rj410n69n3rzf: {
id: 'wall_on4rj410n69n3rzf',
end: [-1, -1],
name: 'Wall 2',
type: 'wall',
start: [-1, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
slab_j3i4ebjg4nsu8xk7: {
id: 'slab_j3i4ebjg4nsu8xk7',
name: 'Room 1 Slab',
type: 'slab',
holes: [],
object: 'node',
polygon: [
[-1, 4],
[-1, -1],
[4, -1],
[4, 1],
[3, 1],
[3, 4],
],
visible: true,
metadata: {},
parentId: 'level_5msog1z8hy2lyvxr',
recessed: false,
elevation: 0.19757210573188194,
thickness: 0.5,
holeMetadata: [],
autoFromWalls: true,
},
}
+14 -1
View File
@@ -46,7 +46,7 @@ export {
DEFAULT_LEVEL_HEIGHT, DEFAULT_LEVEL_HEIGHT,
getCeilingAt, getCeilingAt,
getCeilingHeightAt, getCeilingHeightAt,
getLevelHeight, resolveCeilingHeight,
} from './level-height' } from './level-height'
export { export {
type AxisLock, type AxisLock,
@@ -102,6 +102,19 @@ export {
snapVec3ToGrid, snapVec3ToGrid,
snapWorldXZToBuildingLocal, snapWorldXZToBuildingLocal,
} from './snap' } from './snap'
export {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
findLevelBelowId,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
type LevelElevation,
} from './storey'
export { export {
buildPortComponents, buildPortComponents,
type SystemSummary, type SystemSummary,
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'bun:test'
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height'
function createFixture(): Record<AnyNodeId, AnyNode> {
const nodes: AnyNode[] = [
LevelNode.parse({ id: 'level_empty', children: [] }),
LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }),
WallNode.parse({
id: 'wall_no_slab',
parentId: 'level_no_slab',
start: [10, 0],
end: [12, 0],
}),
LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }),
SlabNode.parse({
id: 'slab_standard',
parentId: 'level_standard_slab',
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
elevation: 0.05,
}),
WallNode.parse({
id: 'wall_standard',
parentId: 'level_standard_slab',
start: [1, 2],
end: [3, 2],
}),
LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }),
SlabNode.parse({
id: 'slab_raised',
parentId: 'level_tall_wall',
polygon: [
[20, 0],
[24, 0],
[24, 4],
[20, 4],
],
elevation: 0.35,
}),
WallNode.parse({
id: 'wall_tall',
parentId: 'level_tall_wall',
start: [21, 2],
end: [23, 2],
height: 3.2,
}),
LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }),
WallNode.parse({
id: 'wall_below_ceiling',
parentId: 'level_ceiling',
start: [40, 0],
end: [42, 0],
}),
CeilingNode.parse({
id: 'ceiling_tall',
parentId: 'level_ceiling',
polygon: [
[40, 0],
[42, 0],
[42, 2],
[40, 2],
],
height: 3.4,
}),
LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }),
SlabNode.parse({
id: 'slab_negative',
parentId: 'level_negative_slab',
polygon: [
[30, 0],
[34, 0],
[34, 4],
[30, 4],
],
elevation: -0.4,
}),
WallNode.parse({
id: 'wall_negative',
parentId: 'level_negative_slab',
start: [31, 2],
end: [33, 2],
height: 2.8,
}),
]
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('deriveLegacyLevelHeight', () => {
const nodes = createFixture()
const cases = [
['level_no_slab', 2.5],
['level_standard_slab', 2.5],
['level_tall_wall', 3.55],
['level_ceiling', 3.4],
['level_negative_slab', 2.8],
['level_empty', 2.5],
] as const
for (const [levelId, expected] of cases) {
it(`derives ${expected} for ${levelId}`, () => {
expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected)
})
}
})
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
// Post-migration stack: two stored-height levels; the upper level carries a
// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower
// level's ceiling clamp bound is 2.5 0.3 0.01 = 2.19 under the deck.
function createResolverFixture(options: { deck?: boolean } = {}): Record<AnyNodeId, AnyNode> {
const list: AnyNode[] = [
BuildingNode.parse({
id: 'building_a',
children: ['level_low', 'level_high'],
}),
LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_high',
level: 1,
height: 2.5,
parentId: 'building_a',
children: options.deck ? ['slab_deck'] : [],
}),
]
if (options.deck) {
list.push(
SlabNode.parse({
id: 'slab_deck',
parentId: 'level_high',
polygon: SQUARE,
elevation: 0,
thickness: 0.3,
}),
)
}
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('resolveCeilingHeight', () => {
it('returns the explicit height verbatim when stored', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 })
expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0)
})
it('resolves an absent height to the level-top clamp bound', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
})
it('tracks a level height change without any ceiling write', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number }
const raised = {
...nodes,
level_low: { ...level, height: 3.2 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19)
})
it('resolves under a covering deck from the level above', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19)
})
it('falls back to the default plane when the level is unresolvable', () => {
const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, {} as Record<AnyNodeId, AnyNode>)).toBeCloseTo(2.49)
})
})
describe('getCeilingAt lowest-wins with mixed follows/explicit', () => {
it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => {
const base = createResolverFixture()
const follows = CeilingNode.parse({
id: 'ceiling_follows',
parentId: 'level_low',
polygon: SQUARE,
})
const explicitLow = CeilingNode.parse({
id: 'ceiling_low',
parentId: 'level_low',
polygon: SQUARE,
height: 2.0,
})
const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] }
const nodes = {
...base,
level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode,
ceiling_follows: follows,
ceiling_low: explicitLow,
} as Record<AnyNodeId, AnyNode>
// Explicit 2.0 undercuts the 2.49 follows bound.
expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id)
// Raise the explicit one above the bound comparison: 2.6 stored — the
// follows ceiling (2.49) is now the lowest surface over the point.
const nodesHighExplicit = {
...nodes,
ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id)
})
})
+55 -23
View File
@@ -1,40 +1,68 @@
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
import { resolveWallTop } from '../systems/wall/wall-top'
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
// both sides only reference the other inside function bodies.
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'
export const DEFAULT_LEVEL_HEIGHT = 2.5 export const DEFAULT_LEVEL_HEIGHT = 2.5
/** /**
* Optional resolver for a wall's rendered base Y (mesh elevation). * Effective ceiling height in level-local meters. An explicit stored
* * `height` wins; absent height means the ceiling follows the level top —
* `packages/core` is pure domain logic and must not read viewer/Three.js * the same bound its write-clamp uses: min(storey plane, lowest
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have * covering-slab underside over its polygon) CEILING_CLAMP_MARGIN (see
* registry access (viewer systems, node tools) may pass a resolver so the * {@link getCeilingClampBound}). Falls back to the default plane minus
* mesh elevation is factored in; pure/headless callers (MCP, tests, server) * the same margin when the owning level is unresolvable.
* omit it and get a deterministic result from serialized node data alone.
*/ */
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined export function resolveCeilingHeight(
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
nodes: Record<AnyNodeId, AnyNode>,
): number {
if (ceiling.height != null) return ceiling.height
const bound =
typeof ceiling.parentId === 'string'
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
: Number.POSITIVE_INFINITY
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
}
export function getLevelHeight( export function deriveLegacyLevelHeight(
levelId: string, levelId: string,
nodes: Record<AnyNodeId, AnyNode>, nodes: Record<AnyNodeId, AnyNode>,
resolveWallBaseY?: WallBaseYResolver,
): number { ): number {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT if (!level) return DEFAULT_LEVEL_HEIGHT
const levelChildren = level.children
.map((childId) => nodes[childId as keyof typeof nodes])
.filter((child): child is AnyNode => child !== undefined)
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')
let maxTop = 0 let maxTop = 0
for (const childId of level.children) { for (const child of levelChildren) {
const child = nodes[childId as keyof typeof nodes]
if (!child) continue
if (child.type === 'ceiling') { if (child.type === 'ceiling') {
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT // Absence here is the PRE-migration legacy schema default (2.5), not
if (ch > maxTop) maxTop = ch // follows-mode — this derivation runs before the level has a height
// for a follows-mode bound to track.
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') { } else if (child.type === 'wall') {
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 const wall = child as WallNode
if (baseY < 0) baseY = 0 const electedElevation = computeWallSlabSupport(
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) {
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation)
if (top > maxTop) maxTop = top if (top > maxTop) maxTop = top
} }
} }
@@ -58,14 +86,18 @@ export function getCeilingAt(
if (!level) return null if (!level) return null
let best: CeilingNode | null = null let best: CeilingNode | null = null
let bestHeight = Number.POSITIVE_INFINITY
for (const childId of level.children) { for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes] const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT const h = resolveCeilingHeight(ceiling, nodes)
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling if (best === null || h < bestHeight) {
best = ceiling
bestHeight = h
}
} }
return best return best
} }
@@ -82,5 +114,5 @@ export function getCeilingHeightAt(
z: number, z: number,
): number | null { ): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z) const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
} }
+527
View File
@@ -0,0 +1,527 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
import {
CEILING_CLAMP_MARGIN,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
} from './storey'
const buildNodes = (list: AnyNode[]): Record<AnyNodeId, AnyNode> =>
Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
const level = (
id: string,
ordinal: number,
opts: { height?: number; parentId?: string | null; children?: string[] } = {},
): LevelNode =>
LevelNode.parse({
id,
level: ordinal,
parentId: opts.parentId ?? null,
children: opts.children ?? [],
...(opts.height === undefined ? {} : { height: opts.height }),
})
const building = (id: string, children: string[]): BuildingNode =>
BuildingNode.parse({ id, children })
const slabNode = (
id: string,
opts: {
polygon?: Array<[number, number]>
holes?: Array<Array<[number, number]>>
elevation?: number
thickness?: number
recessed?: boolean
},
): SlabNode =>
SlabNode.parse({
id,
polygon:
opts.polygon ??
([
[0, 0],
[4, 0],
[4, 4],
[0, 4],
] as Array<[number, number]>),
holes: opts.holes ?? [],
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
...(opts.thickness === undefined ? {} : { thickness: opts.thickness }),
...(opts.recessed === undefined ? {} : { recessed: opts.recessed }),
})
describe('getStoredLevelHeight', () => {
test('returns the stored height when present', () => {
expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25)
})
test('falls back to the default for unmigrated legacy levels', () => {
expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT)
expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5)
})
})
describe('getLevelElevations', () => {
test('single building matches a hand-computed prefix sum', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']),
level('level_0', 0, { height: 3, parentId: 'building_a' }),
level('level_1', 1, { height: 2.5, parentId: 'building_a' }),
level('level_2', 2, { height: 2.75, parentId: 'building_a' }),
level('level_3', 3, { height: 4, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')).toEqual({
baseY: 0,
height: 3,
buildingId: 'building_a',
ordinal: 0,
})
expect(elevations.get('level_1')?.baseY).toBe(3)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_3')?.baseY).toBe(8.25)
})
test('stacks two buildings independently with interleaved, unsorted ordinals', () => {
const nodes = buildNodes([
level('level_b1', 1, { height: 2.5, parentId: 'building_b' }),
level('level_a2', 2, { height: 3, parentId: 'building_a' }),
building('building_a', ['level_a0', 'level_a1', 'level_a2']),
level('level_a0', 0, { height: 3.5, parentId: 'building_a' }),
building('building_b', ['level_b0', 'level_b1']),
level('level_b0', 0, { height: 4, parentId: 'building_b' }),
level('level_a1', 1, { height: 3.25, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_a0')?.baseY).toBe(0)
expect(elevations.get('level_a1')?.baseY).toBe(3.5)
expect(elevations.get('level_a2')?.baseY).toBe(6.75)
expect(elevations.get('level_b0')?.baseY).toBe(0)
expect(elevations.get('level_b1')?.baseY).toBe(4)
expect(elevations.get('level_a2')?.buildingId).toBe('building_a')
expect(elevations.get('level_b1')?.buildingId).toBe('building_b')
})
test('negative ordinals stack from the lowest level up', () => {
const nodes = buildNodes([
building('building_a', ['level_basement', 'level_ground', 'level_upper']),
level('level_upper', 1, { height: 3, parentId: 'building_a' }),
level('level_basement', -1, { height: 2.25, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_basement')?.baseY).toBe(0)
expect(elevations.get('level_ground')?.baseY).toBe(2.25)
expect(elevations.get('level_upper')?.baseY).toBe(4.75)
})
test('duplicate and fractional ordinals stack stably without NaN', () => {
const nodes = buildNodes([
building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']),
level('level_dup_b', 1, { height: 3, parentId: 'building_a' }),
level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }),
level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_ground')?.baseY).toBe(0)
expect(elevations.get('level_mezz')?.baseY).toBe(2.5)
// Stable sort: equal ordinals keep nodes-record insertion order.
expect(elevations.get('level_dup_b')?.baseY).toBe(4)
expect(elevations.get('level_dup_a')?.baseY).toBe(7)
for (const elevation of elevations.values()) {
expect(Number.isFinite(elevation.baseY)).toBe(true)
expect(Number.isFinite(elevation.height)).toBe(true)
}
})
test('levels missing height fall back to 2.5 for both height and stacking', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_1', 1, { height: 3, parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')?.height).toBe(2.5)
expect(elevations.get('level_1')?.baseY).toBe(2.5)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_2')?.height).toBe(2.5)
})
test('resolves buildings via parentId, legacy children membership, and non-building parents', () => {
const nodes = buildNodes([
// level_direct is not in children; level_site has a non-building parentId.
building('building_x', ['level_legacy', 'level_site']),
level('level_direct', 0, { height: 3, parentId: 'building_x' }),
level('level_legacy', 1, { height: 2.5, parentId: null }),
level('level_site', 2, { height: 2.75, parentId: 'site_main' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_direct')?.buildingId).toBe('building_x')
expect(elevations.get('level_legacy')?.buildingId).toBe('building_x')
expect(elevations.get('level_site')?.buildingId).toBe('building_x')
expect(elevations.get('level_direct')?.baseY).toBe(0)
expect(elevations.get('level_legacy')?.baseY).toBe(3)
expect(elevations.get('level_site')?.baseY).toBe(5.5)
})
test('levels with no resolvable building share one legacy stack from 0', () => {
const nodes = buildNodes([
level('level_orphan_1', 1, { height: 3 }),
level('level_orphan_0', 0, { height: 2.75 }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_orphan_0')).toEqual({
baseY: 0,
height: 2.75,
buildingId: null,
ordinal: 0,
})
expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75)
})
})
describe('getLevelAbove', () => {
test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2')
expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5')
expect(getLevelAbove('level_5', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelAbove('level_a0', nodes)).toBeNull()
expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1')
})
test('orphan levels resolve within the shared legacy stack', () => {
const nodes = buildNodes([
level('level_orphan_0', 0, { height: 2.75 }),
level('level_orphan_1', 1, { height: 3 }),
])
expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1')
expect(getLevelAbove('level_orphan_1', nodes)).toBeNull()
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelAbove('level_missing', nodes)).toBeNull()
})
})
describe('getLevelBelow', () => {
test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2')
expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0')
expect(getLevelBelow('level_0', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelBelow('level_a0', nodes)).toBeNull()
expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0')
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelBelow('level_missing', nodes)).toBeNull()
})
})
// Two stacked levels in one building; `slabs` become children of the level
// above the queried one.
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
buildNodes([
building('building_a', ['level_0', 'level_1']),
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
level('level_1', 1, {
height: 2.5,
parentId: 'building_a',
children: slabs.map((node) => node.id),
}),
...slabs,
])
describe('getCoveringSlabUndersideAt', () => {
test('expresses a flush deck underside in the queried level local Y', () => {
// Flush deck occupying [-0.3, 0] above the plane: underside sits at
// storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
test('returns null outside the slab polygon', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull()
})
test('a hole in the slab vetoes coverage', () => {
const nodes = stackedNodes([
slabNode('slab_deck', {
elevation: 0,
thickness: 0.3,
holes: [
[
[1, 1],
[3, 1],
[3, 3],
[1, 3],
],
],
}),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2)
})
test('recessed pools never cover', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
})
test('the lowest underside wins among overlapping covering slabs', () => {
const nodes = stackedNodes([
// Default floor slab occupying [0, 0.05]: underside at the plane (2.5).
slabNode('slab_floor', {}),
slabNode('slab_deck', { elevation: 0, thickness: 0.3 }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
test('returns null when there is no level above', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull()
})
})
describe('getWallPlaneTop', () => {
const wallAt = (
start: [number, number],
end: [number, number],
): { start: [number, number]; end: [number, number] } => ({ start, end })
test('no covering slab → the stored level height', () => {
const nodes = stackedNodes([], 3)
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3)
})
test('a flush thick deck above clamps the plane to its underside', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a slab covering only part of the span clamps via the min of the samples', () => {
// Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it,
// only the end sample (4,2) lands inside — the min still clamps.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[3.5, 0],
[6, 0],
[6, 4],
[3.5, 4],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a recessed slab above is ignored', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5)
})
test('falls back to the default height when the level does not resolve', () => {
const nodes = stackedNodes([])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe(
DEFAULT_LEVEL_HEIGHT,
)
})
test('repro project: both boundary walls clamp to the covering slab underside', () => {
// Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's
// polygon derives from the level-0 wall CENTERLINES, so every perimeter
// wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge)
// clamped while Wall 1 (max-z edge) ran full height — ray-cast
// pointInPolygon includes min-side boundaries and excludes max-side ones.
const nodes = reproFixture as unknown as Record<AnyNodeId, AnyNode>
const levelId = 'level_pomuk0sbwec15mf3'
const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode
const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode
// storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5)
const underside = 2.7 + (0.19757210573188194 - 0.5)
expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside)
expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside)
})
test('all four rectangle walls under a same-footprint covering slab clamp', () => {
// The repro shape distilled: wall centerlines lie exactly on the covering
// slab's polygon edges. Every orientation must clamp identically.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const walls: Array<[[number, number], [number, number]]> = [
[
[0, 0],
[4, 0],
],
[
[4, 0],
[4, 4],
],
[
[4, 4],
[0, 4],
],
[
[0, 4],
[0, 0],
],
]
for (const [start, end] of walls) {
expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2)
}
})
test('a diagonal wall under the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a wall fully outside the covering slab keeps the storey height', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5)
})
test('a wall partially overlapping the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
})
describe('getCeilingClampBound', () => {
const ceilingPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
test('with no covering slab the bound is the storey plane minus the margin', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.5 - CEILING_CLAMP_MARGIN,
)
})
test('a covering deck lowers the bound to its underside minus the margin', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
test('a slab covering only the interior is caught by the centroid sample', () => {
// Deck hovers over the middle of the ceiling — every vertex sample
// misses, only the centroid (2, 2) lands inside it.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[1.5, 1.5],
[2.5, 1.5],
[2.5, 2.5],
[1.5, 2.5],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
test('returns Infinity for an unresolvable level', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe(
Number.POSITIVE_INFINITY,
)
})
test('vertices on the covering slab boundary clamp identically on every side', () => {
// Two mirrored strips share an edge with the 4x4 deck: one along its
// min-z edge, one along its max-z edge. Their interiors and centroids sit
// outside the deck, so only the shared-edge vertices can register —
// ray-cast pointInPolygon used to admit the min-side vertices and reject
// the max-side ones, giving orientation-dependent clamps.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const minSideStrip: Array<[number, number]> = [
[0, -1],
[4, -1],
[4, 0],
[0, 0],
]
const maxSideStrip: Array<[number, number]> = [
[0, 4],
[4, 4],
[4, 5],
[0, 5],
]
expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
})
+358
View File
@@ -0,0 +1,358 @@
import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
pointInPolygon,
pointOnPolygonBoundary,
wallOverlapsSlabFootprint,
} from '../systems/slab/slab-support'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
/**
* Gap kept between a ceiling's stored height and its clamp bound (storey
* plane or covering-slab underside), so the ceiling surface never
* coincides with the solid above it.
*/
export const CEILING_CLAMP_MARGIN = 0.01
/**
* Stored storey height in meters (floor-to-floor). Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height`
* field is absent.
*/
export function getStoredLevelHeight(level: Pick<LevelNode, 'height'>): number {
return level.height ?? DEFAULT_LEVEL_HEIGHT
}
export type LevelElevation = {
/** World Y of the level's floor: prefix sum of the storey heights below it. */
baseY: number
/** Stored storey height of this level (fallback applied). */
height: number
buildingId: string | null
ordinal: number
}
/**
* Resolves the owning building: explicit `parentId` pointing at a building
* wins; legacy levels that only appear in a building's `children` array
* resolve through that membership.
*/
function resolveLevelBuildingId(
levelId: LevelNode['id'],
parentId: string | null,
buildings: readonly BuildingNode[],
): string | null {
const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined
if (directParent) return directParent.id
return buildings.find((building) => building.children.includes(levelId))?.id ?? null
}
/**
* Per-building stacked elevations from stored storey heights: levels are
* sorted by ordinal ascending within each building, the lowest level's floor
* sits at 0, and each next floor sits on top of the previous storey height.
* Levels with no resolvable building share one legacy stack from 0.
*
* Pure — operates on the serialized nodes record only.
*/
export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
const buildings = Object.values(nodes).filter(
(node): node is BuildingNode => node?.type === 'building',
)
const entries: Array<{ levelId: string } & LevelElevation> = []
for (const node of Object.values(nodes)) {
if (node?.type !== 'level') continue
const level = node as LevelNode
entries.push({
levelId: level.id,
baseY: 0,
height: getStoredLevelHeight(level),
buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings),
ordinal: level.level,
})
}
const elevations = new Map<string, LevelElevation>()
const cumulativeYByBuilding = new Map<string | null, number>()
for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) {
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
elevations.set(entry.levelId, {
baseY,
height: entry.height,
buildingId: entry.buildingId,
ordinal: entry.ordinal,
})
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
}
return elevations
}
/**
* The id of the level directly above `levelId` in its own stack (same
* resolved building, or the shared legacy stack for building-less levels):
* the level with the lowest ordinal strictly greater than the queried
* level's. `null` when the level is topmost or unresolvable.
*/
export function findLevelAboveId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let aboveId: string | null = null
let aboveOrdinal = Number.POSITIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) {
aboveOrdinal = candidate.ordinal
aboveId = candidateId
}
}
return aboveId
}
/**
* The level directly above `levelId` — see {@link findLevelAboveId}.
* `null` when topmost or unresolvable. Pure.
*/
export function getLevelAbove(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes))
if (!aboveId) return null
const above = nodes[aboveId as LevelNode['id']]
return above?.type === 'level' ? (above as LevelNode) : null
}
/**
* The id of the level directly below `levelId` in its own stack — mirror of
* {@link findLevelAboveId}: the level with the highest ordinal strictly less
* than the queried level's. `null` when the level is lowest or unresolvable.
*/
export function findLevelBelowId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let belowId: string | null = null
let belowOrdinal = Number.NEGATIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) {
belowOrdinal = candidate.ordinal
belowId = candidateId
}
}
return belowId
}
/**
* The level directly below `levelId` — see {@link findLevelBelowId}.
* `null` when lowest or unresolvable. Pure.
*/
export function getLevelBelow(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const belowId = findLevelBelowId(levelId, getLevelElevations(nodes))
if (!belowId) return null
const below = nodes[belowId as LevelNode['id']]
return below?.type === 'level' ? (below as LevelNode) : null
}
type CoveringSlabContext = {
/** Stored storey height of the QUERIED level. */
storeyHeight: number
/** Non-recessed slab children of the level above. */
slabs: SlabNode[]
}
/**
* Storey height of the queried level plus the level-above's covering
* (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level.
* A missing level above yields an empty slab list, not `null` — the
* storey height is still meaningful for the clamp bound.
*/
function resolveCoveringSlabContext(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): CoveringSlabContext | null {
const level = nodes[levelId as LevelNode['id']]
if (level?.type !== 'level') return null
const above = getLevelAbove(levelId, nodes)
const slabs: SlabNode[] = []
for (const childId of above?.children ?? []) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'slab') continue
const slab = child as SlabNode
// Recessed slabs (pools) are open shells, not covering solids.
if (slab.recessed === true) continue
if (slab.polygon.length < 3) continue
slabs.push(slab)
}
return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs }
}
/**
* Underside of `slab`'s solid in the QUERIED level's local Y. The solid
* occupies `[elevation - thickness, elevation]` in ITS level's local Y,
* which sits `storeyHeight` above the queried level's floor.
*/
function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
}
/**
* Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`.
* Ray-cast pointInPolygon flips arbitrarily for points exactly ON the
* boundary (min-side edges read inside, max-side edges outside), so
* boundary contact counts as covered explicitly — the same convention as
* the slab-support interval classification. A point on a hole's rim keeps
* coverage (mirrors the support election's hole handling).
*
* Raw stored polygon + holes on purpose (mirrors getCeilingAt): the
* clamp bound doesn't need the rendered footprint's junction trims,
* and staying off the render path keeps this query cheap and pure.
*/
function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean {
if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) {
return false
}
for (const hole of slab.holes ?? []) {
if (hole.length < 3) continue
if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false
}
return true
}
/**
* Lowest underside among `slabs` covering `[x, z]`, in the queried
* level's local Y, or `null` when none covers the point.
*/
function lowestCoveringUndersideAt(
context: CoveringSlabContext,
x: number,
z: number,
): number | null {
let lowest: number | null = null
for (const slab of context.slabs) {
if (!slabCoversPoint(slab, x, z)) continue
const underside = coveringUndersideY(context.storeyHeight, slab)
if (lowest === null || underside < lowest) lowest = underside
}
return lowest
}
/**
* Underside of the LOWEST slab from the level above that covers
* level-local point `[x, z]`, expressed in the queried level's local Y:
* `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs
* (pools) never cover. `null` when no covering slab (or no level above).
*
* Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ
* transform and the viewer's LevelSystem writes only `position.y`), so a
* level-local `[x, z]` is valid in every level of the stack unchanged.
*/
export function getCoveringSlabUndersideAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return null
return lowestCoveringUndersideAt(context, x, z)
}
/**
* Top plane for a plane-bound wall on `levelId`, in level-local Y:
* `min(stored storey height, lowest covering-slab underside over the wall's
* span)` — a thick or flush slab on the level above SHORTENS the walls below
* instead of colliding with them (Revit-style automatic attach).
*
* Coverage: the wall's thickness band (centerline + face lines, arc-aware)
* is clipped against each covering slab's stored polygon minus holes via
* {@link wallOverlapsSlabFootprint} — the same overlap machinery as the
* support election. Point sampling is deliberately avoided: auto-slab
* polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON
* the polygon boundary, where ray-cast point-in-polygon flips with the
* edge's orientation (one wall clamped, its neighbor didn't). Boundary
* contact counts as covered on every side of the slab.
*
* This is THE plane for a plane-bound wall (`height` absent). Explicit-height
* walls ignore the value (`resolveWallTop` returns their stored height), so
* passing it wherever a raw storey height feeds `resolveWallTop` /
* `resolveWallEffectiveHeight` is always safe. Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level.
*/
export function getWallPlaneTop(
wall: Pick<WallNode, 'start' | 'end'> & Partial<Pick<WallNode, 'thickness' | 'curveOffset'>>,
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return DEFAULT_LEVEL_HEIGHT
let plane = context.storeyHeight
for (const slab of context.slabs) {
const underside = coveringUndersideY(context.storeyHeight, slab)
if (underside >= plane) continue
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
plane = underside
}
return plane
}
/**
* Upper bound for a ceiling's stored height over `polygon` on `levelId`:
* `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`.
* The covering underside is sampled at every polygon vertex plus the
* centroid — cheap, and a slab overlapping a convex-ish ceiling almost
* always covers one of those points; exact polygon-vs-polygon overlap is
* not worth its cost for a clamp bound. Ceiling outlines share footprint
* edges with the slabs above them the same way walls do, so vertices
* sitting exactly on a slab's boundary count as covered on every side
* (see `slabCoversPoint`) instead of flipping with the edge orientation.
*
* Returns `Infinity` when `levelId` doesn't resolve, so callers clamp
* against nothing rather than a garbage plane.
*/
export function getCeilingClampBound(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
polygon: ReadonlyArray<[number, number]>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return Number.POSITIVE_INFINITY
let bound = context.storeyHeight
if (polygon.length > 0) {
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
const samples: Array<[number, number]> = [
...polygon,
[cx / polygon.length, cz / polygon.length],
]
for (const [x, z] of samples) {
const underside = lowestCoveringUndersideAt(context, x, z)
if (underside !== null && underside < bound) bound = underside
}
}
return bound - CEILING_CLAMP_MARGIN
}
@@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode {
return sanitized.value as AnyNode return sanitized.value as AnyNode
} }
// An explicit `key: undefined` in update data REMOVES the key: optional
// fields like wall.height encode a mode by their absence (absent =
// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so
// a plain spread would leave a lingering own key that breaks `'height' in
// node` checks.
function mergeNodeUpdate(currentNode: AnyNode, patch: Partial<AnyNode>): AnyNode {
const merged: Record<string, unknown> = { ...currentNode, ...patch }
for (const key of Object.keys(patch)) {
if ((patch as Record<string, unknown>)[key] === undefined) delete merged[key]
}
return merged as AnyNode
}
function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode { function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode {
const candidate = { ...currentNode, ...data } const candidate = mergeNodeUpdate(currentNode, data)
const parsed = AnyNodeSchema.safeParse(candidate) const parsed = AnyNodeSchema.safeParse(candidate)
if (parsed.success) return parsed.data if (parsed.success) return parsed.data
@@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
const sanitized = sanitizeNumericValue(schema, data, currentNode, []) const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
if (sanitized.issues.length === 0) { if (sanitized.issues.length === 0) {
return candidate as AnyNode return candidate
} }
warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues)
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode return mergeNodeUpdate(currentNode, sanitized.value as Partial<AnyNode>)
} }
function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) { function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) {
@@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) {
(a.parentId ?? null) === (b.parentId ?? null) && (a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && // Absent height means plane-bound (follows the storey), which must never
// merge with an explicit height — even one that currently matches the plane.
(a.height == null) === (b.height == null) &&
Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 &&
aInterior === bInterior && aInterior === bInterior &&
aExterior === bExterior && aExterior === bExterior &&
a.frontSide === b.frontSide && a.frontSide === b.frontSide &&
@@ -1129,6 +1145,31 @@ export const deleteNodesAction = (
} }
} }
// Deleting a slab strips `supportSlabId` / `deckSlabId` references from
// surviving nodes in the same undo commit (mirrors the collectionIds
// cleanup below), so those nodes re-elect their support / re-derive
// their rise. Deletion is the ONLY writer — a host merely reshaped away
// keeps the field and the read path falls back, letting hosting resume
// if the slab returns.
const deletedSlabIds = new Set<string>()
for (const id of allIds) {
if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id)
}
if (deletedSlabIds.size > 0) {
for (const [nodeId, node] of Object.entries(nextNodes)) {
if (allIds.has(nodeId as AnyNodeId)) continue
const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {}
const hostId = (node as { supportSlabId?: string }).supportSlabId
if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined
const deckId = (node as { deckSlabId?: string }).deckSlabId
if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined
if (Object.keys(patch).length > 0) {
nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode
nodesToMarkDirty.add(nodeId as AnyNodeId)
}
}
}
for (const id of allIds) { for (const id of allIds) {
const node = nextNodes[id] const node = nextNodes[id]
if (!node) continue if (!node) continue
@@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number
const SHELF_ID = 'shelf_sanitize' as AnyNodeId const SHELF_ID = 'shelf_sanitize' as AnyNodeId
const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId
const WALL_ID = 'wall_keyremoval' as AnyNodeId
function makeWall(): AnyNode {
return {
id: WALL_ID,
type: 'wall',
parentId: null,
object: 'node',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [4, 0],
height: 2.5,
} as unknown as AnyNode
}
function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode { function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode {
return { return {
@@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => {
expect(Number.isFinite(created.thickness)).toBe(true) expect(Number.isFinite(created.thickness)).toBe(true)
}) })
}) })
describe('node update explicit-undefined key removal', () => {
beforeEach(() => {
useScene.setState({
nodes: { [WALL_ID]: makeWall() },
rootNodeIds: [WALL_ID],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('an undefined value in update data removes the key from the stored node', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
})
test('undo restores a key removed via an undefined update value', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
expect('height' in (useScene.getState().nodes[WALL_ID] as Record<string, unknown>)).toBe(false)
useScene.temporal.getState().undo()
const wall = useScene.getState().nodes[WALL_ID] as { height?: number }
expect('height' in wall).toBe(true)
expect(wall.height).toBe(2.5)
})
test('other keys in the same patch still apply when one is removed', () => {
useScene.getState().updateNode(WALL_ID, {
height: undefined,
name: 'Plane-bound wall',
} as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
expect(wall.name).toBe('Plane-bound wall')
})
})
@@ -7,6 +7,14 @@ import { create } from 'zustand'
export type LiveTransform = { export type LiveTransform = {
position: [number, number, number] position: [number, number, number]
rotation: number // Y-axis rotation (plan-view rotation) rotation: number // Y-axis rotation (plan-view rotation)
/**
* Pointer-decided support cap (level-local Y) published by 3D drags:
* the elevation of the surface the cursor ray actually points at. The
* floor-elevation system passes it to the slab-support election so a
* deck above the aimed-at floor never lifts the dragged node. Absent
* for 2D floorplan drags (no camera ray) — election stays uncapped.
*/
supportElevationCap?: number
} }
type LiveTransformState = { type LiveTransformState = {
@@ -666,7 +666,9 @@ describe('scene commit boundary', () => {
const snapshot = currentSnapshot() const snapshot = currentSnapshot()
snapshot.nodes = { snapshot.nodes = {
...snapshot.nodes, ...snapshot.nodes,
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode, // Marker must survive the load migration: level ordinals renumber on
// load, so the stored storey height marks the applied snapshot instead.
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode,
} }
snapshot.installedPlugins = ['pascal:trees'] snapshot.installedPlugins = ['pascal:trees']
const commits: SceneCommit[] = [] const commits: SceneCommit[] = []
@@ -674,7 +676,7 @@ describe('scene commit boundary', () => {
useScene.getState().dirtyNodes.clear() useScene.getState().dirtyNodes.clear()
expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true) expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true)
expect(levelNumber()).toBe(8) expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8)
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees']) expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(commits.map((commit) => commit.origin)).toEqual(['host']) expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0) expect(useScene.temporal.getState().pastStates).toHaveLength(0)
@@ -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' },
])
})
})
@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
type RawNode = Record<string, unknown>
function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode {
return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra }
}
function site(children: string[]): RawNode {
return baseNode('site_test', 'site', null, { children })
}
function building(id: string, children: string[]): RawNode {
return baseNode(id, 'building', 'site_test', { children })
}
function level(
id: string,
buildingId: string,
ordinal: number,
children: string[],
extra: RawNode = {},
): RawNode {
return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra })
}
function wall(
id: string,
levelId: string,
start: [number, number],
end: [number, number],
height?: number,
): RawNode {
return baseNode(id, 'wall', levelId, {
start,
end,
children: [],
...(height !== undefined ? { height } : {}),
})
}
function slab(
id: string,
levelId: string,
polygon: Array<[number, number]>,
elevation = 0.05,
): RawNode {
return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation })
}
function ceiling(
id: string,
levelId: string,
polygon: Array<[number, number]>,
height: number,
extra: RawNode = {},
): RawNode {
return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra })
}
function stair(id: string, levelId: string, extra: RawNode = {}): RawNode {
return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra })
}
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function loadScene(nodes: Record<string, RawNode>): Record<string, AnyNode> {
useScene.getState().setScene(nodes as unknown as Record<string, AnyNode>, ['site_test'] as never)
return useScene.getState().nodes as Record<string, AnyNode>
}
type LevelResult = Extract<AnyNode, { type: 'level' }>
type WallResult = Extract<AnyNode, { type: 'wall' }>
type StairResult = Extract<AnyNode, { type: 'stair' }>
type SlabResult = Extract<AnyNode, { type: 'slab' }>
type CeilingResult = Extract<AnyNode, { type: 'ceiling' }>
describe('scene vertical model migration', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('hole pattern: walls within 0.20 of the plane become plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]),
})
// Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short.
expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65)
expect('height' in (nodes.wall_tall as WallResult)).toBe(false)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('intentional short walls at or beyond 0.20 keep their explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect((nodes.wall_a as WallResult).height).toBe(2.3)
expect((nodes.wall_b as WallResult).height).toBe(2.1)
})
test('absent-height wall well short of the plane materializes the 2.5 default', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.wall_a as WallResult).height).toBe(2.5)
})
test('ordinal renumber compacts per building, anchored at zero', () => {
const nodes = loadScene({
site_test: site(['building_a', 'building_b']),
building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']),
building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']),
// Duplicate fractional ordinals (MCP wrote elevation params here).
level_a1: level('level_a1', 'building_a', 2.5, []),
level_a2: level('level_a2', 'building_a', 2.5, []),
level_a3: level('level_a3', 'building_a', 5, []),
// Basements compact upward toward -1, non-negatives down to 0.
level_b1: level('level_b1', 'building_b', -3, []),
level_b2: level('level_b2', 'building_b', -1, []),
level_b3: level('level_b3', 'building_b', 0, []),
level_b4: level('level_b4', 'building_b', 4, []),
})
expect((nodes.level_a1 as LevelResult).level).toBe(0)
expect((nodes.level_a2 as LevelResult).level).toBe(1)
expect((nodes.level_a3 as LevelResult).level).toBe(2)
expect((nodes.level_b1 as LevelResult).level).toBe(-2)
expect((nodes.level_b2 as LevelResult).level).toBe(-1)
expect((nodes.level_b3 as LevelResult).level).toBe(0)
expect((nodes.level_b4 as LevelResult).level).toBe(1)
})
test('near-bound ceiling heights become follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
// Legacy default: ceiling 2.5 drives the derived level height 2.5,
// so the clamp bound is 2.49 and |2.5 2.49| < 0.20 → follows.
level_a: level('level_a', 'building_a', 0, ['ceiling_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
// Already write-clamped default: 2.49 under a derived 2.49 level
// (bound 2.48) → follows too.
level_b: level('level_b', 'building_a', 1, ['ceiling_b']),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49),
})
expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false)
expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false)
})
test('an intentional low ceiling keeps its explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under
// the 2.99 bound — a deliberate dropped ceiling, kept explicit.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0)
})
test('autoFromWalls ceilings always convert to follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// 2.2 is far from the 2.99 bound, but auto heights were always
// derived by the sync — never user intent — so it drops anyway.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }),
})
expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false)
})
test('migrated scene keeps a near-bound ceiling height (gate respected)', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// Post-migration scene (level carries height): a stored 2.49 IS a
// deliberately typed value and must survive reloads.
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }),
})
expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49)
expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49)
})
test('legacy scene drops totalRise 2.5 but keeps other rises', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
})
expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false)
expect((nodes.stair_b as StairResult).totalRise).toBe(3.1)
})
test('migrated scene keeps a deliberately typed totalRise 2.5', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
})
expect((nodes.stair_a as StairResult).totalRise).toBe(2.5)
})
test('already-migrated level and its walls are untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5),
})
expect((nodes.level_a as LevelResult).height).toBe(4.0)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect((nodes.wall_b as WallResult).height).toBe(2.5)
})
test('slab split writes thickness = elevation exactly for legacy solids', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']),
slab_a: slab('slab_a', 'level_a', SQUARE, 0.3),
slab_b: slab('slab_b', 'level_a', SQUARE, 0),
})
const raised = nodes.slab_a as SlabResult
expect(raised.elevation).toBe(0.3)
expect(raised.thickness).toBe(0.3)
expect(raised.recessed).not.toBe(true)
// Degenerate zero-elevation slab keeps its zero occupied interval —
// migration never clamps to MIN_SLAB_THICKNESS.
const flush = nodes.slab_b as SlabResult
expect(flush.elevation).toBe(0)
expect(flush.thickness).toBe(0)
})
test('slab split defaults an absent elevation to the effective 0.05 thickness', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }),
})
expect((nodes.slab_a as SlabResult).thickness).toBe(0.05)
})
test('legacy pool becomes recessed with its elevation unchanged', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: slab('slab_a', 'level_a', SQUARE, -0.15),
})
const pool = nodes.slab_a as SlabResult
expect(pool.elevation).toBe(-0.15)
expect(pool.recessed).toBe(true)
expect(pool.thickness).toBe(0.05)
})
test('slab with thickness already present is untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
// A below-plane SOLID (already-split scene): the gate must not
// reinterpret its negative elevation as a pool.
slab_a: baseNode('slab_a', 'slab', 'level_a', {
polygon: SQUARE,
holes: [],
elevation: -0.15,
thickness: 0.3,
}),
})
const deck = nodes.slab_a as SlabResult
expect(deck.elevation).toBe(-0.15)
expect(deck.thickness).toBe(0.3)
expect('recessed' in deck).toBe(false)
})
test('migration is idempotent', () => {
const first = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
level_a: level('level_a', 'building_a', 2.5, [
'slab_a',
'wall_tall',
'wall_a',
'stair_a',
'stair_b',
]),
level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0),
wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]),
})
const second = loadScene(structuredClone(first) as unknown as Record<string, RawNode>)
expect(second).toEqual(first)
})
})
+211 -2
View File
@@ -32,6 +32,10 @@ import {
type SceneMaterialId, type SceneMaterialId,
} from '../schema/scene-material' } from '../schema/scene-material'
import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types'
import { deriveLegacyLevelHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint'
import { healSceneNodes } from '../utils/heal-scene-graph' import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
import { import {
@@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number
} }
function normalizeStairNode(node: Record<string, unknown>) { function normalizeStairNode(node: Record<string, unknown>) {
const hasTotalRise = 'totalRise' in node
const sanitized = { const sanitized = {
...node, ...node,
position: getVector3(node.position, [0, 0, 0]), position: getVector3(node.position, [0, 0, 0]),
@@ -101,7 +106,7 @@ function normalizeStairNode(node: Record<string, unknown>) {
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'), slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
openingOffset: getFiniteNumber(node.openingOffset, 0), openingOffset: getFiniteNumber(node.openingOffset, 0),
width: getFiniteNumber(node.width, 1), width: getFiniteNumber(node.width, 1),
totalRise: getFiniteNumber(node.totalRise, 2.5), totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined,
stepCount: getFiniteNumber(node.stepCount, 10), stepCount: getFiniteNumber(node.stepCount, 10),
thickness: getFiniteNumber(node.thickness, 0.25), thickness: getFiniteNumber(node.thickness, 0.25),
fillToFloor: getBoolean(node.fillToFloor, true), fillToFloor: getBoolean(node.fillToFloor, true),
@@ -117,7 +122,13 @@ function normalizeStairNode(node: Record<string, unknown>) {
} }
const parsed = StairNodeSchema.safeParse(sanitized) const parsed = StairNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null if (!parsed.success) return null
if (hasTotalRise) return parsed.data
// Absent `totalRise` means "rise derives from the storey height" and must
// survive the load: safeParse echoes the sanitized explicit-undefined key,
// which would flip `'totalRise' in node` checks — strip it back off.
const { totalRise: _totalRise, ...rest } = parsed.data
return rest
} }
function normalizeStairSegmentNode(node: Record<string, unknown>) { function normalizeStairSegmentNode(node: Record<string, unknown>) {
@@ -559,6 +570,36 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next return next
} }
function migrateConstructionDimension(node: Record<string, any>) {
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
const hasLegacyDrawingOverride = drawingOverrides.some(
(entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
entry.presentation === 'reference',
)
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
return {
...dimension,
drawingOverrides: drawingOverrides.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
}),
}
}
// Walls whose top lands within this of the storey plane become plane-bound;
// ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both.
// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
// a taller wall) must snap to the plane, while intentional 0.20-short walls
// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
// height — hence 0.20 with a strictly-less-than comparison.
const PLANE_BOUND_EPSILON = 0.2
function migrateNodes(nodes: Record<string, any>): { function migrateNodes(nodes: Record<string, any>): {
nodes: Record<string, AnyNode> nodes: Record<string, AnyNode>
mintedMaterials: Record<SceneMaterialId, SceneMaterial> mintedMaterials: Record<SceneMaterialId, SceneMaterial>
@@ -667,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
} }
} }
if (node.type === 'construction-dimension') {
patchedNodes[id] = migrateConstructionDimension(node)
}
if (node.type === 'stair') { if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
@@ -886,6 +931,169 @@ function migrateNodes(nodes: Record<string, any>): {
} }
} }
// Pass 3: vertical building model.
// A level without `height` marks a scene saved before the vertical model
// landed. Computed before this pass mutates anything: the stair-rise
// cleanup below must never run on already-migrated scenes.
const isLegacyScene = Object.values(patchedNodes).some(
(node) => node?.type === 'level' && !('height' in node),
)
// 3a. Ordinal renumber — always runs, per building (idempotent
// self-healing; MCP's create-level historically wrote its elevation PARAM
// into the ordinal, so fractional/duplicate ordinals exist in the wild).
const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building')
const levelsByBuilding = new Map<string | null, Array<{ id: string; ordinal: number }>>()
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'level') continue
// Mirrors the building resolution in services/storey.ts: an explicit
// parentId pointing at a building wins, membership in a building's
// children array is the legacy fallback, and unresolvable levels share
// one orphan bucket.
const buildingId =
buildingNodes.find((building) => building.id === node.parentId)?.id ??
buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
null
const bucket = levelsByBuilding.get(buildingId) ?? []
bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) })
levelsByBuilding.set(buildingId, bucket)
}
for (const bucket of levelsByBuilding.values()) {
// Anchored at zero on purpose: ordinals are semantic — `level < 0`
// renders "Basement N" and `level === 0` is the ground-floor default —
// so negatives compact upward toward 1 and non-negatives compact down
// to 0. A blind 0..n renumber would rename basements.
const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal)
const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length
sorted.forEach((entry, index) => {
const nextOrdinal = index - negativeCount
const current = patchedNodes[entry.id]
if (current.level !== nextOrdinal) {
patchedNodes[entry.id] = { ...current, level: nextOrdinal }
}
})
}
// 3b. Stored storey heights: materialize the legacy stacked height verbatim
// (never rounded or snapped — snapping would move existing buildings).
// All planes derive before any wall height below mutates.
const legacyLevelIds = Object.entries(patchedNodes)
.filter(([, node]) => node?.type === 'level' && !('height' in node))
.map(([id]) => id)
const derivedHeights = new Map<string, number>()
for (const levelId of legacyLevelIds) {
derivedHeights.set(
levelId,
deriveLegacyLevelHeight(levelId, patchedNodes as Record<AnyNodeId, AnyNode>),
)
}
for (const levelId of legacyLevelIds) {
const plane = derivedHeights.get(levelId)!
const level = patchedNodes[levelId]
patchedNodes[levelId] = { ...level, height: plane }
// 3c. Wall-top classification against the just-written plane, using the
// same slab-support election as deriveLegacyLevelHeight (call shape
// mirrored from services/level-height.ts). Walls whose top meets the
// plane drop their explicit height and follow the level from now on;
// walls ending short (or tall) keep an explicit height — materializing
// the 2.5 default onto absent-height walls that end short of the plane.
const children = getStringArray(level.children)
.map((childId) => patchedNodes[childId])
.filter((child) => child !== undefined)
const slabs = children.filter((child) => child.type === 'slab')
const walls = children.filter((child) => child.type === 'wall')
for (const wall of walls) {
const electedBase = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const top = Math.max(0, electedBase) + effectiveHeight
if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
if ('height' in wall) {
const { height: _height, ...planeBound } = wall
patchedNodes[wall.id] = planeBound
}
} else {
patchedNodes[wall.id] = { ...wall, height: effectiveHeight }
}
}
}
// 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
// schema default, not a user choice — drop it so the rise derives from the
// storey height. Gated on isLegacyScene because on a post-migration scene
// a stored 2.5 IS a deliberately typed value and must survive reloads.
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'stair') continue
if (node.totalRise !== 2.5) continue
const { totalRise: _totalRise, ...derivedRise } = node
patchedNodes[id] = derivedRise
}
}
// 3e. Slab placement/thickness split. `elevation` stays the walking surface;
// the new `thickness` grows downward so the solid occupies
// [elevation thickness, elevation]. Legacy solids extruded [0, elevation],
// so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
// applies to edits only, never here) keeps the occupied interval identical.
// Legacy pools (elevation < 0) become explicit `recessed` intent with
// elevation unchanged. Gated per slab on a missing `thickness` — the
// migration output is cast, so schema defaults never materialize on load.
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'slab' || 'thickness' in node) continue
const elevation = getFiniteNumber(node.elevation, 0.05)
patchedNodes[id] =
elevation < 0
? { ...node, thickness: 0.05, recessed: true }
: { ...node, thickness: elevation }
}
// 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
// after 3b/3e so the clamp bound sees stored level heights and split slab
// thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
// clamp bound (min(storey plane, covering-slab underside) margin, via
// getCeilingClampBound) is the legacy default tracking the level top, not
// a choice — drop it so the ceiling follows the level from now on.
// autoFromWalls ceilings always convert: their height was derived by the
// space-detection sync, never user intent. Gated on isLegacyScene, which
// is exact — nothing shipped between the level-height migration and this
// one — and makes the step idempotent. Known accepted edge: a
// post-migration user typing a custom height exactly equal to the bound
// keeps it (the gate prevents re-classification on later loads).
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'ceiling' || !('height' in node)) continue
const dropHeight = () => {
const { height: _height, ...follows } = node
patchedNodes[id] = follows
}
if (node.autoFromWalls === true) {
dropHeight()
continue
}
if (typeof node.parentId !== 'string') continue
const bound = getCeilingClampBound(
node.parentId,
patchedNodes as Record<AnyNodeId, AnyNode>,
Array.isArray(node.polygon) ? node.polygon : [],
)
const stored = getFiniteNumber(node.height, Number.NaN)
if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
dropHeight()
}
}
}
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials } return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
} }
@@ -1163,6 +1371,7 @@ const useScene: UseSceneStore = create<SceneState>()(
const level0 = LevelNode.parse({ const level0 = LevelNode.parse({
level: 0, level: 0,
children: [], children: [],
height: 2.5,
}) })
const building = BuildingNode.parse({ const building = BuildingNode.parse({
@@ -1,13 +1,5 @@
import type { import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
AnyNode, import { getStoredLevelHeight } from '../../services/storey'
AnyNodeId,
CeilingNode,
ElevatorNode,
LevelNode,
WallNode,
} from '../../schema'
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
export type ElevatorLevelEntry = { export type ElevatorLevelEntry = {
id: LevelNode['id'] id: LevelNode['id']
@@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels(
return levels.slice(minIndex, maxIndex + 1) return levels.slice(minIndex, maxIndex + 1)
} }
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
export function resolveElevatorLevels( export function resolveElevatorLevels(
elevator: ElevatorNode, elevator: ElevatorNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
@@ -119,7 +89,7 @@ export function resolveElevatorLevels(
let cumulativeY = 0 let cumulativeY = 0
for (const level of allLevels) { for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY) baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getElevatorLevelHeight(level.id, nodes) cumulativeY += getStoredLevelHeight(level)
} }
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'bun:test'
import { SlabNode, WallNode } from '../../schema'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
import {
clampSlabElevationForWalls,
computeWallSlabSupport,
getSlabElevationUpperBound,
} from './slab-support'
// 4×3 room slab drawn on the wall centerlines, like an auto-slab.
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const STOREY_HEIGHT = 2.7
const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT
function roomSlab(elevation: number) {
return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true })
}
function roomWalls(height?: number) {
return [
WallNode.parse({ start: [0, 0], end: [4, 0], height }),
WallNode.parse({ start: [4, 0], end: [4, 3], height }),
WallNode.parse({ start: [4, 3], end: [0, 3], height }),
WallNode.parse({ start: [0, 3], end: [0, 0], height }),
]
}
describe('clampSlabElevationForWalls', () => {
it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(true)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('leaves proposals at or below the bound untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('passes negative (recessed-committing) proposals through untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(-0.6)
})
it('does not clamp when the walls all carry explicit heights', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
it('does not clamp a slab covering no walls', () => {
const island = SlabNode.parse({
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.05,
})
const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
})
describe('getSlabElevationUpperBound', () => {
it('bounds a slab electable by plane-bound walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND)
})
it('is unbounded under explicit-height walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe(
Number.POSITIVE_INFINITY,
)
})
})
describe('computeWallSlabSupport preferred host', () => {
const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] }
const low = SlabNode.parse({
id: 'slab_low',
polygon: SQUARE,
elevation: 0.1,
autoFromWalls: true,
})
const high = SlabNode.parse({
id: 'slab_high',
polygon: SQUARE,
elevation: 0.6,
autoFromWalls: true,
})
it('elects the highest supporting elevation without a preference', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [])
expect(support.elevation).toBeCloseTo(0.6)
})
it('pins the elected elevation to a still-supporting preferred slab', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low')
expect(support.elevation).toBeCloseTo(0.1)
// Fill-down machinery still derives from ALL supporting slabs.
expect(support.baseSegments).toHaveLength(1)
expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6)
})
it('ignores a preferred slab that no longer supports the wall', () => {
const island = SlabNode.parse({
id: 'slab_island',
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.9,
})
const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island')
expect(support.elevation).toBeCloseTo(0.6)
})
})
@@ -0,0 +1,688 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import type { SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
export type SlabElevationClamp = {
elevation: number
clamped: boolean
}
/**
* Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall
* (no stored `height`) keeps its top at the storey plane, so a slab that
* rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that
* wall's base would squeeze the wall body below its minimum (and at the
* plane, to nothing). Walls with explicit heights don't constrain — their
* top rides the elected base, not the plane. Negative proposals (the
* drag-through-zero path that commits the `recessed` intent) pass
* through untouched: this is a purely numeric upper bound.
*
* The election runs against `levelSlabs` with `proposedElevation`
* substituted into `slab`, so a slab that would only WIN the election at
* the proposed elevation still clamps, and a slab out-elected by a
* sibling doesn't. Pure.
*/
export function clampSlabElevationForWalls(
proposedElevation: number,
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): SlabElevationClamp {
const bound = storeyHeight - MIN_WALL_HEIGHT
if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false }
if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false }
const substituted = levelSlabs.some((candidate) => candidate.id === slab.id)
? levelSlabs.map((candidate) =>
candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate,
)
: [...levelSlabs, { ...slab, elevation: proposedElevation }]
for (const wall of levelWalls) {
if (wall.height != null) continue
const wallLike: WallOverlapInput = {
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
}
// Cheap pre-filter: a wall that never reaches the slab's footprint
// can't elect it, whatever the election says about sibling slabs.
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
const support = computeWallSlabSupport(wallLike, substituted, levelWalls)
if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) {
return { elevation: bound, clamped: true }
}
}
return { elevation: proposedElevation, clamped: false }
}
/**
* Static upper bound for a slab-elevation drag: probe the election with
* the slab raised above every sibling and the storey plane. If any
* plane-bound wall would elect it there, the drag may not pass
* `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above.
*/
export function getSlabElevationUpperBound(
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): number {
const probe =
Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1
return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped
? storeyHeight - MIN_WALL_HEIGHT
: Number.POSITIVE_INFINITY
}
/**
* Point-in-polygon test using ray casting algorithm.
*/
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0],
zi = polygon[i]![1]
const xj = polygon[j]![0],
zj = polygon[j]![1]
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
function pointSegmentDistance(
px: number,
pz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number {
const dx = bx - ax
const dz = bz - az
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
}
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
// boundary: the answer flips depending on which side of the polygon the edge
// is on. Interval classification below therefore treats "within this distance
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
// edge (the common case — auto-slab polygons derive from wall centerlines)
// classify identically on every side of the slab.
const ON_BOUNDARY_EPSILON = 1e-4
export function pointOnPolygonBoundary(
px: number,
pz: number,
polygon: Array<[number, number]>,
): boolean {
const n = polygon.length
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i]!
const [bx, bz] = polygon[(i + 1) % n]!
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
}
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
for (let i = 0; i < n; i++) {
const [px, pz] = polygon[i]!
const [qx, qz] = polygon[(i + 1) % n]!
const ex = qx - px
const ez = qz - pz
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
const t = ((px - ax) * ez - (pz - az) * ex) / denom
const s = ((px - ax) * dz - (pz - az) * dx) / denom
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
}
ts.sort((a, b) => a - b)
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
if (t1 - t0 < 1e-9) continue
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
thickness?: number
}
// Minimum length of wall that must lie on/inside a slab polygon before the
// wall counts as overlapping it. Point contact (a perpendicular wall butting
// into a room's edge) clips to ~zero length and never reaches this, so such
// walls don't follow the slab's elevation.
const WALL_SLAB_MIN_OVERLAP = 0.05
/**
* Centerline of the wall plus its two face lines (centerline offset by
* ±halfThickness). The face lines catch walls whose centerline sits on or
* just outside the slab boundary but whose body reaches onto the slab —
* e.g. slab polygons drawn to the room's interior faces.
*/
function wallTestPolylines(
start: [number, number],
end: [number, number],
curveOffset: number,
halfThickness: number,
): Array<Array<{ x: number; y: number }>> {
const wallLike = { start, end, curveOffset }
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
const count = 16
const center: Array<{ x: number; y: number }> = []
const left: Array<{ x: number; y: number }> = []
const right: Array<{ x: number; y: number }> = []
for (let i = 0; i <= count; i++) {
const frame = getWallCurveFrameAt(wallLike, i / count)
center.push(frame.point)
left.push({
x: frame.point.x + frame.normal.x * halfThickness,
y: frame.point.y + frame.normal.y * halfThickness,
})
right.push({
x: frame.point.x - frame.normal.x * halfThickness,
y: frame.point.y - frame.normal.y * halfThickness,
})
}
return halfThickness > 0 ? [center, left, right] : [center]
}
const center = [
{ x: start[0], y: start[1] },
{ x: end[0], y: end[1] },
]
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const len = Math.hypot(dx, dz)
if (len < 1e-10 || halfThickness <= 0) return [center]
const nx = (-dz / len) * halfThickness
const nz = (dx / len) * halfThickness
return [
center,
[
{ x: start[0] + nx, y: start[1] + nz },
{ x: end[0] + nx, y: end[1] + nz },
],
[
{ x: start[0] - nx, y: start[1] - nz },
{ x: end[0] - nx, y: end[1] - nz },
],
]
}
/**
* Test whether a wall overlaps a slab polygon along a segment of its length.
*
* The wall's centerline and both face lines are clipped against the polygon;
* the wall overlaps when the longest clipped inside-or-on-boundary length
* exceeds a threshold (5cm, halved for very short walls). Because interval
* midpoints classify "on the boundary" as inside explicitly (never by
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
* identically on every side of the slab.
*
* A wall that only touches the polygon at a point — a perpendicular wall
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
* length and does NOT overlap.
*/
export function wallOverlapsPolygon(
startOrWall: [number, number] | WallOverlapInput,
endOrPolygon: [number, number] | Array<[number, number]>,
polygonArg?: Array<[number, number]>,
): boolean {
// Two call shapes:
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
let start: [number, number]
let end: [number, number]
let polygon: Array<[number, number]>
let curveOffset = 0
let thickness = DEFAULT_WALL_THICKNESS
if (Array.isArray(startOrWall)) {
start = startOrWall as [number, number]
end = endOrPolygon as [number, number]
polygon = polygonArg as Array<[number, number]>
} else {
start = startOrWall.start
end = startOrWall.end
curveOffset = startOrWall.curveOffset ?? 0
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
polygon = endOrPolygon as Array<[number, number]>
}
return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon)
}
/**
* {@link wallOverlapsPolygon} with the slab's stored holes subtracted from
* the covered length: a wall whose band only reaches the polygon inside a
* hole does not overlap. Hole boundaries keep coverage (rim convention —
* see {@link computeWallSlabSupport}). Polygon boundary contact counts as
* covered, so a wall sitting exactly on a slab edge resolves identically
* on every side of the slab. Pure.
*/
export function wallOverlapsSlabFootprint(
wallLike: WallOverlapInput,
polygon: Array<[number, number]>,
holes?: ReadonlyArray<Array<[number, number]>>,
): boolean {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
for (const line of polylines) {
let intervals = polylineInsideIntervals(line, polygon)
for (const hole of holes ?? []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
overlap = Math.max(overlap, intervalsLength(intervals))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
/**
* Tolerance for the pointer-decided support cap: a slab still counts as
* "the surface you're pointing at (or below)" when its walking surface is
* within this many meters ABOVE the pointed elevation. Absorbs elevation
* noise between the ray hit and slab tops without letting a deck hanging
* clearly above the hit point capture the election. Defined here (rather
* than in the spatial-grid manager, which re-exports it) so the wall
* election below can honour the same cap without an import cycle.
*/
export const SUPPORT_ELEVATION_EPSILON = 0.05
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Slab whose elevation won the election, or null when the wall has no support. */
electedSlabId: string | null
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
elevation: number
}
/**
* `preferredSlabId` is a persisted support host (`wall.supportSlabId`):
* while that slab is still in the candidate set (still overlaps the wall
* band with enough covered length), the elected `elevation` is pinned to
* it instead of the majority/best-coverage election. `baseSegments` /
* `baseElevation` (fill-down) still derive from ALL supporting slabs
* unchanged. A preferred slab that no longer qualifies is silently
* ignored — deliberately never cleared here, so the host resumes if the
* slab's polygon returns (only slab deletion strips the stored field).
*
* `maxElevation` is the pointer-decided support cap (level-local Y, same
* semantics as the item election): when set, elevation groups whose
* walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON`
* are excluded from the majority/best election — a deck hanging above the
* surface the cursor ray actually hit never captures the elected base.
* `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
* an explicit `preferredSlabId` still wins over the cap.
*/
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = {
elevation: number
slabIds: string[]
perPolyline: LengthInterval[][]
}
const groups: ElevationGroup[] = []
let preferredElevation: number | null = null
let preferredElectedSlabId: string | null = null
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
if (preferredSlabId != null && slab.id === preferredSlabId) {
preferredElevation = elevation
preferredElectedSlabId = slab.id
}
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) }
groups.push(group)
}
group.slabIds.push(slab.id)
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
const electableGroups =
maxElevation == null
? evaluatedGroups
: evaluatedGroups.filter(
(group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON,
)
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of electableGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
preferredElevation !== null
? preferredElevation
: majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const electedSlabId =
preferredElectedSlabId ??
electableGroups
.find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON)
?.slabIds.slice()
.sort()[0] ??
null
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, electedSlabId, baseElevation, baseSegments }
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
}
@@ -9,8 +9,10 @@ import type {
StairSegmentNode, StairSegmentNode,
SurfaceHoleMetadata, SurfaceHoleMetadata,
} from '../../schema' } from '../../schema'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { resolveCeilingHeight } from '../../services/level-height'
import { getLevelElevations } from '../../services/storey'
import { computeSegmentTransforms, rotateXZ } from './stair-footprint' import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
import { resolveStairTotalRise } from './stair-rise'
type SegmentTransform = { type SegmentTransform = {
position: [number, number, number] position: [number, number, number]
@@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface(
const layouts = getStraightStairLayouts(stair, nodes) const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return [] if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = [] const openingRects: AxisAlignedRect[] = []
@@ -605,17 +607,16 @@ function getTargetSlabElevationForStair(
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
) { ) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes) const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const slabLevel = getLevelNumber(slabLevelId, nodes) const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const slabElevation = elevations.get(slabLevelId)
if (fromLevel === undefined || slabLevel === undefined) { if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) {
return slab.elevation ?? 0.05 return slab.elevation ?? 0.05
} }
return ( return (
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT + slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0)
(slab.elevation ?? 0.05) -
(stair.position[1] ?? 0)
) )
} }
@@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair(
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
) { ) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes) const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const ceilingElevation = elevations.get(ceilingLevelId)
if (fromLevel === undefined || ceilingLevel === undefined) { const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record<AnyNodeId, AnyNode>)
return ceiling.height ?? DEFAULT_WALL_HEIGHT
if (
!fromElevation ||
!ceilingElevation ||
fromElevation.buildingId !== ceilingElevation.buildingId
) {
return ceilingHeight
} }
return ( return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0)
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
(stair.position[1] ?? 0)
)
} }
function shouldApplyStairToSlab( function shouldApplyStairToSlab(
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { AnyNode } from '../../schema' import type { AnyNode, AnyNodeId } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveNodeOverrides from '../../store/use-live-node-overrides'
import useLiveTransforms from '../../store/use-live-transforms' import useLiveTransforms from '../../store/use-live-transforms'
@@ -12,6 +12,7 @@ import {
hasLiveStairOpeningInputs, hasLiveStairOpeningInputs,
} from './stair-opening-preview' } from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync' import { syncAutoStairOpenings } from './stair-opening-sync'
import { syncStairRises } from './stair-rise'
function isOpeningRelevantNode(node: AnyNode | undefined) { function isOpeningRelevantNode(node: AnyNode | undefined) {
return ( return (
@@ -47,7 +48,7 @@ export const StairOpeningSystem = () => {
const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
useEffect(() => { useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => { const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }>) => {
if (updates.length === 0) return if (updates.length === 0) return
syncingAutoOpeningsRef.current = true syncingAutoOpeningsRef.current = true
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
@@ -103,14 +104,40 @@ export const StairOpeningSystem = () => {
) )
} }
const runAutoSync = () => {
// Rise first: straight stairs converge their flight heights to the
// resolved rise (level height or deck elevation), and the opening pass
// reads those segment heights — so it must run against the post-rise
// nodes.
applyUpdates(syncStairRises(useScene.getState().nodes))
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
}
let disposed = false
let autoSyncQueued = false
const scheduleAutoSync = () => {
if (autoSyncQueued) return
autoSyncQueued = true
// One microtask later so every other scene-store listener for the
// triggering transition (and, at mount, the editor's spatial-grid
// init) runs first — the spatial-grid sync in particular. The
// deck-attached rise elects the stair's floor-stack base elevation
// through the spatial grid; syncing before the grid listener would
// rescale flights against the pre-transition slab state.
queueMicrotask(() => {
autoSyncQueued = false
if (disposed) return
runAutoSync()
refreshLivePreview() refreshLivePreview()
})
}
scheduleAutoSync()
const unsubscribeScene = useScene.subscribe((state, prevState) => { const unsubscribeScene = useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoStairOpenings(state.nodes)) scheduleAutoSync()
refreshLivePreview()
}) })
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
@@ -122,6 +149,7 @@ export const StairOpeningSystem = () => {
}) })
return () => { return () => {
disposed = true
unsubscribeScene() unsubscribeScene()
unsubscribeLiveTransforms() unsubscribeLiveTransforms()
unsubscribeLiveOverrides() unsubscribeLiveOverrides()
@@ -0,0 +1,465 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import { z } from 'zod'
import {
GROUND_SUPPORT_ID,
getFloorPlacedElevation,
} from '../../hooks/spatial-grid/floor-placed-elevation'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, StairNode as StairNodeType } from '../../schema'
import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import { resolveStairTotalRise, syncStairRises } from './stair-rise'
// The deck branch elects the stair's floor-stack base through the node
// registry + spatial grid singletons — reset them so tests are hermetic
// (base elects 0 unless a test registers a stair footprint and slabs).
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
function buildScene(levelHeight: number | undefined, totalRise: number | undefined) {
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
...(totalRise !== undefined ? { totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
children: ['stair_1'],
...(levelHeight !== undefined ? { height: levelHeight } : {}),
})
return { stair, nodes: { level_1: level, stair_1: stair } }
}
function makeDeck(elevation: number, polygon?: Array<[number, number]>) {
return SlabNode.parse({
id: 'slab_deck',
type: 'slab',
polygon: polygon ?? [
[0, 0],
[2, 0],
[2, 2],
[0, 2],
],
elevation,
thickness: 0.05,
})
}
function buildDeckScene(options: {
deckElevation: number
deckPolygon?: Array<[number, number]>
totalRise?: number
deckSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const deck = makeDeck(options.deckElevation, options.deckPolygon)
const segments = (options.segments ?? []).map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
deckSlabId: options.deckSlabId ?? deck.id,
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: 2.5,
children: ['stair_1', deck.id],
})
const nodes: Record<string, AnyNode> = {
level_1: level,
stair_1: stair,
[deck.id]: deck,
}
for (const segment of segments) nodes[segment.id] = segment
return { deck, stair, nodes }
}
function buildLevelSceneWithSegments(options: {
levelHeight: number
totalRise?: number
segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const segments = options.segments.map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: options.levelHeight,
children: ['stair_1'],
})
const nodes: Record<string, AnyNode> = { level_1: level, stair_1: stair }
for (const segment of segments) nodes[segment.id] = segment
return { level, stair, nodes }
}
describe('resolveStairTotalRise', () => {
it('derives the rise from the containing level stored height when absent', () => {
const { stair, nodes } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(3.2)
})
it('tracks a storey height change without any stair write', () => {
const { stair, nodes } = buildScene(2.55, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.55)
const level = nodes.level_1
if (level.type !== 'level') throw new Error('expected level')
const updated = { ...nodes, level_1: { ...level, height: 3.0 } }
expect(resolveStairTotalRise(stair, updated)).toBe(3.0)
})
it('prefers an explicit totalRise over the storey height', () => {
const { stair, nodes } = buildScene(3.2, 2.5)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
it('falls back to the default when the stair has no containing level', () => {
const { stair } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, {})).toBe(2.5)
})
it('derives the rise from the attached deck elevation', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
expect(resolveStairTotalRise(stair, nodes)).toBe(1.25)
})
it('tracks a deck elevation change without any stair write', () => {
const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } }
expect(resolveStairTotalRise(stair, updated)).toBe(1.6)
})
it('prefers an explicit totalRise over the attached deck', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('falls through a stale deckSlabId to the storey height silently', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
})
describe('syncStairRises', () => {
it('writes the deck elevation into a single flight segment', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }])
})
it('is a no-op when the flights already match the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('scales multiple flights proportionally and leaves landings alone', () => {
const { nodes } = buildDeckScene({
deckElevation: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('distributes an explicit custom rise instead of the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }])
})
it('falls a stale deckSlabId back to the storey height', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a stale-deck stair with an explicit rise untouched', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('converges a level-following straight stair to the storey height', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('converges a level-following stair after a storey height change', () => {
const scene = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }])
})
it('rescales level-following flights proportionally, landings untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('converges back to the storey height after a deck detach', () => {
const scene = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const { deckSlabId: _deckSlabId, ...detached } = scene.stair
const nodes = { ...scene.nodes, stair_1: detached as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a detached explicit-rise stair with hand-set segments untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
totalRise: 2.0,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.9 },
{ id: 'sseg_2', segmentType: 'stair', height: 0.6 },
],
})
expect(syncStairRises(nodes)).toEqual([])
})
})
// The stair stands on a floor slab (the default 0.05 one, or whatever the
// floor-stack elects) — the deck-derived rise must be measured from that
// lifted base so the last step lands flush with the deck's walking surface.
describe('deck-attached rise with a floor-lifted base', () => {
const FLOOR_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
// Away from the stair footprint at the origin so the base election never
// sees the deck itself.
const AWAY_DECK_POLYGON: Array<[number, number]> = [
[8, 8],
[10, 8],
[10, 10],
[8, 10],
]
beforeEach(() => {
registerNode({
kind: 'stair',
schemaVersion: 1,
schema: z.object({ type: z.literal('stair') }) as never,
category: 'structure',
defaults: () => ({}) as never,
capabilities: {
floorPlaced: {
footprints: (node) => [
{
position: (node as StairNodeType).position,
dimensions: [1, 1, 2] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
},
],
},
},
} as AnyNodeDefinition)
})
function makeFloorSlab(elevation: number) {
return SlabNode.parse({
id: 'slab_floor',
type: 'slab',
polygon: FLOOR_POLYGON,
elevation,
thickness: 0.05,
})
}
function buildLiftedDeckScene(options: {
deckElevation: number
floorElevation?: number
totalRise?: number
supportSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const floor = makeFloorSlab(options.floorElevation ?? 0.05)
const scene = buildDeckScene({
deckElevation: options.deckElevation,
deckPolygon: AWAY_DECK_POLYGON,
totalRise: options.totalRise,
segments: options.segments,
})
const stair = options.supportSlabId
? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair)
: scene.stair
const nodes: Record<string, AnyNode> = {
...scene.nodes,
stair_1: stair,
[floor.id]: floor,
}
spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1')
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
return { deck: scene.deck, floor, stair, nodes }
}
it('lands the last step flush: rise = deck elevation elected base', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 })
const base = getFloorPlacedElevation({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: 'level_1',
})
expect(base).toBeCloseTo(0.05)
const rise = resolveStairTotalRise(stair, nodes)
expect(rise).toBeCloseTo(1.2)
// Top surface = visual base + rise = the deck's walking surface, not 1.30.
expect(base + rise).toBeCloseTo(1.25)
})
it('rescales a flight converged under the old rule down to the flush rise', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2)
})
it('keeps the full deck elevation when the stair stands on bare ground', () => {
const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON })
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25)
})
it('lets an explicit totalRise win over the base-adjusted deck rise', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('re-converges to flush after a deck elevation change', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const movedDeck = { ...scene.deck, elevation: 1.6 }
const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode }
spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55)
})
it('re-converges to flush after the base slab elevation changes', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
const movedFloor = { ...scene.floor, elevation: 0.3 }
const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode }
spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95)
})
it('rescales flights proportionally from the lifted base, landings untouched', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 2.15,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
// Target flight rise = 2.15 0.05 (base) 0.1 (landing) = 2.0 → 1.0 each.
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0)
expect(updates[1]?.id).toBe('sseg_3' as never)
expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0)
})
it('honors a persisted ground host over the floor slab election', () => {
const { stair, nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
supportSlabId: GROUND_SUPPORT_ID,
})
expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25)
})
})
@@ -0,0 +1,90 @@
import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height'
import { getStoredLevelHeight } from '../../services/storey'
export function resolveStairTotalRise(stair: StairNode, nodes: Record<string, AnyNode>): number {
if (stair.totalRise !== undefined) return stair.totalRise
const level = Object.values(nodes).find(
(node) => node.type === 'level' && node.children.includes(stair.id),
)
if (stair.deckSlabId) {
const deck = nodes[stair.deckSlabId]
// The deck's `elevation` IS its walking surface (level-local), but the
// stair's own base may be lifted onto a floor slab by the floor-stack
// (`FloorElevationSystem` / `syncStairGroupElevation` put the group at
// `position[1] + elected slab elevation`). The rise is measured from
// that base, so subtract it — electing the base exactly the way the
// visual systems do (persisted `supportSlabId` honored, uncapped
// election otherwise) keeps base + rise landing precisely on the deck's
// walking surface. A stale reference (deck gone) falls through to the
// level-derived rise.
if (deck?.type === 'slab') {
const baseElevation = getFloorStackedPosition({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: level?.id ?? null,
})[1]
return (deck.elevation ?? 0.05) - baseElevation
}
}
return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT
}
const RISE_SYNC_EPSILON = 1e-4
/**
* Keeps straight stairs' flight segments in step with the resolved rise.
* Straight-stair geometry derives from per-segment heights (not from
* `resolveStairTotalRise`), so level-height and deck-elevation changes must
* write through to the flight segments — curved/spiral stairs read the
* resolved rise directly and need no sync.
*
* Scope: stairs whose total the system owns — follows-mode stairs (absent
* `totalRise`, tracking their level or their deck) and deck-attached stairs
* (an explicit rise converges to the typed value). A detached stair with an
* explicit `totalRise` is the one place hand-edited segment chains are
* legitimate, so it is never touched. Flight heights scale proportionally
* (landings keep theirs); returns `updateNodes` patches, empty when every
* stair is already in step.
*/
export function syncStairRises(
nodes: Record<string, AnyNode>,
): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
for (const node of Object.values(nodes)) {
if (node.type !== 'stair' || node.stairType !== 'straight') continue
const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined
if (node.totalRise !== undefined && deck?.type !== 'slab') continue
const segments = (node.children ?? [])
.map((childId) => nodes[childId])
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
const flights = segments.filter((segment) => segment.segmentType === 'stair')
if (flights.length === 0) continue
const landingRise = segments
.filter((segment) => segment.segmentType !== 'stair')
.reduce((sum, segment) => sum + segment.height, 0)
const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0)
const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise
if (targetFlightRise <= 0) continue
if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue
for (const flight of flights) {
const height =
flightRise > RISE_SYNC_EPSILON
? flight.height * (targetFlightRise / flightRise)
: targetFlightRise / flights.length
updates.push({ id: flight.id as AnyNodeId, data: { height } })
}
}
return updates
}
+1 -1
View File
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
} }
} }
function getWallArcData(wall: WallCurveLike) { export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall) const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall) const sagitta = getClampedWallCurveOffset(wall)
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true, visible: true,
parentId: 'level_test', parentId: 'level_test',
children: [], children: [],
assemblyLayers: [],
start, start,
end, end,
thickness: 0.1, thickness: 0.1,
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top'
describe('resolveWallTop', () => {
test('explicit height on zero base keeps the stored top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('explicit height on raised base rides the base', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1)
})
test('explicit height on sunken base keeps the absolute top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5)
})
test('plane-bound wall tops out at the storey plane regardless of base', () => {
expect(resolveWallTop({}, 3, 0)).toBe(3)
expect(resolveWallTop({}, 3, 0.6)).toBe(3)
expect(resolveWallTop({}, 3, -0.4)).toBe(3)
})
})
describe('resolveWallEffectiveHeight', () => {
test('explicit on raised base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5)
})
test('explicit on zero base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('plane-bound on raised base gets shorter, never taller', () => {
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4)
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3)
})
test('plane-bound on zero base spans the full storey', () => {
expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3)
})
test('plane-bound on sunken base fills down while the top stays at the plane', () => {
expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4)
})
})
@@ -0,0 +1,53 @@
import type { WallNode } from '../../schema/nodes/wall'
/**
* Minimum wall body height in meters. Governs both the wall height
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
* elects it as its base, or the wall's extrusion (plane minus base)
* would collapse below this minimum.
*/
export const MIN_WALL_HEIGHT = 0.5
/**
* Wall-top inversion (vertical building model): a wall with no stored
* `height` is plane-bound — its top sits at the storey plane (level-local
* Y = the level's stored height), so a slab lifting the wall's base makes
* the wall shorter, never taller, and no gap can open at the top of a
* level. A wall WITH `height` is an explicit exception (half wall,
* parapet) and keeps the legacy semantics: the top rides a raised elected
* base (`electedBase + height`), while a zero or sunken base leaves the
* top at `height` (the legacy negative-slab constraint).
*
* Returns the top in level-local Y (same frame as `electedBase`).
*/
export function resolveWallTop(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
if (wall.height == null) return storeyHeight
return electedBase > 0 ? electedBase + wall.height : wall.height
}
/**
* Extruded height of the wall body: {@link resolveWallTop} minus the
* elected base. Base convention: the elected slab-support elevation itself
* — the viewer computes `effectiveBaseElevation = min(baseElevation,
* slabElevation)` and defaults `baseElevation` to the elected elevation,
* so with only the election in hand the two coincide. Fill-down below the
* elected base (`baseSegments`) is a geometry detail the extruder handles
* separately and never changes where the top sits.
*
* Equivalently: the wall-local Y of the wall's top, measured from the wall
* mesh origin (which sits at `electedBase`). May be non-positive when a
* slab reaches the storey plane; callers own the degenerate-geometry
* policy.
*/
export function resolveWallEffectiveHeight(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
return resolveWallTop(wall, storeyHeight, electedBase) - electedBase
}
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { CollectionId } from '../schema/collections' import type { CollectionId } from '../schema/collections'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { forkSceneGraph, type SceneGraph } from './clone-scene-graph' import {
cloneLevelSubtree,
cloneSceneGraph,
forkSceneGraph,
type SceneGraph,
} from './clone-scene-graph'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode { function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return { return {
@@ -71,3 +76,163 @@ describe('forkSceneGraph', () => {
expect(forked.installedPlugins).toEqual(['pascal:trees']) expect(forked.installedPlugins).toEqual(['pascal:trees'])
}) })
}) })
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'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const cloned = cloneSceneGraph({
nodes: {
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: item,
},
rootNodeIds: ['level_1' as AnyNodeId],
})
const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')!
const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')!
expect(clonedSlab.id).not.toBe('slab_1')
expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id)
})
test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const external = makeNode('item_2', 'item', {
parentId: 'level_1',
supportSlabId: 'slab_external',
})
const { clonedNodes, idMap } = cloneLevelSubtree(
{
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: hosted,
['item_2' as AnyNodeId]: external,
},
'level_1' as AnyNodeId,
)
const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))!
const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))!
expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!)
expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external')
})
})
+50 -3
View File
@@ -1,7 +1,12 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry' import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema' import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base' import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections' import type { Collection, CollectionId } from '../schema/collections'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = { export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
@@ -44,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) { for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId const newId = idMap.get(oldId)! as AnyNodeId
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId // Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -85,9 +90,33 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
) as string | undefined ) as string | undefined
} }
// Remap supportSlabId (persisted slab-support hosts). The 'ground'
// sentinel is not a node id — keep it as-is.
if (
'supportSlabId' in clonedNode &&
typeof clonedNode.supportSlabId === 'string' &&
clonedNode.supportSlabId !== GROUND_SUPPORT_ID
) {
;(clonedNode as Record<string, unknown>).supportSlabId = idMap.get(
clonedNode.supportSlabId,
) as string | undefined
}
if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') {
;(clonedNode as Record<string, unknown>).deckSlabId = idMap.get(clonedNode.deckSlabId) as
| string
| undefined
}
if (clonedNode.type === 'measurement') { if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
} }
if (clonedNode.type === 'construction-dimension') {
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
}
if (clonedNode.type === 'drawing-sheet') {
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
}
clonedNodes[newId] = clonedNode clonedNodes[newId] = clonedNode
} }
@@ -202,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc. // JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId ;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself // Remap parentId — but only for descendants, not the level node itself
@@ -240,9 +269,27 @@ export function cloneLevelSubtree(
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
} }
// Remap supportSlabId when the host slab is inside the cloned subtree;
// preserve it otherwise (like wallId, the reference may point outside).
if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') {
;(cloned as Record<string, unknown>).supportSlabId =
idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId
}
if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') {
;(cloned as Record<string, unknown>).deckSlabId =
idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId
}
if (cloned.type === 'measurement') { if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
} }
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
clonedNodes.push(cloned) clonedNodes.push(cloned)
} }
+4 -2
View File
@@ -40,16 +40,16 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0", "@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0", "pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8", "three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -59,8 +59,10 @@
"@pascal-app/core": "^0.9.2", "@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.2", "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides) const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit) const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const ctx = useFloorplanRender() const ctx = useFloorplanRender()
if (guides.length === 0) return null if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X. // offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeasurement(distMeters, unit) const distLabel = formatMeasurement(distMeters, unit, metricNotation)
const charWidth = pillFontSize * 0.55 const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2 const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2 const pillHeight = pillFontSize + pillPadY * 2
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
const draftLevelId = useMeasurementDraft((state) => state.levelId) const draftLevelId = useMeasurementDraft((state) => state.levelId)
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
useEffect(() => { useEffect(() => {
if (active) { if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
angle: Math.atan2(end[2] - start[2], end[0] - start[0]), angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2], point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
screenUpright: false, screenUpright: false,
text: formatLinearMeasurement(measurementDistance(start, end), unit), text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
} }
} else if (kind === 'angle' && livePoints.length >= 3) { } else if (kind === 'angle' && livePoints.length >= 3) {
const anglePoints = livePoints.slice(0, 3) as [ const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
text: text:
kind === 'area' kind === 'area'
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}` ? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`, : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
} }
} }
} else if (kind === 'volume' && center && baseNormal) { } else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
(segmentStart[1] + segmentEnd[1]) / 2, (segmentStart[1] + segmentEnd[1]) / 2,
(segmentStart[2] + segmentEnd[2]) / 2, (segmentStart[2] + segmentEnd[2]) / 2,
], ],
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit), text: formatLinearMeasurement(
measurementDistance(segmentStart, segmentEnd),
unit,
metricNotation,
),
} }
} }
} }
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
polygonPoints, polygonPoints,
segmentLabel, segmentLabel,
} }
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag]) }, [
axisGuide,
baseNormal,
extrusionHeight,
hover,
kind,
metricNotation,
points,
stage,
unit,
vertexDrag,
])
if (smartActive) return <FloorplanQuickMeasureLayer /> if (smartActive) return <FloorplanQuickMeasureLayer />
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
text={`${hover.semantic.label}${ text={`${hover.semantic.label}${
hover.semantic.length === null hover.semantic.length === null
? '' ? ''
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}` : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
}`} }`}
textColor={labelText} textColor={labelText}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
@@ -0,0 +1,61 @@
'use client'
import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
import {
type FloorplanToolContext,
getFloorplanNodeExtension,
} from '../../lib/floorplan/floorplan-extension'
import useEditor from '../../store/use-editor'
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
if (!tool) return null
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
if (!loader) return null
const cached = lazyToolCache.get(loader)
if (cached) return cached
const component = lazy(loader)
lazyToolCache.set(loader, component)
return component
}
export function FloorplanRegisteredToolLayer() {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const toolDefaults = useEditor((state) =>
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
const sceneApi = useMemo(() => createSceneApi(useScene), [])
const selectNode = useCallback(
(id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
useViewer.getState().setSelection({ selectedIds: [id] }),
[],
)
const finishTool = useCallback(() => {
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}, [])
if (mode !== 'build') return null
const Tool = registeredFloorplanTool(tool)
return Tool ? (
<Suspense fallback={null}>
<Tool
activeLevelId={activeLevelId}
finishTool={finishTool}
gridSnapStep={gridSnapStep}
metricNotation={metricNotation}
sceneApi={sceneApi}
selectNode={selectNode}
toolDefaults={toolDefaults}
unit={unit}
/>
</Suspense>
) : null
}
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion' import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation' import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import { import {
createFreshPlacementSubtree, createFreshPlacementSubtree,
duplicatesAsFreshSubtree, duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement' } from '../../lib/fresh-planar-placement'
import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope' import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu' import { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref' import { IconRefGlyph } from '../ui/icon-ref'
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path. * `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* Walls are excluded — their move is reached via the side-arrow * Walls are excluded — their move is reached via the side-arrow
* handles emitted from `def.floorplan`, not via a menu button. * handles emitted from `def.floorplan`, not via a menu button.
* - Curve (wall only): enters curve reshape mode. The selected wall's
* midpoint curve handle remains visible so it can be dragged in plan.
* - Add hole (slab + ceiling only): inserts a small default-square * - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy * hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`. * `handleAddHole` in `floating-action-menu.tsx`.
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's * - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def. * `relations.cascadeDelete` if declared on the def.
* *
* Hidden while in a move state (so we don't show buttons over a ghost). * Hidden while moving or curving so the menu does not compete with the active affordance.
*/ */
export function FloorplanRegistryActionMenu() { export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion() const reducedMotion = useReducedMotion()
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined, s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
) as AnyNodeId | undefined ) as AnyNodeId | undefined
const movingNode = useMovingNode() const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D // Gate on floorplan hover so this 2D menu never coexists with the 3D
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
// Only show for registered kinds (skip legacy kinds — they have their // Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries). // own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const canCurve = useScene((s) => {
if (!selectedId) return false
const selectedNode = s.nodes[selectedId]
if (!selectedNode) return false
const definition = nodeRegistry.get(selectedNode.type)
const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
return (
!!definition?.floorplanAffordances?.curve &&
!!canCurveNode?.({
node: selectedNode as never,
nodes: s.nodes,
})
)
})
const def = selectedKind ? nodeRegistry.get(selectedKind) : null const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def const isRegistryKind = !!def
const isVisible = const isVisible =
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered isRegistryKind &&
def?.presentation?.actionMenu !== false &&
!movingNode &&
!isCurveReshape &&
isFloorplanHovered
const isWall = selectedKind === 'wall' const isWall = selectedKind === 'wall'
const quickActionNodes = useScene( const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
) )
} }
const handleCurve = () => {
if (!canCurve) return
sfxEmitter.emit('sfx:item-pick')
useInteractionScope.getState().begin(curveReshapeScope(node.id))
}
const handleDuplicate = () => { const handleDuplicate = () => {
if (!node.parentId) return if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined} onAddHole={canAddHole ? handleAddHole : undefined}
onCurve={canCurve ? handleCurve : undefined}
onDelete={canDelete ? handleDelete : undefined} onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined} onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined} onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,467 @@
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 animationFrames: FrameRequestCallback[] = []
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) => {
animationFrames.push(callback)
return animationFrames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
const flushAnimationFrame = () => {
const callbacks = animationFrames
animationFrames = []
for (const callback of callbacks) callback(0)
}
let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1
})
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(0)
flushAnimationFrame()
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
}
})
test('waits for a quiet frame instead of resolving on every mutation frame', () => {
const OriginalMutationObserver = globalThis.MutationObserver
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined
let animationFrames: FrameRequestCallback[] = []
class FakeMutationObserver {
constructor(callback: MutationCallback) {
notify = callback
}
observe(): void {}
disconnect(): void {}
takeRecords(): MutationRecord[] {
return []
}
}
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
const flushAnimationFrame = () => {
const callbacks = animationFrames
animationFrames = []
for (const callback of callbacks) callback(0)
}
let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1
})
for (let frame = 0; frame < 30; frame += 1) {
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
flushAnimationFrame()
}
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(1)
stop()
} finally {
globalThis.MutationObserver = OriginalMutationObserver
globalThis.requestAnimationFrame = originalRequestAnimationFrame
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
}
})
})
@@ -0,0 +1,695 @@
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(target: Node, onChange: () => void): () => void {
let scheduledFrame: number | null = null
let mutationVersion = 0
let observedVersion = 0
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
const flushWhenSettled = () => {
if (observedVersion !== mutationVersion) {
observedVersion = mutationVersion
scheduledFrame = requestFrame(flushWhenSettled)
return
}
scheduledFrame = null
onChange()
}
const schedule = () => {
mutationVersion += 1
if (scheduledFrame !== null) return
observedVersion = mutationVersion - 1
scheduledFrame = requestFrame(flushWhenSettled)
}
const observer = new MutationObserver((mutations) => {
if (mutations.some(isAnnotationLayoutMutation)) schedule()
})
observer.observe(target, {
attributes: true,
attributeFilter: [
'cx',
'cy',
'd',
'dominant-baseline',
'font-family',
'font-size',
'font-weight',
'height',
'points',
'r',
'rx',
'ry',
'stroke-width',
'text-anchor',
'transform',
'visibility',
'width',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
],
characterData: true,
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (scheduledFrame === null) return
if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
else clearTimeout(scheduledFrame)
}
}
function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
if (mutation.type !== 'attributes') return true
const attribute = mutation.attributeName ?? ''
const target = mutation.target as Element
const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
if (
attribute === 'data-floorplan-annotation-id' ||
attribute === 'data-floorplan-annotation-layout-dx' ||
attribute === 'data-floorplan-annotation-layout-dy' ||
attribute === 'data-floorplan-layout-unresolved'
) {
return false
}
if (
closest?.('[data-floorplan-annotation-label]') &&
(attribute === 'style' || attribute === 'transform')
) {
return false
}
if (
closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
(attribute === 'x1' ||
attribute === 'x2' ||
attribute === 'y1' ||
attribute === 'y2' ||
attribute === 'visibility')
) {
return false
}
return true
}
export function collectAnnotationLayoutPreflightIssues(
rectangles: readonly AnnotationLabelRectangle[],
shifts: readonly AnnotationLabelShift[],
obstacles: readonly AnnotationObstacleRectangle[] = [],
): AnnotationPreflightIssue[] {
const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
const finalRectangles = rectangles.map((rectangle) => {
const shift = shiftsById.get(rectangle.id) ?? {
id: rectangle.id,
dx: 0,
dy: 0,
resolved: false,
}
return {
source: rectangle,
shift,
bounds: {
x: rectangle.x + shift.dx,
y: rectangle.y + shift.dy,
width: rectangle.width,
height: rectangle.height,
},
}
})
const issues: AnnotationPreflightIssue[] = []
const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
issues.push({ id, kind, severity: 'warning', message })
}
for (const entry of finalRectangles) {
const label = preflightLabel(entry.source)
if (entry.source.labelPlacement === 'outside-end') {
addIssue(
entry.source.id,
'short-unreadable-segment',
`${label} is too short for inline text and uses an outside label or leader.`,
)
}
if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
addIssue(
entry.source.id,
'plan-geometry-conflict',
`${label} still conflicts with fixed plan geometry after automatic layout.`,
)
}
if (!entry.shift.resolved) {
const collidesWithLabel = finalRectangles.some(
(candidate) =>
candidate.source.id !== entry.source.id &&
rectanglesOverlap(entry.bounds, candidate.bounds),
)
if (collidesWithLabel) {
addIssue(
entry.source.id,
'unresolved-collision',
`${label} still overlaps another annotation after automatic layout.`,
)
}
}
}
return issues
}
function preflightLabel(rectangle: AnnotationLabelRectangle): string {
const text = rectangle.text?.trim()
return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
}
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
const explicit = label.dataset.floorplanAnnotationId?.trim()
if (explicit) return explicit
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
const text = label.textContent?.trim() ?? ''
return `annotation:${index}:${text}:${defaultTransform}`
}
function resetDimensionConnectors(svg: SVGSVGElement): void {
for (const line of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-line]')) {
line.setAttribute(
'x1',
line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0',
)
line.setAttribute(
'y1',
line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0',
)
line.setAttribute(
'x2',
line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0',
)
line.setAttribute(
'y2',
line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0',
)
}
for (const leader of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-leader]')) {
leader.setAttribute('visibility', 'hidden')
}
}
function applyOutsideStartDimensionLine(label: SVGGElement): void {
const dimension = label.closest('[data-floorplan-dimension]')
const line = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
if (!line) return
const { dataset } = line
if (
dataset.floorplanDimensionOutsideStartX1 === undefined ||
dataset.floorplanDimensionOutsideStartY1 === undefined ||
dataset.floorplanDimensionOutsideStartX2 === undefined ||
dataset.floorplanDimensionOutsideStartY2 === undefined
) {
return
}
line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1)
line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1)
line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2)
line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2)
}
function showDimensionLeader(
label: SVGGElement,
labelMatrix: DOMMatrix,
dx: number,
dy: number,
): void {
const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null
const leader = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-leader]')
const dimensionLine = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
const dimensionMatrix = dimension?.getScreenCTM()
if (!leader || !dimensionLine || !dimensionMatrix) return
const start = dimensionEndpoint(label, 'start')
const end = dimensionEndpoint(label, 'end')
if (!start || !end) return
dimensionLine.setAttribute('x1', String(start.x))
dimensionLine.setAttribute('y1', String(start.y))
dimensionLine.setAttribute('x2', String(end.x))
dimensionLine.setAttribute('y2', String(end.y))
const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy }
const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y)
const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y)
const anchor =
Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) <
Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y)
? start
: end
const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y)
leader.setAttribute('x1', String(anchor.x))
leader.setAttribute('y1', String(anchor.y))
leader.setAttribute('x2', String(labelPoint.x))
leader.setAttribute('y2', String(labelPoint.y))
leader.setAttribute('visibility', 'visible')
}
function dimensionEndpoint(
label: SVGGElement,
endpoint: 'start' | 'end',
): { x: number; y: number } | null {
const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`])
const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`])
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
}
function localPointToScreen(matrix: DOMMatrix, x: number, y: number) {
return {
x: matrix.a * x + matrix.c * y + matrix.e,
y: matrix.b * x + matrix.d * y + matrix.f,
}
}
function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) {
const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f)
return { x: local.x, y: local.y }
}
function svgAnnotationObstacleRectangles(
obstacle: SVGGraphicsElement,
): AnnotationObstacleRectangle[] {
const bounds = obstacle.getBoundingClientRect()
const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }]
if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback
const geometry = obstacle as SVGGeometryElement
const matrix = geometry.getScreenCTM()
if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback
try {
const length = geometry.getTotalLength()
const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d))
const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX))
const points = Array.from({ length: sampleCount + 1 }, (_, index) => {
const point = geometry.getPointAtLength((length * index) / sampleCount)
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}
})
return polylineObstacleRectangles(points)
} catch {
return fallback
}
}
export function polylineObstacleRectangles(
points: readonly { x: number; y: number }[],
): AnnotationObstacleRectangle[] {
if (points.length === 1) {
const point = points[0]!
return [
{
x: point.x - OUTLINE_OBSTACLE_PADDING_PX,
y: point.y - OUTLINE_OBSTACLE_PADDING_PX,
width: OUTLINE_OBSTACLE_PADDING_PX * 2,
height: OUTLINE_OBSTACLE_PADDING_PX * 2,
},
]
}
const rectangles: AnnotationObstacleRectangle[] = []
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1]!
const end = points[index]!
rectangles.push({
x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX,
y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX,
width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2,
height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2,
})
}
return rectangles
}
function resolveLabelShift(
rectangle: AnnotationLabelRectangle,
occupied: AnnotationObstacleIndex,
): { dx: number; dy: number } | undefined {
const candidates = new Map<string, { dx: number; dy: number; preference: number; cost: number }>()
const visited = new Set<string>()
const addCandidate = (dx: number, dy: number, preference = 0) => {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return
const key = `${dx}:${dy}`
if (visited.has(key)) return
const existing = candidates.get(key)
if (!existing || preference < existing.preference) {
candidates.set(key, {
dx,
dy,
preference,
cost: candidateCost({ dx, dy, preference }, rectangle),
})
}
}
addCandidate(0, 0, -2)
for (const preferred of rectangle.preferredShifts ?? []) {
addCandidate(preferred.dx, preferred.dy, -1)
}
while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) {
let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined
for (const queued of candidates.values()) {
if (!candidate || queued.cost < candidate.cost) candidate = queued
}
if (!candidate) return undefined
const key = `${candidate.dx}:${candidate.dy}`
candidates.delete(key)
visited.add(key)
const shifted = {
...rectangle,
x: rectangle.x + candidate.dx,
y: rectangle.y + candidate.dy,
}
const blockers = occupied.findOverlaps(shifted)
if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy }
const blockerBounds = blockers.reduce(
(bounds, blocker) => ({
minX: Math.min(bounds.minX, blocker.x),
minY: Math.min(bounds.minY, blocker.y),
maxX: Math.max(bounds.maxX, blocker.x + blocker.width),
maxY: Math.max(bounds.maxY, blocker.y + blocker.height),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
)
const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x
const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x
const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y
const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y
addCandidate(candidate.dx, above)
addCandidate(candidate.dx, below)
addCandidate(left, candidate.dy)
addCandidate(right, candidate.dy)
addCandidate(left, above)
addCandidate(right, above)
addCandidate(left, below)
addCandidate(right, below)
}
return undefined
}
function candidateCost(
candidate: { dx: number; dy: number; preference?: number },
rectangle: AnnotationLabelRectangle,
): number {
const distance = Math.hypot(candidate.dx, candidate.dy)
const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP
if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) {
return preference + distance
}
const perpendicularMovement = Math.abs(
candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX,
)
return preference + distance + perpendicularMovement * 4
}
export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean {
if (floorplanAnnotationObstacleMode(geometry)) return true
if (geometry.kind !== 'group') return false
const hasPlate = geometry.children.some(
(child) => child.kind === 'rect' || child.kind === 'circle',
)
const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright)
return hasPlate && hasUprightText
}
export function floorplanAnnotationObstacleMode(
geometry: FloorplanGeometry,
): 'bounds' | 'outline' | '' | undefined {
const metadata = readFloorplanGeometryMetadata(geometry)
if (metadata.annotationObstacle) return metadata.annotationObstacle
switch (metadata.annotationRole) {
case 'room-label':
return geometry.kind === 'text' ? 'bounds' : undefined
case 'column-center':
return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined
case 'stair-annotation':
return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds'
default:
return undefined
}
}
function rectanglesOverlap(
left: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
right: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
): boolean {
return !(
left.x + left.width + LABEL_GAP_PX <= right.x ||
right.x + right.width + LABEL_GAP_PX <= left.x ||
left.y + left.height + LABEL_GAP_PX <= right.y ||
right.y + right.height + LABEL_GAP_PX <= left.y
)
}
function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
return {
x: (matrix.d * dx - matrix.c * dy) / determinant,
y: (-matrix.b * dx + matrix.a * dy) / determinant,
}
}
@@ -0,0 +1,236 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import {
computeArchitecturalDimensionLayout,
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
floorplanDimensionAnnotationPriority,
} from './floorplan-dimension-renderer'
const dimension = {
kind: 'dimension',
start: [0, 0],
end: [4, 0],
offsetNormal: [0, 1],
offsetDistance: 0.45,
extensionOvershoot: 0.12,
text: `13'-1 1/2"`,
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
describe('architectural floor-plan dimensions', () => {
test('leaves a paper-space gap before solid extension lines', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout).not.toBeNull()
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 0.075])
expect(layout?.extensionStartTip[0]).toBe(0)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57)
expect(layout?.extensionEndTip[0]).toBe(4)
expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57)
expect(layout?.dimensionStart).toEqual([0, 0.45])
expect(layout?.dimensionEnd).toEqual([4, 0.45])
expect(layout?.dimensionLineEnd).toEqual([4, 0.45])
expect(layout?.labelPlacement).toBe('inside')
})
test('builds consistent 45-degree architectural slash terminators', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5)
expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5)
})
test('honors dimension standard overrides for gaps, terminators, and text placement', () => {
const customDimension = {
...dimension,
extensionStartGap: 0.2,
terminator: 'dot',
textPosition: 'centered',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
const layout = computeArchitecturalDimensionLayout(customDimension, 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={customDimension} />
</svg>,
)
expect(layout?.extensionStart).toEqual([0, 0.2])
expect(markup).toContain('<circle')
expect(markup).toContain('y="0.0525"')
})
test('moves a short value beyond its end tick and extends the dimension line', () => {
const shortDimension = {
...dimension,
end: [0.23, 0] as [number, number],
text: '0.23m',
}
const layout = computeArchitecturalDimensionLayout(shortDimension, 0)
expect(layout?.labelPlacement).toBe('outside-end')
expect(layout?.labelPoint[0]).toBeGreaterThan(0.23)
expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0)
expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan(
layout?.outsideStartLabelPoint?.[0] ?? 0,
)
expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0)
expect(layout?.dimensionEnd).toEqual([0.23, 0.45])
const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01)
expect(documentLayout?.labelPlacement).toBe('outside-end')
expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={shortDimension} sceneRotationDeg={37} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
expect(markup).not.toContain('data-floorplan-dimension-leader=""')
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer
annotationUnitsPerPoint={0.01}
geometry={shortDimension}
sceneRotationDeg={37}
/>
</svg>,
)
expect(documentMarkup).toContain('data-floorplan-dimension-leader=""')
expect(documentMarkup).toContain('visibility="hidden"')
})
test('aligns stepped feature origins to an explicit exterior baseline', () => {
const layout = computeArchitecturalDimensionLayout(
{
...dimension,
start: [0, 0],
end: [4, 1],
dimensionStart: [0, 2],
dimensionEnd: [4, 2],
offsetDistance: 2,
},
0,
)
expect(layout?.dimensionStart).toEqual([0, 2])
expect(layout?.dimensionEnd).toEqual([4, 2])
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 1.075])
expect(layout?.extensionStartTip).toEqual([0, 2.12])
expect(layout?.extensionEndTip).toEqual([4, 2.12])
})
test('renders one uninterrupted line with the label above it', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} />
</svg>,
)
expect(markup.match(/<line/g)).toHaveLength(5)
expect(markup).not.toContain('stroke-dasharray')
expect(markup).toContain('y="-0.12"')
expect(markup).toContain('13&#x27;-1 1/2&quot;')
expect(markup).toContain('paint-order="stroke"')
expect(markup).toContain('stroke="#ffffff"')
expect(markup).toContain('data-floorplan-annotation-priority="145"')
})
test('keeps farther-out architectural strings fixed before inner strings', () => {
expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan(
floorplanDimensionAnnotationPriority(0.55),
)
})
test('keeps labels readable after the scene rotates', () => {
expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180)
})
test('resolves document annotation sizes from paper points', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer annotationUnitsPerPoint={0.01} geometry={dimension} />
</svg>,
)
expect(layout?.extensionStart[1]).toBeCloseTo(0.03)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('y="-0.05"')
})
test('uses PDF-safe label plates and hairline measurement strokes for export', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('stroke-width="0.5"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('stroke="#ffffff"')
})
test('renders a dimension string with shared witness extension lines and ticks', () => {
const stringGeometry = {
kind: 'dimension-string',
segments: [
{
start: [0, 0],
end: [2, 0],
dimensionStart: [0, 1],
dimensionEnd: [2, 1],
text: '2m',
},
{
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
text: '3m',
},
],
offsetNormal: [0, 1],
offsetDistance: 1,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={stringGeometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-string=""')
expect(markup.match(/<line/g)).toHaveLength(8)
expect(markup).toContain('2m')
expect(markup).toContain('3m')
})
test('offsets automatic dimension-string lines when no explicit baseline is supplied', () => {
const automaticString = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [2, 0], text: '2m' }],
offsetNormal: [0, 1],
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={automaticString} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
})
})
@@ -0,0 +1,608 @@
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const EXTENSION_START_GAP = 0.075
const TICK_HALF_LENGTH = 0.09
const LABEL_FONT_SIZE = 0.15
const LABEL_BASELINE_OFFSET = 0.12
const LABEL_CHARACTER_WIDTH_RATIO = 0.62
const LABEL_END_GAP = 0.075
const DOCUMENT_EXTENSION_START_GAP_PT = 3
const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
const DOCUMENT_LABEL_FONT_SIZE_PT = 8
const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
const DOCUMENT_LABEL_END_GAP_PT = 3
const LINE_STROKE_WIDTH_PX = 0.9
const TICK_STROKE_WIDTH_PX = 1.35
const PDF_LINE_STROKE_WIDTH_PT = 0.5
const PDF_TICK_STROKE_WIDTH_PT = 0.75
const SQRT_ONE_HALF = Math.SQRT1_2
type FloorplanDimensionRenderMode = 'screen' | 'pdf'
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
export type ArchitecturalDimensionLayout = {
dimensionStart: FloorplanPoint
dimensionEnd: FloorplanPoint
dimensionLineStart: FloorplanPoint
dimensionLineEnd: FloorplanPoint
extensionStart: FloorplanPoint
extensionEnd: FloorplanPoint
extensionStartTip: FloorplanPoint
extensionEndTip: FloorplanPoint
tickHalfVector: FloorplanPoint
labelPoint: FloorplanPoint
labelAngleDeg: number
labelPlacement: 'inside' | 'outside-end'
outsideStartLabelPoint?: FloorplanPoint
outsideStartDimensionLineStart?: FloorplanPoint
}
export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
return 100 + Math.round(Math.abs(offsetDistance) * 100)
}
export function computeArchitecturalDimensionLayout(
geometry: DimensionGeometry,
sceneRotationDeg: number,
annotationUnitsPerPoint?: number,
): ArchitecturalDimensionLayout | null {
const extensionStartGap = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
: (geometry.extensionStartGap ?? EXTENSION_START_GAP)
const extensionOvershoot = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
: geometry.extensionOvershoot
const tickHalfLength = annotationUnitsPerPoint
? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
: TICK_HALF_LENGTH
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelEndGap = annotationUnitsPerPoint
? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
: LABEL_END_GAP
const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
geometry.start[0] + offsetX,
geometry.start[1] + offsetY,
]
const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
geometry.end[0] + offsetX,
geometry.end[1] + offsetY,
]
const dx = dimensionEnd[0] - dimensionStart[0]
const dy = dimensionEnd[1] - dimensionStart[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return null
const dirX = dx / length
const dirY = dy / length
const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
// A 45-degree architectural slash. Every terminator within one string uses
// this same vector instead of rotating independently around its endpoint.
const tickHalfVector: FloorplanPoint = [
(dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
(dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
]
const labelWidth = Math.max(
labelFontSize,
geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
)
const labelPlacement =
length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
const direction: FloorplanPoint = [dirX, dirY]
const labelPoint =
labelPlacement === 'inside'
? ([
(dimensionStart[0] + dimensionEnd[0]) / 2,
(dimensionStart[1] + dimensionEnd[1]) / 2,
] as FloorplanPoint)
: addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
const dimensionLineEnd =
labelPlacement === 'inside'
? dimensionEnd
: addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
const outsideStartLabelPoint =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
: undefined
const outsideStartDimensionLineStart =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
: undefined
return {
dimensionStart,
dimensionEnd,
dimensionLineStart: dimensionStart,
dimensionLineEnd,
extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
tickHalfVector,
labelPoint,
labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
labelPlacement,
outsideStartLabelPoint,
outsideStartDimensionLineStart,
}
}
function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
return [left[0] - right[0], left[1] - right[1]]
}
function dot(left: FloorplanPoint, right: FloorplanPoint): number {
return left[0] * right[0] + left[1] * right[1]
}
function addScaled(
point: FloorplanPoint,
direction: FloorplanPoint,
distance: number,
): FloorplanPoint {
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
}
export function FloorplanDimensionRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const layout = computeArchitecturalDimensionLayout(
geometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
if (!layout) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const terminator = geometry.terminator ?? 'architectural-tick'
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g data-floorplan-dimension="" pointerEvents="none">
<line
{...lineProps}
x1={layout.extensionStart[0]}
x2={layout.extensionStartTip[0]}
y1={layout.extensionStart[1]}
y2={layout.extensionStartTip[1]}
/>
<line
{...lineProps}
x1={layout.extensionEnd[0]}
x2={layout.extensionEndTip[0]}
y1={layout.extensionEnd[1]}
y2={layout.extensionEndTip[1]}
/>
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
{renderTerminator(
terminator,
layout.dimensionStart,
layout.dimensionEnd,
layout,
lineProps,
tickStrokeWidth,
)}
{renderTerminator(
terminator,
layout.dimensionEnd,
layout.dimensionStart,
layout,
lineProps,
tickStrokeWidth,
)}
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={geometry.text}
y={labelY}
/>
</g>
</g>
)
}
export function FloorplanDimensionStringRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionStringGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const segmentLayouts = geometry.segments.flatMap((segment, index) => {
const segmentGeometry: DimensionGeometry = {
kind: 'dimension',
start: segment.start,
end: segment.end,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
offsetNormal: geometry.offsetNormal,
offsetDistance: geometry.offsetDistance,
extensionOvershoot: geometry.extensionOvershoot,
extensionStartGap: geometry.extensionStartGap,
terminator: geometry.terminator,
textPosition: geometry.textPosition,
text: segment.text,
stroke: geometry.stroke,
}
const layout = computeArchitecturalDimensionLayout(
segmentGeometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
return layout ? [{ index, layout, segment: segmentGeometry }] : []
})
if (segmentLayouts.length === 0) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const extensionLines = new Map<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
const ticks = new Map<
string,
{ point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
>()
for (const { layout } of segmentLayouts) {
extensionLines.set(pointKey(layout.dimensionStart), {
start: layout.extensionStart,
tip: layout.extensionStartTip,
})
extensionLines.set(pointKey(layout.dimensionEnd), {
start: layout.extensionEnd,
tip: layout.extensionEndTip,
})
ticks.set(pointKey(layout.dimensionStart), {
point: layout.dimensionStart,
toward: layout.dimensionEnd,
tickHalfVector: layout.tickHalfVector,
})
ticks.set(pointKey(layout.dimensionEnd), {
point: layout.dimensionEnd,
toward: layout.dimensionStart,
tickHalfVector: layout.tickHalfVector,
})
}
const terminator = geometry.terminator ?? 'architectural-tick'
return (
<g data-floorplan-dimension-string="" pointerEvents="none">
{[...extensionLines.values()].map((line, index) => (
<line
{...lineProps}
key={`extension-${index}`}
x1={line.start[0]}
x2={line.tip[0]}
y1={line.start[1]}
y2={line.tip[1]}
/>
))}
{segmentLayouts.map(({ index, layout }) => (
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
key={`dimension-line-${index}`}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
))}
{[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
renderTerminator(
terminator,
point,
toward,
{ tickHalfVector },
lineProps,
tickStrokeWidth,
`tick-${index}`,
),
)}
{segmentLayouts.map(({ index, layout, segment }) => {
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g key={`label-${index}`}>
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={segment.text}
y={labelY}
/>
</g>
</g>
)
})}
</g>
)
}
function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
const cosine = Math.cos(radians)
const sine = Math.sin(radians)
return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
}
function DimensionLabel({
text,
y,
fontSize,
stroke,
renderMode,
}: {
text: string
y: number
fontSize: number
stroke: string
renderMode: FloorplanDimensionRenderMode
}) {
const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
const plateWidth = width + fontSize * 0.5
const plateHeight = fontSize * 1.2
const plateY = y - fontSize * 0.82
return (
<>
{renderMode === 'pdf' ? (
<rect
data-floorplan-dimension-label-plate=""
fill="#ffffff"
height={plateHeight}
rx={fontSize * 0.12}
ry={fontSize * 0.12}
width={plateWidth}
x={-plateWidth / 2}
y={plateY}
/>
) : null}
<text
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={500}
paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
strokeWidth={renderMode === 'pdf' ? undefined : 3}
textAnchor="middle"
vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
x={0}
y={y}
>
{text}
</text>
</>
)
}
function renderTerminator(
terminator: DimensionTerminator,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
lineProps: {
stroke: string
strokeLinecap: 'butt'
strokeWidth: number
vectorEffect: 'non-scaling-stroke'
},
tickStrokeWidth: number,
key?: string,
): React.ReactElement | null {
const direction = normalized(point, toward)
if (!direction) return null
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
if (terminator === 'dot') {
return (
<circle
fill={lineProps.stroke}
key={key}
r={tickHalfLength * 0.45}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[1]}
/>
)
}
if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
const base = addScaled(point, direction, tickHalfLength * 1.7)
const normal: FloorplanPoint = [-direction[1], direction[0]]
const wing = tickHalfLength * 0.65
const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
if (terminator === 'filled-arrow') {
return (
<polygon
fill={lineProps.stroke}
key={key}
points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
vectorEffect="non-scaling-stroke"
/>
)
}
return (
<g key={key}>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={left[0]}
y1={point[1]}
y2={left[1]}
/>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={right[0]}
y1={point[1]}
y2={right[1]}
/>
</g>
)
}
const [tickX, tickY] = layout.tickHalfVector
return (
<line
{...lineProps}
key={key}
strokeWidth={tickStrokeWidth}
x1={point[0] - tickX}
x2={point[0] + tickX}
y1={point[1] - tickY}
y2={point[1] + tickY}
/>
)
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
}
function pointKey(point: FloorplanPoint): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
}
@@ -0,0 +1,369 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
describe('FloorplanGeometryRenderer static labels', () => {
test('renders a measurement value together with its geometry', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' },
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 0,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('<line')
expect(markup).toContain('2.00m')
expect(markup).toContain('translate(0 -0.14)')
})
test('keeps screen-upright measurement labels readable in rotated exports', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: 'A 6.0m²',
angle: Math.PI / 3,
screenUpright: true,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} sceneRotationDeg={90} />
</svg>,
)
expect(markup).toContain('A 6.0m²')
expect(markup).toContain('rotate(-90)')
})
test('uses paper-point sizing when document scale is provided', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
})
test('uses live screen sizing without enabling document annotation styles', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'LIVE TEXT',
fontSize: 0.16,
upright: true,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} screenUnitsPerPixel={0.02} />
</svg>,
)
expect(markup).toContain('font-size="0.16"')
expect(markup).toContain('font-size="0.24"')
expect(markup).toContain('translate(0 -0.28)')
})
test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={geometry}
renderMode="pdf"
screenUnitsPerPixel={0.02}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('fill="#111827"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('fill="#ffffff" font-family=')
})
test('caps PDF annotation linework without changing live stroke widths', () => {
const geometry = {
kind: 'line',
x1: 0,
y1: 0,
x2: 2,
y2: 0,
stroke: '#334155',
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const pdfMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(liveMarkup).toContain('stroke-width="2"')
expect(pdfMarkup).toContain('stroke-width="0.5"')
})
test('removes unsupported paint-order outlines from generic PDF text', () => {
const geometry = {
kind: 'text',
x: 1,
y: 2,
text: '101',
fontSize: 0.15,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.04,
paintOrder: 'stroke',
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('fill="#334155"')
expect(markup).not.toContain('paint-order')
expect(markup).not.toContain('stroke=')
})
test('resolves generic annotation text from paper points only in document mode', () => {
const geometry = {
kind: 'text',
x: 2,
y: 3,
text: 'VERIFY DIMENSIONS',
fontSize: 0.16,
upright: true,
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(liveMarkup).toContain('font-size="0.16"')
expect(documentMarkup).toContain('font-size="0.08"')
})
test('uses a room-label paper profile while preserving room label hierarchy', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.2,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.18,
text: '101',
fontSize: 0.16,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.36,
text: 'CH: 2700',
fontSize: 0.11,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('font-size="0.07"')
expect(markup).toContain('font-size="0.055"')
expect(markup).toMatch(/translate\(0 0\.08625/)
expect(markup).toMatch(/translate\(0 0\.18625/)
expect(markup).toMatch(/translate\(0 0\.27375/)
})
test('uses paper stroke profiles for leaders and opening marks in document mode', () => {
const geometry = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{
kind: 'polyline',
points: [
[0, 0],
[1, 0],
[1.4, 0],
],
stroke: '#334155',
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'rect',
x: 2,
y: 2,
width: 0.42,
height: 0.32,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.02,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('stroke-width="0.9"')
expect(markup).toContain('vector-effect="non-scaling-stroke"')
expect(markup).toContain('height="0.14"')
expect(markup).toContain('rx="0.07"')
})
test('registers fixed mark pills as annotation obstacles', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle=""')
})
test('registers semantic plan primitives as annotation obstacles', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
],
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
}}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
test('registers fixed annotation categories as obstacles', () => {
const roomLabel = {
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.18,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
const stairArrow = {
kind: 'polyline',
points: [
[0, 0],
[0.5, 0.5],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={roomLabel} />
<FloorplanGeometryRenderer geometry={stairArrow} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
})
@@ -2,6 +2,29 @@
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core' import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react' import { memo, useEffect, useState } from 'react'
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
} from './floorplan-annotation-layout'
import {
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
} from './floorplan-dimension-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
const DOCUMENT_MARK_HEIGHT_PT = 14
const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
type FloorplanRenderMode = 'screen' | 'pdf'
/** /**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by * Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({ export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry, geometry,
pointerEventsOverride, pointerEventsOverride,
sceneRotationDeg = 0,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode = 'screen',
}: { }: {
geometry: FloorplanGeometry geometry: FloorplanGeometry
pointerEventsOverride?: string pointerEventsOverride?: string
sceneRotationDeg?: number
annotationUnitsPerPoint?: number
screenUnitsPerPixel?: number
renderMode?: FloorplanRenderMode
}) { }) {
return renderNode(geometry, 0, pointerEventsOverride) return renderNode(
geometry,
0,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
)
}) })
function styleAttrs( function styleAttrs(
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> }, g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
pointerEventsOverride?: string, pointerEventsOverride?: string,
annotationUnitsPerPoint?: number,
renderMode: FloorplanRenderMode = 'screen',
) { ) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per- // Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land // primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
pointerEvents?: string pointerEvents?: string
cursor?: string cursor?: string
} }
const annotationMetadata = readFloorplanGeometryMetadata(g)
const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
const strokeWidth =
renderMode === 'pdf' &&
vectorEffect === 'non-scaling-stroke' &&
resolvedStrokeWidth !== undefined
? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
: resolvedStrokeWidth
return { return {
fill: s.fill ?? 'none', 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
'data-floorplan-annotation-role': annotationMetadata.annotationRole,
fill: documentStyle.fill ?? s.fill ?? 'none',
fillOpacity: s.fillOpacity, fillOpacity: s.fillOpacity,
stroke: s.stroke, stroke: documentStyle.stroke ?? s.stroke,
strokeWidth: s.strokeWidth, strokeWidth,
strokeDasharray: s.strokeDasharray, strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap, strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin, strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity, strokeOpacity: s.strokeOpacity,
opacity: s.opacity, opacity: s.opacity,
vectorEffect: s.vectorEffect, vectorEffect,
pointerEvents: pointerEventsOverride ?? s.pointerEvents, pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined, style: s.cursor ? { cursor: s.cursor } : undefined,
} }
} }
export function resolveDocumentFloorplanAnnotationStyle(
geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
annotationUnitsPerPoint?: number,
): {
fill?: string
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
} {
if (annotationUnitsPerPoint === undefined) return {}
const styled = geometry as FloorplanGeometry & {
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
}
if (!styled.stroke && geometry.kind !== 'text') return {}
if (geometry.kind === 'text') {
const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
const sourceStrokeWidth = geometry.strokeWidth ?? 0
const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
return {
strokeWidth:
geometry.stroke && outlineRatio > 0
? Math.max(
DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
fontSize * outlineRatio,
)
: undefined,
}
}
return {
strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
vectorEffect: 'non-scaling-stroke',
}
}
function documentTextFontSize(
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
annotationUnitsPerPoint: number,
): number {
return documentTextSizePt(geometry) * annotationUnitsPerPoint
}
function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
case 'room-label':
if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
case 'column-center':
case 'stair-annotation':
return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
default:
return DOCUMENT_DEFAULT_TEXT_SIZE_PT
}
}
function documentStrokeWidth(
geometry: { strokeWidth?: number },
annotationUnitsPerPoint: number,
): number {
return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
}
export function documentRectGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'rect' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
return {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
rx: geometry.rx,
ry: geometry.ry,
}
}
const centerX = geometry.x + geometry.width / 2
const centerY = geometry.y + geometry.height / 2
const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
return {
x: centerX - width / 2,
y: centerY - height / 2,
width,
height,
rx: height / 2,
ry: height / 2,
}
}
function isAnnotationMarkRect(geometry: Extract<FloorplanGeometry, { kind: 'rect' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
}
export function documentCircleGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
return { r: geometry.r }
}
return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
}
function isAnnotationMarkCircle(geometry: Extract<FloorplanGeometry, { kind: 'circle' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
}
export function resolveDocumentAnnotationGroupChildren(
children: FloorplanGeometry[],
annotationUnitsPerPoint?: number,
): FloorplanGeometry[] {
if (annotationUnitsPerPoint === undefined) return children
const next = [...children]
let start = 0
while (start < next.length) {
const first = next[start]
if (!isDocumentTextLine(first)) {
start++
continue
}
let end = start + 1
while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
if (end - start > 1) {
const run = next.slice(start, end) as Extract<FloorplanGeometry, { kind: 'text' }>[]
const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
for (let index = 0; index < adjusted.length; index++) {
const line = adjusted[index]
if (line) next[start + index] = line
}
}
start = end
}
return next
}
function isDocumentTextLine(
geometry: FloorplanGeometry | undefined,
): geometry is Extract<FloorplanGeometry, { kind: 'text' }> {
return geometry?.kind === 'text' && geometry.upright === true
}
function isSameDocumentTextRun(
first: Extract<FloorplanGeometry, { kind: 'text' }>,
candidate: FloorplanGeometry | undefined,
): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
return (
isDocumentTextLine(candidate) &&
Math.abs(candidate.x - first.x) < 1e-6 &&
candidate.textAnchor === first.textAnchor &&
readFloorplanGeometryMetadata(candidate).annotationRole ===
readFloorplanGeometryMetadata(first).annotationRole
)
}
function positionDocumentTextRun(
run: Extract<FloorplanGeometry, { kind: 'text' }>[],
annotationUnitsPerPoint: number,
): Extract<FloorplanGeometry, { kind: 'text' }>[] {
const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
const steps = run.slice(0, -1).map((line, index) => {
const next = run[index + 1] ?? line
const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
return largerFontPt * 1.25 * annotationUnitsPerPoint
})
const totalHeight = steps.reduce((sum, step) => sum + step, 0)
let y = centerY - totalHeight / 2
return run.map((line, index) => {
if (index > 0) y += steps[index - 1] ?? 0
return { ...line, y }
})
}
function renderNode( function renderNode(
g: FloorplanGeometry, g: FloorplanGeometry,
keyHint: number, keyHint: number,
pointerEventsOverride?: string, pointerEventsOverride?: string,
sceneRotationDeg = 0,
annotationUnitsPerPoint?: number,
screenUnitsPerPixel?: number,
renderMode: FloorplanRenderMode = 'screen',
): React.ReactElement | null { ): React.ReactElement | null {
switch (g.kind) { switch (g.kind) {
case 'path': case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} /> return (
<path
d={g.d}
key={keyHint}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'polygon': case 'polygon':
return ( return (
<polygon <polygon
key={keyHint} key={keyHint}
points={pointsToAttr(g.points)} points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
@@ -93,34 +338,38 @@ function renderNode(
<polyline <polyline
key={keyHint} key={keyHint}
points={pointsToAttr(g.points)} points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
case 'rect': case 'rect': {
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
return ( return (
<rect <rect
height={g.height} height={attrs.height}
key={keyHint} key={keyHint}
rx={g.rx} rx={attrs.rx}
ry={g.ry} ry={attrs.ry}
width={g.width} width={attrs.width}
x={g.x} x={attrs.x}
y={g.y} y={attrs.y}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
}
case 'circle': case 'circle': {
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
return ( return (
<circle <circle
cx={g.cx} cx={g.cx}
cy={g.cy} cy={g.cy}
key={keyHint} key={keyHint}
r={g.r} r={attrs.r}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
}
case 'line': case 'line':
return ( return (
@@ -130,25 +379,65 @@ function renderNode(
x2={g.x2} x2={g.x2}
y1={g.y1} y1={g.y1}
y2={g.y2} y2={g.y2}
{...styleAttrs(g, pointerEventsOverride)} {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/> />
) )
case 'text': case 'text': {
const fontSize =
annotationUnitsPerPoint !== undefined
? documentTextFontSize(g, annotationUnitsPerPoint)
: g.fontSize
const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
const fill =
pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
? g.stroke
: (g.fill ?? '#171717')
if (g.upright) {
return ( return (
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text <text
dominantBaseline={g.dominantBaseline ?? 'middle'} dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'} fill={fill}
fontFamily={g.fontFamily} fontFamily={g.fontFamily}
fontSize={g.fontSize} fontSize={fontSize}
fontWeight={g.fontWeight}
opacity={g.opacity}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
pointerEvents={pointerEventsOverride}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
return (
<text
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={fontSize}
fontWeight={g.fontWeight} fontWeight={g.fontWeight}
key={keyHint} key={keyHint}
opacity={g.opacity} opacity={g.opacity}
paintOrder={g.paintOrder} paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
stroke={g.stroke} stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined} strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined} strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth} strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'} textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride} pointerEvents={pointerEventsOverride}
x={g.x} x={g.x}
@@ -157,6 +446,93 @@ function renderNode(
{g.text} {g.text}
</text> </text>
) )
}
case 'dimension':
return (
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
annotationUnitsPerPoint={annotationUnitsPerPoint}
renderMode={renderMode}
/>
)
case 'dimension-string':
return (
<FloorplanDimensionStringRenderer
annotationUnitsPerPoint={annotationUnitsPerPoint}
geometry={g}
key={keyHint}
renderMode={renderMode}
sceneRotationDeg={sceneRotationDeg}
/>
)
case 'dimension-label': {
const unitsPerPixel =
annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
const documentMode = annotationUnitsPerPoint !== undefined
const outlined = g.appearance === 'outlined'
const pdfOutlined = outlined && renderMode === 'pdf'
const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3
const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
const textWidth = g.text.length * unitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
return (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={labelTransform}
>
{outlined && !pdfOutlined ? null : (
<rect
data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
fill="#ffffff"
height={plateH}
opacity={0.92}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
stroke={pdfOutlined ? undefined : '#334155'}
strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
)}
<text
dominantBaseline="middle"
fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
fontFamily={
outlined && !pdfOutlined
? 'system-ui, -apple-system, sans-serif'
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
}
fontSize={fontSize}
fontWeight={outlined ? 500 : 600}
paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
stroke={outlined && !pdfOutlined ? '#334155' : undefined}
strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
textAnchor="middle"
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
case 'image': case 'image':
return ( return (
@@ -174,15 +550,32 @@ function renderNode(
case 'group': { case 'group': {
const transform = formatTransform(g.transform) const transform = formatTransform(g.transform)
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
return ( return (
<g key={keyHint} transform={transform}> <g
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))} data-floorplan-annotation-obstacle={
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
}
key={keyHint}
transform={transform}
>
{children.map((child, i) =>
renderNode(
child,
i,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
),
)}
</g> </g>
) )
} }
// The interactive primitives (hatch / hit-line / endpoint-handle / // The remaining interactive primitives (hatch / hit-line / endpoint-handle)
// dimension-label) need the SVG context + theme palette + units-per- // need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered // pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If // by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it // a caller routes one of these through this pure renderer it
@@ -3,15 +3,25 @@ import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
FloorplanAffordanceSession, FloorplanAffordanceSession,
FloorplanGeometry,
LiveNodeOverrides, LiveNodeOverrides,
} from '@pascal-app/core' } from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core' import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { z } from 'zod' import { z } from 'zod'
import {
FLOORPLAN_NODE_EXTENSION_KEY,
floorplanGeometryMetadata,
} from '../../../lib/floorplan/floorplan-extension'
import { import {
cancelFloorplanAffordanceDrag, cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes, collectFloorplanDependencyNodes,
collectFloorplanLinkedLevelNodes,
computeAffectedSiblingIds, computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance, floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel, subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer' } from './floorplan-registry-layer'
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
}) })
}) })
describe('floorplan annotation overlay routing', () => {
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
const noop = () => {}
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
renderToStaticMarkup(
createElement(
'svg',
null,
createElement(InteractiveGeometry, {
activeDragId: null,
activeRotateNodeId: null,
geometry,
hatchPatternId: undefined,
hoveredHandleId: null,
isMarqueeSelectionActive: false,
nodeId: 'wall_test' as AnyNodeId,
onHandleDoubleClick: noop,
onHandleHoverChange: noop,
onHandlePointerDown: noop,
onMoveHandlePointerDown: noop,
palette: undefined,
sceneRotationDeg: 180,
unitsPerPixel: 0.01,
}),
),
)
const dimensionString = (
end: readonly [number, number],
offsetNormal: readonly [number, number],
): FloorplanGeometry => ({
kind: 'dimension-string',
segments: [{ start: [0, 0], end, text: '2m' }],
offsetNormal,
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
})
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
})
test('keeps a fixed mark pill together in the overlay pass', () => {
const mark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
})
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
const columnCenter = {
kind: 'line',
x1: 0,
y1: 0,
x2: 1,
y2: 0,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
})
})
describe('computeAffectedSiblingIds', () => { describe('computeAffectedSiblingIds', () => {
beforeEach(() => { beforeEach(() => {
nodeRegistry._reset() nodeRegistry._reset()
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
]) ])
}) })
}) })
describe('collectFloorplanLinkedLevelNodes', () => {
test('projects a node onto a linked destination level with its real children', () => {
nodeRegistry._reset()
registerNode({
kind: 'linked-floorplan-test',
schemaVersion: 1,
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
category: 'structure',
defaults: () => ({}) as never,
floorplan: () => null,
extensions: {
[FLOORPLAN_NODE_EXTENSION_KEY]: {
linkedLevelIds: () => ['level_upper' as AnyNodeId],
},
},
} as unknown as AnyNodeDefinition)
const child = {
id: 'linked_child',
type: 'linked-child',
parentId: 'linked_parent',
} as unknown as AnyNode
const parent = {
id: 'linked_parent',
type: 'linked-floorplan-test',
parentId: 'level_lower',
children: [child.id],
} as unknown as AnyNode
const nodes = { [parent.id]: parent, [child.id]: child }
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
{ id: parent.id, node: parent, children: [child] },
])
expect(
collectFloorplanLinkedLevelNodes(
nodes,
'level_upper' as AnyNodeId,
new Set([parent.id as AnyNodeId]),
),
).toEqual([])
})
})

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