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:
@@ -183,7 +183,6 @@ Systems are React components that run in the render loop (`useFrame`) to update
|
||||
| System | Responsibility |
|
||||
|--------|---------------|
|
||||
| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |
|
||||
| `SlabSystem` | Generates floor geometry from polygons |
|
||||
| `CeilingSystem` | Generates ceiling geometry |
|
||||
| `RoofSystem` | Generates roof geometry |
|
||||
| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { BuildTab } from '@/components/build-tab'
|
||||
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
|
||||
import {
|
||||
CommunityViewerToolbarLeft,
|
||||
CommunityViewerToolbarRight,
|
||||
@@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor'
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen">
|
||||
<FloorplanConstructionPreflight />
|
||||
{PROJECT_ID === 'local-editor' && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { nodeRegistry } from '@pascal-app/core'
|
||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
getFloorplanNodeExtension,
|
||||
MaterialPaintPanel,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -13,24 +18,6 @@ import {
|
||||
} from '@/components/toolbar-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
|
||||
* editor's `StructureTool` ids.
|
||||
*/
|
||||
type BuildToolKind =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'stair'
|
||||
| 'elevator'
|
||||
| 'door'
|
||||
| 'window'
|
||||
| 'column'
|
||||
| 'shelf'
|
||||
| 'spawn'
|
||||
|
||||
/**
|
||||
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
|
||||
* group tile — its own sub-grid, like Roof's "Features".
|
||||
@@ -53,7 +40,8 @@ type BuildType = {
|
||||
/** Raster asset tile (legacy Build sidebar artwork). */
|
||||
iconSrc: string
|
||||
/** Present for structure-tool types (absent for paint mode and the MEP group). */
|
||||
kind?: BuildToolKind
|
||||
kind?: string
|
||||
paletteOrder?: number
|
||||
/** Non-placement special mode. */
|
||||
mode?: 'material-paint'
|
||||
}
|
||||
@@ -67,7 +55,7 @@ type MepItem = {
|
||||
}
|
||||
|
||||
// Same icons + ordering as the community Build sidebar, minus presets.
|
||||
const BUILD_TYPES: BuildType[] = [
|
||||
const BASE_BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
|
||||
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
|
||||
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' },
|
||||
@@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
|
||||
]
|
||||
|
||||
function collectBuildTypes(): BuildType[] {
|
||||
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
|
||||
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
|
||||
...type,
|
||||
paletteOrder:
|
||||
nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10,
|
||||
}))
|
||||
for (const [kind, definition] of nodeRegistry.entries()) {
|
||||
const presentation = definition.presentation
|
||||
const extension = getFloorplanNodeExtension(definition)
|
||||
if (
|
||||
baseKinds.has(kind) ||
|
||||
!extension?.tool ||
|
||||
!presentation ||
|
||||
presentation.hidden ||
|
||||
presentation.paletteSection !== 'structure'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
tools.push({
|
||||
id: kind,
|
||||
kind,
|
||||
label: presentation.label,
|
||||
iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp',
|
||||
paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
}
|
||||
tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0))
|
||||
return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)]
|
||||
}
|
||||
|
||||
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
|
||||
// tools had in the community Build sidebar.
|
||||
const MEP_ITEMS: MepItem[] = [
|
||||
@@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [
|
||||
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
|
||||
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
|
||||
*/
|
||||
function activateBuildTool(kind: BuildToolKind | MepToolKind): void {
|
||||
function activateBuildTool(kind: string): void {
|
||||
const ed = useEditor.getState()
|
||||
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
|
||||
if (preferredView) ed.setViewMode(preferredView)
|
||||
ed.setPhase('structure')
|
||||
ed.setStructureLayer('elements')
|
||||
ed.setCatalogCategory(null)
|
||||
@@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void {
|
||||
ed.setStructureLayer('elements')
|
||||
ed.setCatalogCategory(null)
|
||||
ed.setMode('build')
|
||||
ed.setTool(kind as Parameters<typeof ed.setTool>[0])
|
||||
ed.setTool(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +186,7 @@ export function BuildTab() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||
const buildTypes = useMemo(collectBuildTypes, [])
|
||||
|
||||
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||
@@ -245,9 +267,9 @@ export function BuildTab() {
|
||||
didInitRef.current = true
|
||||
const ed = useEditor.getState()
|
||||
if (ed.mode === 'build' && ed.tool) return
|
||||
const firstType = BUILD_TYPES.find((t) => t.kind)
|
||||
const firstType = buildTypes.find((t) => t.kind)
|
||||
if (firstType) handleTypeClick(firstType)
|
||||
}, [handleTypeClick])
|
||||
}, [buildTypes, handleTypeClick])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
@@ -256,7 +278,7 @@ export function BuildTab() {
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
|
||||
>
|
||||
{BUILD_TYPES.map((type) => {
|
||||
{buildTypes.map((type) => {
|
||||
const active = isTypeActive(type)
|
||||
return (
|
||||
<Tooltip key={type.id}>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useFloorplanPreflight } from '@pascal-app/editor'
|
||||
import {
|
||||
buildClearanceAdvisories,
|
||||
buildConstructionModuleAdvisories,
|
||||
buildDimensionCompletenessAudit,
|
||||
} from '@pascal-app/nodes'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export function FloorplanConstructionPreflight() {
|
||||
const nodes = useDebouncedSceneNodes()
|
||||
const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled)
|
||||
const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled)
|
||||
const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues)
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const completeness = buildDimensionCompletenessAudit(nodes, {
|
||||
includeAutomaticDimensions: true,
|
||||
}).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'dimension-completeness' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
const clearance = clearanceChecksEnabled
|
||||
? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'clearance-advisory' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
: []
|
||||
const modules = moduleChecksEnabled
|
||||
? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'module-advisory' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
: []
|
||||
return [...completeness, ...clearance, ...modules]
|
||||
}, [clearanceChecksEnabled, moduleChecksEnabled, nodes])
|
||||
|
||||
useEffect(() => {
|
||||
setAuditIssues(issues)
|
||||
return () => setAuditIssues([])
|
||||
}, [issues, setAuditIssues])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useDebouncedSceneNodes() {
|
||||
const [nodes, setNodes] = useState(() => useScene.getState().nodes)
|
||||
|
||||
useEffect(() => {
|
||||
let pending: ReturnType<typeof setTimeout> | undefined
|
||||
const unsubscribe = useScene.subscribe((state) => {
|
||||
if (pending) clearTimeout(pending)
|
||||
pending = setTimeout(() => setNodes(state.nodes), 100)
|
||||
})
|
||||
return () => {
|
||||
if (pending) clearTimeout(pending)
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return nodes
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
import {
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useDrawingView,
|
||||
useEditor,
|
||||
useFloorplanAnnotationVisibility,
|
||||
useSidebarStore,
|
||||
type ViewMode,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -32,12 +35,16 @@ import {
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Grid2X2,
|
||||
Layers3,
|
||||
Magnet,
|
||||
PenLine,
|
||||
Ruler,
|
||||
ScanLine,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
SquareUserRound,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { type ReactNode, useCallback } from 'react'
|
||||
@@ -133,6 +140,22 @@ const SHADING_OPTIONS = [
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_ANNOTATION_OPTIONS = [
|
||||
{ id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler },
|
||||
{ id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler },
|
||||
{ id: 'measurements', name: 'Measurements', icon: ScanLine },
|
||||
{ id: 'openingMarks', name: 'Door/window marks', icon: Tag },
|
||||
{ id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 },
|
||||
{ id: 'roomLabels', name: 'Room labels', icon: SquareUserRound },
|
||||
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
|
||||
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
|
||||
{ id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
|
||||
{ id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' },
|
||||
] as const
|
||||
|
||||
function ViewModeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const setViewMode = useEditor((state) => state.setViewMode)
|
||||
@@ -165,6 +188,49 @@ function ViewModeControl() {
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingTypeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const drawingType = useDrawingView((state) => state.drawingType)
|
||||
const setDrawingType = useDrawingView((state) => state.setDrawingType)
|
||||
if (viewMode === '3d') return null
|
||||
|
||||
const active =
|
||||
DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0]
|
||||
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
<DropdownMenu>
|
||||
<ToolbarTooltip label="Select coordinated drawing">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Drawing type: ${active.label}`}
|
||||
className="flex items-center gap-1.5 px-2.5 font-medium text-foreground/90 text-xs transition-colors hover:bg-white/8"
|
||||
type="button"
|
||||
>
|
||||
<Layers3 className="h-3.5 w-3.5" />
|
||||
<span>{active.label}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</ToolbarTooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
>
|
||||
{DRAWING_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onSelect={() => setDrawingType(option.id)}>
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{drawingType === option.id ? <Check className="ml-auto h-4 w-4" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapseSidebarButton() {
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
@@ -278,12 +344,15 @@ const EDGE_OPTIONS = [
|
||||
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
|
||||
|
||||
function DisplayMenu() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const setShowGrid = useViewer((state) => state.setShowGrid)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
const setMetricNotation = useViewer((state) => state.setMetricNotation)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||
const shading = useViewer((state) => state.shading)
|
||||
@@ -296,6 +365,14 @@ function DisplayMenu() {
|
||||
const setShadows = useViewer((state) => state.setShadows)
|
||||
const magneticSnap = useEditor((state) => state.magneticSnap)
|
||||
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
|
||||
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
|
||||
const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory)
|
||||
const wallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.wallDimensionReference,
|
||||
)
|
||||
const setWallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.setWallDimensionReference,
|
||||
)
|
||||
|
||||
const activeShading =
|
||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||
@@ -337,17 +414,82 @@ function DisplayMenu() {
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{viewMode !== '2d' ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
|
||||
>
|
||||
<Ruler className="h-4 w-4" />
|
||||
<span>Measurements</span>
|
||||
<span>{viewMode === 'split' ? '3D measurements' : 'Measurements'}</span>
|
||||
{showMeasurements ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{viewMode !== '3d' ? (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>Floor plan annotations</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const visible = annotationVisibility[option.id]
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(e) =>
|
||||
keepOpen(e, () => setAnnotationCategory(option.id, !visible))
|
||||
}
|
||||
>
|
||||
<OptionIcon className="h-4 w-4" />
|
||||
<span>{option.name}</span>
|
||||
{visible ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Ruler className="h-4 w-4" />
|
||||
<span>Wall dimensions</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{
|
||||
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
|
||||
(option) => option.id === wallDimensionReference,
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(event) =>
|
||||
keepOpen(event, () => setWallDimensionReference(option.id))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{wallDimensionReference === option.id ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
||||
<Magnet className="h-4 w-4" />
|
||||
<span>Magnetic snap</span>
|
||||
@@ -377,17 +519,48 @@ function DisplayMenu() {
|
||||
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
|
||||
>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
||||
{unit === 'metric' ? 'm' : 'ft'}
|
||||
{unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'}
|
||||
</span>
|
||||
<span>Units</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{unit === 'metric' ? 'Metric' : 'Imperial'}
|
||||
{unit === 'imperial'
|
||||
? 'Feet & inches'
|
||||
: metricNotation === 'millimeters'
|
||||
? 'Millimeters'
|
||||
: 'Meters'}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
<DropdownMenuItem onSelect={() => setMetricNotation('meters')}>
|
||||
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
||||
m
|
||||
</span>
|
||||
<span>Meters</span>
|
||||
{unit === 'metric' && metricNotation === 'meters' ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setMetricNotation('millimeters')}>
|
||||
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
||||
mm
|
||||
</span>
|
||||
<span>Millimeters</span>
|
||||
{unit === 'metric' && metricNotation === 'millimeters' ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setUnit('imperial')}>
|
||||
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
||||
ft
|
||||
</span>
|
||||
<span>Feet & inches</span>
|
||||
{unit === 'imperial' ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() {
|
||||
<>
|
||||
<CollapseSidebarButton />
|
||||
<ViewModeControl />
|
||||
<DrawingTypeControl />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"dedent": "^1.7.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
@@ -124,7 +124,7 @@
|
||||
},
|
||||
"packages/editor": {
|
||||
"name": "@pascal-app/editor",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -145,35 +145,37 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@visual-json/react": "^0.4.0",
|
||||
"blob-stream": "^0.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "~0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/viewer": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"next": ">=15",
|
||||
@@ -201,7 +203,7 @@
|
||||
},
|
||||
"packages/ifc-converter": {
|
||||
"name": "@pascal-app/ifc-converter",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -215,7 +217,7 @@
|
||||
},
|
||||
"packages/mcp": {
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"bin": {
|
||||
"pascal-mcp": "./dist/bin/pascal-mcp.js",
|
||||
},
|
||||
@@ -225,22 +227,22 @@
|
||||
"zod": "^4.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^22.19.20",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
},
|
||||
},
|
||||
"packages/nodes": {
|
||||
"name": "@pascal-app/nodes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.9.0",
|
||||
"@pascal-app/editor": "^0.9.0",
|
||||
"@pascal-app/viewer": "^0.9.0",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/editor": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/node": "^22.19.12",
|
||||
@@ -249,9 +251,9 @@
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.0",
|
||||
"@pascal-app/editor": "^0.9.0",
|
||||
"@pascal-app/viewer": "^0.9.0",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/editor": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"lucide-react": "^1",
|
||||
@@ -283,7 +285,7 @@
|
||||
},
|
||||
"packages/viewer": {
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.2",
|
||||
"dependencies": {
|
||||
"three-bvh-csg": "^0.0.18",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
@@ -297,7 +299,7 @@
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"react": "^18 || ^19",
|
||||
@@ -538,6 +540,10 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
@@ -852,6 +858,8 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
|
||||
@@ -868,9 +876,7 @@
|
||||
|
||||
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
"@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
@@ -882,8 +888,6 @@
|
||||
|
||||
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
@@ -978,8 +982,6 @@
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="],
|
||||
@@ -988,12 +990,20 @@
|
||||
|
||||
"bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
|
||||
|
||||
"blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="],
|
||||
|
||||
"blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
@@ -1014,8 +1024,6 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
@@ -1030,6 +1038,8 @@
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
@@ -1056,18 +1066,12 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
@@ -1100,9 +1104,9 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||
|
||||
@@ -1202,8 +1206,6 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
@@ -1232,7 +1234,7 @@
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="],
|
||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
@@ -1302,8 +1304,6 @@
|
||||
|
||||
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -1330,8 +1330,6 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
@@ -1404,6 +1402,8 @@
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
@@ -1422,8 +1422,6 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
@@ -1460,6 +1458,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
@@ -1580,7 +1580,7 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
|
||||
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
@@ -1598,7 +1598,7 @@
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
"pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
@@ -1606,6 +1606,8 @@
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
@@ -1632,8 +1634,6 @@
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -1660,8 +1660,6 @@
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
@@ -1674,9 +1672,9 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
@@ -1726,10 +1724,6 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
||||
|
||||
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
|
||||
@@ -1768,12 +1762,6 @@
|
||||
|
||||
"suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="],
|
||||
|
||||
"svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
@@ -1782,8 +1770,6 @@
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
|
||||
|
||||
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
|
||||
@@ -1792,6 +1778,8 @@
|
||||
|
||||
"three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
@@ -1844,6 +1832,10 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
|
||||
|
||||
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
|
||||
@@ -1860,8 +1852,6 @@
|
||||
|
||||
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -1962,6 +1952,8 @@
|
||||
|
||||
"agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
|
||||
|
||||
"deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -1978,6 +1970,8 @@
|
||||
|
||||
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
**Comparison Target**
|
||||
|
||||
- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`.
|
||||
- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`.
|
||||
- Viewport: 1280 × 720.
|
||||
- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator.
|
||||
- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions.
|
||||
|
||||
**Full-view Comparison Evidence**
|
||||
|
||||
- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text.
|
||||
- The larger left perimeter door has no room-side width dimension in the source state.
|
||||
- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading.
|
||||
|
||||
**Focused-region Comparison Evidence**
|
||||
|
||||
- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall.
|
||||
- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`.
|
||||
- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door.
|
||||
- A same-state rendered focused comparison is blocked by the local loading state.
|
||||
|
||||
**Findings**
|
||||
|
||||
- [P0] Browser-rendered implementation evidence unavailable.
|
||||
Location: local editor preview.
|
||||
Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected.
|
||||
Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted.
|
||||
Fix: restore a working local scene preview and recapture the reported room in 2D.
|
||||
|
||||
**Required Fidelity Surfaces**
|
||||
|
||||
- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection.
|
||||
- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection.
|
||||
- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection.
|
||||
- Image quality and asset fidelity: no image assets are involved.
|
||||
- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included.
|
||||
|
||||
**Comparison History**
|
||||
|
||||
- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls.
|
||||
- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls.
|
||||
- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked.
|
||||
|
||||
**Implementation Evidence**
|
||||
|
||||
- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed.
|
||||
- Nodes package build: passed.
|
||||
- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`.
|
||||
- Biome check: passed.
|
||||
- Git diff whitespace check: passed.
|
||||
- Browser console warnings/errors: none reported.
|
||||
|
||||
**Implementation Checklist**
|
||||
|
||||
- Restore the local editor preview.
|
||||
- Reopen the reported room in 2D.
|
||||
- Confirm each internal string has a visible parallel baseline, witness lines, and ticks.
|
||||
- Confirm the large left-side door displays its room-side width dimension.
|
||||
|
||||
**Follow-up Polish**
|
||||
|
||||
- Reassess internal line contrast only after the corrected geometry can be seen in the target scene.
|
||||
|
||||
final result: blocked
|
||||
@@ -9,10 +9,12 @@ import type {
|
||||
CeilingNode,
|
||||
ChimneyNode,
|
||||
ColumnNode,
|
||||
ConstructionDimensionNode,
|
||||
CupolaNode,
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
DuctTerminalNode,
|
||||
@@ -42,6 +44,7 @@ import type {
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
StructuralGridNode,
|
||||
TurbineVentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
|
||||
export type SpawnEvent = NodeEvent<SpawnNode>
|
||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||
export type ColumnEvent = NodeEvent<ColumnNode>
|
||||
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
|
||||
export type RoofEvent = NodeEvent<RoofNode>
|
||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||
export type StairEvent = NodeEvent<StairNode>
|
||||
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
|
||||
export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
|
||||
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'spawn', SpawnEvent> &
|
||||
NodeEvents<'ceiling', CeilingEvent> &
|
||||
NodeEvents<'column', ColumnEvent> &
|
||||
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
|
||||
NodeEvents<'roof', RoofEvent> &
|
||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||
NodeEvents<'stair', StairEvent> &
|
||||
NodeEvents<'stair-segment', StairSegmentEvent> &
|
||||
NodeEvents<'structural-grid', StructuralGridEvent> &
|
||||
NodeEvents<'window', WindowEvent> &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
NodeEvents<'scan', ScanEvent> &
|
||||
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
NodeEvents<'downspout', DownspoutEvent> &
|
||||
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
|
||||
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||
|
||||
@@ -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: [],
|
||||
holeMetadata: [],
|
||||
elevation,
|
||||
thickness: Math.max(elevation, 0),
|
||||
recessed: elevation < 0,
|
||||
autoFromWalls: false,
|
||||
} as SlabNode
|
||||
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
|
||||
|
||||
@@ -8,12 +8,29 @@ import type {
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
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 = {
|
||||
node: AnyNode
|
||||
nodes: Record<string, AnyNode>
|
||||
position: [number, number, number]
|
||||
rotation?: unknown
|
||||
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 {
|
||||
@@ -50,6 +67,7 @@ export function getFloorPlacedElevation({
|
||||
position,
|
||||
rotation,
|
||||
levelId,
|
||||
maxElevation,
|
||||
}: FloorPlacedElevationArgs): number {
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (!floorPlaced) return 0
|
||||
@@ -66,8 +84,31 @@ export function getFloorPlacedElevation({
|
||||
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
|
||||
if (!resolvedLevelId) return 0
|
||||
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
|
||||
const footprints = 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 elevation = finiteSlabElevation(
|
||||
spatialGridManager.getSlabElevationForItem(
|
||||
@@ -75,14 +116,15 @@ export function getFloorPlacedElevation({
|
||||
footprintPosition,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
maxElevation,
|
||||
),
|
||||
)
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
if (elevation > elected) {
|
||||
elected = elevation
|
||||
}
|
||||
}
|
||||
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
return elected === Number.NEGATIVE_INFINITY ? 0 : elected
|
||||
}
|
||||
|
||||
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 type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
||||
import { getWallPlaneTop } from '../../services/storey'
|
||||
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 { resolveWallEffectiveHeight } from '../../systems/wall/wall-top'
|
||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import { SpatialGrid } from './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
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -295,512 +294,29 @@ export function itemOverlapsPolygon(
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
/** One slab overlapping a queried footprint, as seen by support election. */
|
||||
export type SlabSupportCandidate = {
|
||||
slabId: string
|
||||
elevation: number
|
||||
}
|
||||
|
||||
export function computeWallSlabSupport(
|
||||
wallLike: WallOverlapInput,
|
||||
slabs: readonly SlabNode[],
|
||||
levelWalls: WallNode[],
|
||||
): 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: [] }
|
||||
export type ItemSlabSupport = {
|
||||
elevation: number
|
||||
/** The winning slab, or null when no slab overlaps the footprint. */
|
||||
slabId: string | null
|
||||
}
|
||||
|
||||
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(
|
||||
wallLike: WallOverlapInput,
|
||||
slabs: readonly SlabNode[],
|
||||
levelWalls: WallNode[],
|
||||
): number {
|
||||
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
|
||||
export type PointedSupportSurface = ItemSlabSupport & {
|
||||
/**
|
||||
* Level-local XZ where the ray meets the pointed surface's plane, or
|
||||
* null when the ray never reaches it (grazing / aimed above the base).
|
||||
* This is the plan point the pointer actually indicates: unlike a grid
|
||||
* 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 {
|
||||
@@ -842,7 +358,24 @@ export class SpatialGridManager {
|
||||
|
||||
private getWallHeight(wallId: string): number {
|
||||
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 {
|
||||
@@ -859,15 +392,74 @@ export class SpatialGridManager {
|
||||
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
|
||||
handleNodeCreated(node: AnyNode, levelId: string) {
|
||||
if (node.type === 'slab') {
|
||||
this.getSlabMap(levelId).set(node.id, node as SlabNode)
|
||||
this.invalidateRenderedSlabPolygons(levelId)
|
||||
} else if (node.type === 'ceiling') {
|
||||
this.ceilings.set(node.id, node as CeilingNode)
|
||||
} else if (node.type === 'wall') {
|
||||
const wall = node as WallNode
|
||||
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') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
||||
@@ -920,11 +512,13 @@ export class SpatialGridManager {
|
||||
handleNodeUpdated(node: AnyNode, levelId: string) {
|
||||
if (node.type === 'slab') {
|
||||
this.getSlabMap(levelId).set(node.id, node as SlabNode)
|
||||
this.invalidateRenderedSlabPolygons(levelId)
|
||||
} else if (node.type === 'ceiling') {
|
||||
this.ceilings.set(node.id, node as CeilingNode)
|
||||
} else if (node.type === 'wall') {
|
||||
const wall = node as WallNode
|
||||
this.walls.set(wall.id, wall)
|
||||
this.invalidateRenderedSlabPolygons(levelId)
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
||||
@@ -982,12 +576,16 @@ export class SpatialGridManager {
|
||||
|
||||
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
|
||||
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)
|
||||
} else if (nodeType === 'ceiling') {
|
||||
this.ceilings.delete(nodeId)
|
||||
this.ceilingGrids.delete(nodeId)
|
||||
} else if (nodeType === 'wall') {
|
||||
this.walls.delete(nodeId)
|
||||
this.invalidateRenderedSlabPolygons(levelId)
|
||||
// Remove all items attached to this wall from the spatial grid
|
||||
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
|
||||
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).
|
||||
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).
|
||||
* Returns the highest overlapping slab elevation, or 0 if none.
|
||||
* Thin wrapper over {@link getSlabSupportForItem} for callers (and tests)
|
||||
* that only need the number.
|
||||
*/
|
||||
getSlabElevationForItem(
|
||||
levelId: string,
|
||||
position: [number, number, number],
|
||||
dimensions: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
maxElevation?: number | null,
|
||||
): number {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation)
|
||||
.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()) {
|
||||
if (
|
||||
slab.polygon.length >= 3 &&
|
||||
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
|
||||
) {
|
||||
// Check if item is entirely within a hole (if so, ignore this slab)
|
||||
// We consider it entirely in a hole if the item center is in the hole
|
||||
const elevation = slab.elevation ?? 0.05
|
||||
if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue
|
||||
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
|
||||
if (elevation > winningElevation) {
|
||||
winningElevation = elevation
|
||||
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
|
||||
const [cx, , cz] = position
|
||||
const holes = slab.holes || []
|
||||
for (const hole of holes) {
|
||||
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
|
||||
for (const hole of slab.holes || []) {
|
||||
if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
|
||||
inHole = true
|
||||
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
|
||||
if (elevation > maxElevation) {
|
||||
maxElevation = elevation
|
||||
/**
|
||||
* All slabs supporting a footprint, one entry per overlapping slab
|
||||
* (highest elevation first; slab id breaks ties deterministically).
|
||||
* 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],
|
||||
curveOffset = 0,
|
||||
thickness = DEFAULT_WALL_THICKNESS,
|
||||
preferredSlabId?: string | null,
|
||||
): number {
|
||||
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation
|
||||
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId)
|
||||
.elevation
|
||||
}
|
||||
|
||||
getSlabSupportForWall(
|
||||
@@ -1265,11 +982,14 @@ export class SpatialGridManager {
|
||||
end: [number, number],
|
||||
curveOffset = 0,
|
||||
thickness = DEFAULT_WALL_THICKNESS,
|
||||
preferredSlabId?: string | null,
|
||||
maxElevation?: number | null,
|
||||
): WallSlabSupport {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) {
|
||||
return {
|
||||
elevation: 0,
|
||||
electedSlabId: null,
|
||||
baseElevation: 0,
|
||||
baseSegments: [{ start: 0, end: 1, elevation: 0 }],
|
||||
}
|
||||
@@ -1279,6 +999,8 @@ export class SpatialGridManager {
|
||||
{ start, end, curveOffset, thickness },
|
||||
[...slabMap.values()],
|
||||
this.getLevelWallNodes(levelId),
|
||||
preferredSlabId,
|
||||
maxElevation,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1387,6 +1109,7 @@ export class SpatialGridManager {
|
||||
}
|
||||
|
||||
clearLevel(levelId: string) {
|
||||
this.invalidateRenderedSlabPolygons(levelId)
|
||||
this.floorGrids.delete(levelId)
|
||||
this.wallGrids.delete(levelId)
|
||||
this.slabsByLevel.delete(levelId)
|
||||
@@ -1400,8 +1123,33 @@ export class SpatialGridManager {
|
||||
this.ceilingGrids.clear()
|
||||
this.ceilings.clear()
|
||||
this.itemCeilingMap.clear()
|
||||
this.renderedSlabPolygons.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
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 { 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 { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import {
|
||||
@@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void {
|
||||
// When a slab is added, mark overlapping items/walls dirty
|
||||
if (node.type === 'slab') {
|
||||
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)
|
||||
if (node.type === 'slab') {
|
||||
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') {
|
||||
if (
|
||||
const supportChanged =
|
||||
node.polygon !== prev.polygon ||
|
||||
node.elevation !== prev.elevation ||
|
||||
node.holes !== prev.holes
|
||||
) {
|
||||
if (supportChanged) {
|
||||
const levelId = resolveLevelId(node, state.nodes)
|
||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||
|
||||
@@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void {
|
||||
markNodesOverlappingSlab(prev 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])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -190,10 +284,11 @@ function markNodesOverlappingSlab(
|
||||
if (slab.polygon.length < 3) return
|
||||
const slabLevelId = resolveLevelId(slab, nodes)
|
||||
|
||||
// Walls follow the slab's RENDERED footprint (band-adopted edges reach
|
||||
// the wall's outer face), so the dirty gate must test the same polygon
|
||||
// `getSlabElevationForWall` will re-evaluate — a stored polygon that
|
||||
// stops short of the wall body would otherwise never re-elevate it.
|
||||
// Walls AND floor-placed nodes follow the slab's RENDERED footprint
|
||||
// (band-adopted edges reach the wall's outer face), so the dirty gate
|
||||
// must test the same polygon the support queries re-evaluate — a stored
|
||||
// polygon that stops short of the wall body would otherwise never
|
||||
// re-elevate nodes sitting over the adopted band.
|
||||
const levelWalls: WallNode[] = []
|
||||
const siblingSlabs: SlabNode[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
@@ -249,7 +344,7 @@ function markNodesOverlappingSlab(
|
||||
footprint.position ?? position,
|
||||
footprint.dimensions,
|
||||
footprint.rotation,
|
||||
slab.polygon,
|
||||
renderedPolygon,
|
||||
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([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]!
|
||||
expect(
|
||||
@@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => {
|
||||
).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', () => {
|
||||
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
|
||||
// 1cm short of even the inner faces, so the STORED polygon never
|
||||
@@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => {
|
||||
parseWall([4, 4], [0, 4]),
|
||||
parseWall([0, 4], [0, 0]),
|
||||
]
|
||||
// Grounded (thickness = elevation): band adoption only applies to
|
||||
// grounded room floors under the vertical model.
|
||||
const slab = SlabNode.parse({
|
||||
polygon: [
|
||||
[0.06, 0.06],
|
||||
@@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => {
|
||||
[0.06, 3.94],
|
||||
],
|
||||
elevation: 0.1,
|
||||
thickness: 0.1,
|
||||
})
|
||||
|
||||
const bottom = walls[0]!
|
||||
@@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => {
|
||||
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
|
||||
).toEqual({
|
||||
elevation: 0.6,
|
||||
electedSlabId: platform.id,
|
||||
baseElevation: 0.6,
|
||||
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
|
||||
})
|
||||
@@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => {
|
||||
),
|
||||
).toEqual({
|
||||
elevation: 0.6,
|
||||
electedSlabId: platform.id,
|
||||
baseElevation: 0.05,
|
||||
baseSegments: [
|
||||
{ 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', () => {
|
||||
const sharedWall = parseWall([4, 0], [4, 4])
|
||||
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({
|
||||
polygon: [
|
||||
[4, 0],
|
||||
@@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => {
|
||||
[4, 4],
|
||||
],
|
||||
elevation: 0.6,
|
||||
thickness: 0.6,
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => {
|
||||
),
|
||||
).toEqual({
|
||||
elevation: 0.6,
|
||||
electedSlabId: high.id,
|
||||
baseElevation: 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, 4.5], [4, 4.5]),
|
||||
]
|
||||
// Grounded raised room floor (see the shared-wall test above).
|
||||
const high = SlabNode.parse({
|
||||
polygon: [
|
||||
[0, 0],
|
||||
@@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => {
|
||||
[0, 3],
|
||||
],
|
||||
elevation: 0.6,
|
||||
thickness: 0.6,
|
||||
})
|
||||
const low = SlabNode.parse({
|
||||
polygon: [
|
||||
@@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => {
|
||||
),
|
||||
).toEqual({
|
||||
elevation: 0.6,
|
||||
electedSlabId: high.id,
|
||||
baseElevation: 0.05,
|
||||
baseSegments: [
|
||||
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 },
|
||||
|
||||
@@ -9,8 +9,10 @@ export type {
|
||||
CeilingEvent,
|
||||
ChimneyEvent,
|
||||
ColumnEvent,
|
||||
ConstructionDimensionEvent,
|
||||
DoorEvent,
|
||||
DormerEvent,
|
||||
DrawingSheetEvent,
|
||||
ElevatorEvent,
|
||||
EventSuffix,
|
||||
FenceEvent,
|
||||
@@ -34,6 +36,7 @@ export type {
|
||||
SpawnEvent,
|
||||
StairEvent,
|
||||
StairSegmentEvent,
|
||||
StructuralGridEvent,
|
||||
WallEvent,
|
||||
WindowEvent,
|
||||
ZoneEvent,
|
||||
@@ -46,12 +49,16 @@ export {
|
||||
} from './hooks/scene-registry/scene-registry'
|
||||
export {
|
||||
type FloorPlacedElevationArgs,
|
||||
GROUND_SUPPORT_ID,
|
||||
getFloorPlacedElevation,
|
||||
getFloorPlacedFootprints,
|
||||
getFloorStackedPosition,
|
||||
} from './hooks/spatial-grid/floor-placed-elevation'
|
||||
export {
|
||||
getWallEffectiveHeightForNodes,
|
||||
type PointedSupportSurface,
|
||||
pointInPolygon,
|
||||
SUPPORT_ELEVATION_EPSILON,
|
||||
spatialGridManager,
|
||||
type WallSlabSupportSegment,
|
||||
} from './hooks/spatial-grid/spatial-grid-manager'
|
||||
@@ -61,6 +68,14 @@ export {
|
||||
resolveBuildingForLevel,
|
||||
resolveLevelId,
|
||||
} 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 { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||
export {
|
||||
@@ -76,6 +91,7 @@ export {
|
||||
closestMeasurementFeatureBinding,
|
||||
MEASUREMENT_PLANAR_TOLERANCE,
|
||||
measurementAnchorFallback,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementAreaVector,
|
||||
@@ -86,6 +102,7 @@ export {
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
measurementReferenceNodeIds,
|
||||
remapMeasurementAnchors,
|
||||
remapMeasurementReferences,
|
||||
} from './lib/measurement-geometry'
|
||||
export {
|
||||
@@ -123,7 +140,6 @@ export {
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
planAutoZonesForLevel,
|
||||
projectAutoSlabsForPlan,
|
||||
resolveAutoZonePolygon,
|
||||
resumeSpaceDetection,
|
||||
type Space,
|
||||
@@ -146,7 +162,9 @@ export {
|
||||
} from './lib/zone-quantities'
|
||||
export {
|
||||
getCatalogMaterialById,
|
||||
getDynamicLibraryMaterials,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getLibraryMaterialsVersion,
|
||||
getMaterialPresetByRef,
|
||||
getMaterialsForCategory,
|
||||
getSceneMaterialIdFromRef,
|
||||
@@ -157,12 +175,16 @@ export {
|
||||
type MaterialCatalogItem,
|
||||
type MaterialCategory,
|
||||
type MaterialRef,
|
||||
type MaterialSource,
|
||||
type MaterialSurface,
|
||||
type ParsedMaterialRef,
|
||||
parseMaterialRef,
|
||||
registerLibraryMaterials,
|
||||
SCENE_MATERIAL_REF_PREFIX,
|
||||
subscribeLibraryMaterials,
|
||||
toLibraryMaterialRef,
|
||||
toSceneMaterialRef,
|
||||
unregisterLibraryMaterials,
|
||||
} from './material-library'
|
||||
export type {
|
||||
FloorPlacedFootprint,
|
||||
@@ -248,9 +270,7 @@ export {
|
||||
} from './systems/elevator/elevator-runtime'
|
||||
export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system'
|
||||
export {
|
||||
DEFAULT_ELEVATOR_LEVEL_HEIGHT,
|
||||
type ElevatorLevelEntry,
|
||||
getElevatorLevelHeight,
|
||||
resolveElevatorBuildingLevels,
|
||||
resolveElevatorLevels,
|
||||
resolveElevatorServiceLevelIds,
|
||||
@@ -269,13 +289,20 @@ export {
|
||||
isSplineFence,
|
||||
sampleFenceSpline,
|
||||
} 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 { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
|
||||
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
|
||||
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
|
||||
export { resolveStairTotalRise } from './systems/stair/stair-rise'
|
||||
export {
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallArcData,
|
||||
getWallChordFrame,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
@@ -313,6 +340,11 @@ export {
|
||||
type WallMoveLinkedWallTargetPlan,
|
||||
type WallPlanPoint,
|
||||
} 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 { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { MeasurementFeature } from '../registry/types'
|
||||
import type { MeasurementPoint } from '../schema/nodes/measurement'
|
||||
import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
|
||||
import {
|
||||
areMeasurementPointsCoplanar,
|
||||
closestMeasurementFeatureBinding,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementAreaVector,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
measurementNormal,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
remapMeasurementAnchors,
|
||||
} from './measurement-geometry'
|
||||
|
||||
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
|
||||
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
|
||||
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
|
||||
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
|
||||
})
|
||||
|
||||
test('remaps and collects references for arbitrary anchor strings', () => {
|
||||
const anchors: MeasurementAnchor[] = [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
|
||||
fallback: [4, 0, 0],
|
||||
},
|
||||
]
|
||||
|
||||
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
|
||||
const remapped = remapMeasurementAnchors(
|
||||
anchors,
|
||||
new Map([
|
||||
['wall_a', 'wall_a_copy'],
|
||||
['wall_b', 'wall_b_copy'],
|
||||
]),
|
||||
)
|
||||
const first = remapped[0]!
|
||||
const last = remapped[2]!
|
||||
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
|
||||
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
|
||||
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
|
||||
import type {
|
||||
MeasurementAnchor,
|
||||
MeasurementPayload,
|
||||
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
|
||||
measurement: MeasurementPayload,
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): MeasurementPayload {
|
||||
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const nodeId = idMap.get(anchor.reference.nodeId)
|
||||
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
|
||||
}
|
||||
const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
|
||||
remapMeasurementAnchors([anchor], idMap)[0]!
|
||||
|
||||
switch (measurement.kind) {
|
||||
case 'distance':
|
||||
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
|
||||
}
|
||||
}
|
||||
|
||||
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
|
||||
const anchors =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points
|
||||
: measurement.base
|
||||
export function remapMeasurementAnchors(
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): MeasurementAnchor[] {
|
||||
return anchors.map((anchor) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const nodeId = idMap.get(anchor.reference.nodeId)
|
||||
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
|
||||
})
|
||||
}
|
||||
|
||||
export function remapConstructionDimensionReferences(
|
||||
dimension: ConstructionDimensionNode,
|
||||
idMap: ReadonlyMap<string, string>,
|
||||
): ConstructionDimensionNode {
|
||||
const controllingDimensionId = dimension.controllingDimensionId
|
||||
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
|
||||
dimension.controllingDimensionId)
|
||||
: null
|
||||
return {
|
||||
...dimension,
|
||||
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
|
||||
controllingDimensionId,
|
||||
}
|
||||
}
|
||||
|
||||
export function measurementAnchorReferenceNodeIds(
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
): AnyNodeId[] {
|
||||
const ids = new Set<string>()
|
||||
for (const anchor of anchors) {
|
||||
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
|
||||
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
|
||||
return [...ids] as AnyNodeId[]
|
||||
}
|
||||
|
||||
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
|
||||
const anchors =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points
|
||||
: measurement.base
|
||||
return measurementAnchorReferenceNodeIds(anchors)
|
||||
}
|
||||
|
||||
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
|
||||
if (points.length < 3) return [0, 0, 0]
|
||||
|
||||
|
||||
@@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1)
|
||||
return WallNode.parse({ start, end, thickness })
|
||||
}
|
||||
|
||||
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) {
|
||||
return SlabNode.parse(
|
||||
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation },
|
||||
)
|
||||
function slabOf(
|
||||
polygon: Array<[number, number]>,
|
||||
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]>) {
|
||||
@@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => {
|
||||
],
|
||||
false,
|
||||
0.34,
|
||||
0.34,
|
||||
)
|
||||
const low = slabOf(
|
||||
[
|
||||
@@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => {
|
||||
],
|
||||
false,
|
||||
0.4,
|
||||
0.4,
|
||||
)
|
||||
const legacyLow = slabOf(
|
||||
[
|
||||
@@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => {
|
||||
})
|
||||
|
||||
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 platform = slabOf(roomA, false, 0.4)
|
||||
const platform = slabOf(roomA, false, 0.4, 0.4)
|
||||
const walls = [
|
||||
wallOf([0, 0], [4, 0]),
|
||||
wallOf([4, 0], [4, 3]),
|
||||
@@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => {
|
||||
],
|
||||
false,
|
||||
0.3,
|
||||
0.3,
|
||||
)
|
||||
const stepLow = slabOf(
|
||||
[
|
||||
@@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => {
|
||||
],
|
||||
true,
|
||||
0.4,
|
||||
0.4,
|
||||
)
|
||||
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', () => {
|
||||
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])])
|
||||
|
||||
@@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint'
|
||||
* render offsets.
|
||||
* - 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
|
||||
* perpendicular STEP connector at the breakpoint. Breakpoints sit on
|
||||
* 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 SLAB_SEAM_ELEVATION_EPSILON = 1e-4
|
||||
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. */
|
||||
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 = {
|
||||
/** Walls on the slab's level. */
|
||||
walls: WallNode[]
|
||||
@@ -138,7 +164,7 @@ export function getRenderableSlabPolygon(
|
||||
context: SlabPolygonContext,
|
||||
): Array<[number, number]> {
|
||||
const polygon = slabNode.polygon
|
||||
if (polygon.length < 3) {
|
||||
if (polygon.length < 3 || isFloatingSlab(slabNode)) {
|
||||
return polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
}
|
||||
|
||||
@@ -368,6 +394,11 @@ function computeEdgeSubSpans(
|
||||
|
||||
const neighborSegments: NeighborSegment[] = []
|
||||
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
|
||||
if (siblingPolygon.length < 2) continue
|
||||
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoCeilingsForLevel,
|
||||
planAutoSlabsForLevel,
|
||||
planAutoZonesForLevel,
|
||||
@@ -38,16 +42,35 @@ function slab(elevation: number) {
|
||||
}
|
||||
|
||||
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()], [], {
|
||||
walls: squareWalls(),
|
||||
slabs: [slab(0.05)],
|
||||
storeyHeight: 2.7,
|
||||
}).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({
|
||||
polygon: square,
|
||||
height: 2.55,
|
||||
@@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => {
|
||||
})
|
||||
|
||||
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
|
||||
walls: squareWalls(),
|
||||
slabs: [slab(0.4)],
|
||||
storeyHeight: 3,
|
||||
})
|
||||
|
||||
expect(plan.update).toHaveLength(1)
|
||||
expect(plan.update[0]?.id).toBe(ceiling.id)
|
||||
expect(plan.update[0]?.data.polygon).toBeUndefined()
|
||||
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)
|
||||
// The sync no longer re-derives auto heights; a user-set explicit
|
||||
// height survives (still under the bound, so no clamp either).
|
||||
expect(plan.update).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('does not replace a manual ceiling with an auto ceiling', () => {
|
||||
@@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => {
|
||||
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], {
|
||||
walls: squareWalls(),
|
||||
slabs: [slab(0.4)],
|
||||
storeyHeight: 2.7,
|
||||
})
|
||||
|
||||
expect(plan.create).toHaveLength(0)
|
||||
@@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => {
|
||||
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data })
|
||||
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], {
|
||||
walls: squareWalls(),
|
||||
slabs: [slab(0.05)],
|
||||
storeyHeight: 2.7,
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const areaOf = (polygon: Array<{ x: number; y: number }>) => {
|
||||
let area = 0
|
||||
|
||||
@@ -2,12 +2,21 @@ import {
|
||||
type AnyNodeId,
|
||||
CeilingNode,
|
||||
type CeilingNode as CeilingNodeType,
|
||||
type LevelNode,
|
||||
SlabNode,
|
||||
type SlabNode as SlabNodeType,
|
||||
type WallNode,
|
||||
ZoneNode,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '../schema'
|
||||
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
|
||||
import {
|
||||
CEILING_CLAMP_MARGIN,
|
||||
findLevelAboveId,
|
||||
getCeilingClampBound,
|
||||
getLevelElevations,
|
||||
getStoredLevelHeight,
|
||||
} from '../services/storey'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
@@ -56,10 +65,6 @@ type DetectedRoom = {
|
||||
bbox: ReturnType<typeof bboxOf>
|
||||
}
|
||||
|
||||
type DetectedCeilingRoom = DetectedRoom & {
|
||||
ceilingHeight: number
|
||||
}
|
||||
|
||||
export type AutoSlabSyncPlan = {
|
||||
create: 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_CEILING_HEIGHT = 2.5
|
||||
const CEILING_HEIGHT_EPSILON = 1e-6
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
const MAX_CURVE_SUBDIVISION_DEPTH = 6
|
||||
@@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08
|
||||
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6
|
||||
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 = {
|
||||
walls?: WallNode[]
|
||||
slabs?: SlabNodeType[]
|
||||
/** Stored storey height of the level being planned (floor-to-floor). */
|
||||
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 {
|
||||
@@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) {
|
||||
return matchingPoints.length >= 2
|
||||
}
|
||||
|
||||
function pointIsOnSlab(point: Point2D, slab: SlabNodeType) {
|
||||
if (slab.polygon.length < 3) return false
|
||||
const slabPolygon = slab.polygon.map(pointFromTuple)
|
||||
if (!pointInPolygon(point, slabPolygon)) return false
|
||||
|
||||
for (const hole of slab.holes ?? []) {
|
||||
if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 = {},
|
||||
/**
|
||||
* The clamp bound for a ceiling polygon under this planning context —
|
||||
* the context's cross-level resolver when provided, else the plane-only
|
||||
* `storeyHeight - CEILING_CLAMP_MARGIN` degradation.
|
||||
*/
|
||||
function resolveCeilingClampBound(
|
||||
polygon: Array<[number, number]>,
|
||||
context: AutoCeilingPlanningContext,
|
||||
) {
|
||||
return (
|
||||
resolveRoomSlabElevation(roomPolygon, context.slabs) +
|
||||
resolveRoomWallHeight(roomPolygon, context.walls)
|
||||
)
|
||||
if (context.ceilingClampBound) return context.ceilingClampBound(polygon)
|
||||
return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN
|
||||
}
|
||||
|
||||
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
|
||||
@@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) {
|
||||
wall.end[0].toFixed(4),
|
||||
wall.end[1].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),
|
||||
].join('|')
|
||||
}
|
||||
@@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) {
|
||||
].join('|')
|
||||
}
|
||||
|
||||
// Slabs and ceilings stay out of the trigger signature: including generated
|
||||
// surfaces caused delete/recreate feedback. Zones are included only so a newly
|
||||
// traced room footprint can adopt its enclosing walls without waiting for the
|
||||
// next remodel.
|
||||
// Slab/ceiling POLYGONS stay out of the trigger signature: including
|
||||
// generated footprints caused delete/recreate feedback. Zones are included
|
||||
// only so a newly traced room footprint can adopt its enclosing walls
|
||||
// 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>) {
|
||||
const wallsByLevel = new Map<string, WallNode[]>()
|
||||
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)) {
|
||||
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) ?? []
|
||||
zones.push(ZoneNode.parse(node))
|
||||
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 levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()])
|
||||
for (const levelId of levelIds) {
|
||||
const walls = wallsByLevel.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(
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
roomPolygons: Point2D[][],
|
||||
existingCeilings: CeilingNodeType[],
|
||||
@@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel(
|
||||
)
|
||||
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple))
|
||||
|
||||
const detectedAll: DetectedCeilingRoom[] = roomPolygons
|
||||
const detectedAll: DetectedRoom[] = roomPolygons
|
||||
.map((poly) => ({
|
||||
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
|
||||
pointFromTuple,
|
||||
@@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel(
|
||||
centroid: polygonCentroid(room.poly),
|
||||
area: Math.abs(polygonArea(room.poly)),
|
||||
bbox: bboxOf(room.poly),
|
||||
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
|
||||
}))
|
||||
|
||||
const detected = detectedAll.filter(
|
||||
@@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel(
|
||||
|
||||
const matchedCeilingIds = new Set<string>()
|
||||
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]>>()
|
||||
for (const entry of existingAutoMeta) {
|
||||
@@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel(
|
||||
matchedCeilingIds.add(existing.ceiling.id)
|
||||
updatesById.set(existing.ceiling.id, {
|
||||
polygon: room.poly.map(pointToTuple),
|
||||
height: room.ceilingHeight,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel(
|
||||
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
|
||||
updatesById.set(bestMatch.entry.ceiling.id, {
|
||||
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 = [
|
||||
// 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
|
||||
.filter((ceiling) => updatesById.has(ceiling.id))
|
||||
.flatMap((ceiling) => {
|
||||
const update = updatesById.get(ceiling.id)
|
||||
if (!update) return []
|
||||
|
||||
const data: Partial<CeilingNodeType> = {}
|
||||
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 }]
|
||||
if (sameTuplePolygon(ceiling.polygon, update.polygon)) return []
|
||||
return [{ id: ceiling.id, data: { polygon: update.polygon } }]
|
||||
}),
|
||||
...ceilingDemotions,
|
||||
...manualClamps,
|
||||
]
|
||||
|
||||
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
|
||||
@@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel(
|
||||
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
|
||||
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(
|
||||
CeilingNode.parse({
|
||||
name,
|
||||
polygon: room.poly.map(pointToTuple),
|
||||
holes: [],
|
||||
height: room.ceilingHeight,
|
||||
autoFromWalls: true,
|
||||
}),
|
||||
)
|
||||
@@ -1402,14 +1395,21 @@ function runSpaceDetection(
|
||||
}
|
||||
|
||||
const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
|
||||
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
|
||||
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan)
|
||||
syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
|
||||
const levelNode = nodes[levelId]
|
||||
const storeyHeight =
|
||||
levelNode?.type === 'level'
|
||||
? getStoredLevelHeight(levelNode as LevelNode)
|
||||
: DEFAULT_LEVEL_HEIGHT
|
||||
syncAutoCeilingsForLevel(
|
||||
levelId,
|
||||
roomPolygons,
|
||||
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
|
||||
sceneStore,
|
||||
{ walls, slabs: projectedSlabs },
|
||||
{
|
||||
storeyHeight,
|
||||
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
|
||||
},
|
||||
)
|
||||
const zonePlan = planAutoZonesForLevel(
|
||||
spaces,
|
||||
|
||||
@@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record<string, AnyNode> {
|
||||
function roomNodes() {
|
||||
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 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) =>
|
||||
WallNode.parse({
|
||||
id: `wall_${index}`,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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 { 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'
|
||||
|
||||
type Point2D = readonly [number, number]
|
||||
@@ -466,13 +471,19 @@ function unavailable(reason: string): ZoneQuantityValue {
|
||||
|
||||
export function deriveZoneQuantityReport(
|
||||
zone: ZoneNode,
|
||||
sceneNodes: Record<string, AnyNode>,
|
||||
sceneNodes: Readonly<Record<string, AnyNode>>,
|
||||
): ZoneQuantityReport {
|
||||
const levelId = zone.parentId
|
||||
const levelNodes = levelId
|
||||
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
|
||||
: []
|
||||
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 end = zone.polygon[(index + 1) % zone.polygon.length]
|
||||
return end ? pointDistance(start, end) : 0
|
||||
@@ -488,7 +499,7 @@ export function deriveZoneQuantityReport(
|
||||
const ceilingCoverage = proveSurfaceCoverage(
|
||||
zone,
|
||||
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' },
|
||||
)
|
||||
|
||||
@@ -517,7 +528,7 @@ export function deriveZoneQuantityReport(
|
||||
? {
|
||||
status: 'available' as const,
|
||||
value: wallSpans!.reduce(
|
||||
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
|
||||
(sum, span) => sum + span.length * wallEffectiveHeight(span.wall),
|
||||
0,
|
||||
),
|
||||
note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
|
||||
|
||||
@@ -4,10 +4,14 @@ import {
|
||||
MaterialTarget as MaterialTargetSchema,
|
||||
} from './schema/material'
|
||||
|
||||
export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace'
|
||||
|
||||
export type MaterialCatalogItem = {
|
||||
id: string
|
||||
label: string
|
||||
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).
|
||||
* The paint picker may filter by the slot being painted; v1 shows everything.
|
||||
@@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [
|
||||
'roofing',
|
||||
'ground',
|
||||
'glass',
|
||||
'other',
|
||||
] as const
|
||||
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[] {
|
||||
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 {
|
||||
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:'
|
||||
|
||||
@@ -65,6 +65,8 @@ export type {
|
||||
Capabilities,
|
||||
CapabilityCtx,
|
||||
CuttableConfig,
|
||||
DimensionTerminator,
|
||||
DimensionTextPosition,
|
||||
DistributionRole,
|
||||
DragAction,
|
||||
DuplicableConfig,
|
||||
@@ -88,6 +90,7 @@ export type {
|
||||
FloorplanPoint,
|
||||
FloorplanStyle,
|
||||
GeometryContext,
|
||||
GroupMoveSnapArgs,
|
||||
HostableConfig,
|
||||
IconRef,
|
||||
Issue,
|
||||
|
||||
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
|
||||
) {
|
||||
const anchor = clonedMeasurement.measurement.points[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (!Array.isArray(anchor)) {
|
||||
if (anchor && !Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
|
||||
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
|
||||
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[1, 0, 0],
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
|
||||
fallback: [2, 0, 0],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
chainMode: 'continuous',
|
||||
})
|
||||
const result = cloneNodesInto([wall, dimension], {
|
||||
rootId: 'wall_1' as AnyNodeId,
|
||||
})
|
||||
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
|
||||
|
||||
expect(clonedDimension?.type).toBe('construction-dimension')
|
||||
if (clonedDimension?.type === 'construction-dimension') {
|
||||
const anchor = clonedDimension.anchors[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (anchor && !Array.isArray(anchor)) {
|
||||
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
const lastAnchor = clonedDimension.anchors[2]
|
||||
expect(Array.isArray(lastAnchor)).toBe(false)
|
||||
if (lastAnchor && !Array.isArray(lastAnchor)) {
|
||||
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('remaps a construction dimension foundation controller when both are cloned', () => {
|
||||
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
drawingType: 'foundation-plan',
|
||||
})
|
||||
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
|
||||
parentId: 'level_1',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
controllingDimensionId: controller.id,
|
||||
})
|
||||
|
||||
const result = cloneNodesInto([controller, dependent], {
|
||||
rootId: controller.id as AnyNodeId,
|
||||
})
|
||||
const clonedDependent = result.nodes.find(
|
||||
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
|
||||
)
|
||||
|
||||
expect(clonedDependent?.type).toBe('construction-dimension')
|
||||
if (clonedDependent?.type === 'construction-dimension') {
|
||||
expect(clonedDependent.controllingDimensionId).toBe(
|
||||
result.idMap.get(
|
||||
controller.id as AnyNodeId,
|
||||
) as typeof clonedDependent.controllingDimensionId,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('regenerates drawing-sheet identities while preserving external level references', () => {
|
||||
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [1, 1],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
})
|
||||
|
||||
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
|
||||
const cloned = nodes[0]
|
||||
|
||||
expect(cloned?.type).toBe('drawing-sheet')
|
||||
if (cloned?.type === 'drawing-sheet') {
|
||||
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
|
||||
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
|
||||
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
|
||||
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('parents the cloned root under opts.parentId when supplied', () => {
|
||||
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
|
||||
const { nodes } = cloneNodesInto([orig], {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { remapMeasurementReferences } from '../lib/measurement-geometry'
|
||||
import {
|
||||
remapConstructionDimensionReferences,
|
||||
remapMeasurementReferences,
|
||||
} from '../lib/measurement-geometry'
|
||||
import { generateId } from '../schema/base'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
// Generic, opinion-free primitives the host app composes to implement
|
||||
@@ -141,7 +145,7 @@ export function cloneNodesInto(
|
||||
const out: AnyNode[] = []
|
||||
let root: AnyNode | null = null
|
||||
for (const original of nodes) {
|
||||
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
|
||||
let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
|
||||
const freshId = idMap.get(original.id)!
|
||||
;(cloned as { id: AnyNodeId }).id = freshId
|
||||
// parentId: root's parentId becomes opts.parentId (or preserved
|
||||
@@ -169,6 +173,12 @@ export function cloneNodesInto(
|
||||
if (cloned.type === 'measurement') {
|
||||
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
|
||||
}
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
if (original.id === opts.rootId) {
|
||||
if (opts.position) {
|
||||
|
||||
@@ -51,6 +51,8 @@ export type GeometryContext = {
|
||||
* `scene:` refs.
|
||||
*/
|
||||
materials?: Record<SceneMaterialId, SceneMaterial>
|
||||
/** Opaque host/plugin context. Core never interprets extension values. */
|
||||
extensions?: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* Optional view state — only populated for `def.floorplan` builders. The
|
||||
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
|
||||
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
|
||||
|
||||
export type FloorplanPoint = readonly [x: number, y: number]
|
||||
|
||||
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
|
||||
|
||||
export type DimensionTextPosition = 'above' | 'centered'
|
||||
|
||||
export type FloorplanStyle = {
|
||||
stroke?: string
|
||||
fill?: string
|
||||
strokeWidth?: number
|
||||
strokeDasharray?: string
|
||||
opacity?: number
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
/**
|
||||
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
|
||||
* as a constant screen-pixel width regardless of viewport zoom. Maps
|
||||
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
|
||||
* of the floor-plan's scene rotation (default 90°).
|
||||
*/
|
||||
upright?: boolean
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/**
|
||||
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
|
||||
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
|
||||
children: FloorplanGeometry[]
|
||||
/** Optional transform applied to all children. Rotation in radians. */
|
||||
transform?: { translate?: FloorplanPoint; rotate?: number }
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
/**
|
||||
* Hatched fill overlay — same polygon shape as the kind's main fill but
|
||||
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
|
||||
kind: 'dimension'
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint
|
||||
/**
|
||||
* Optional explicit dimension-line endpoints. Use these when the
|
||||
* measured origins sit at different depths, such as stepped facades or
|
||||
* an exterior column row. Extension lines still originate at
|
||||
* `start`/`end`, while the measurement is drawn between these aligned
|
||||
* baseline points.
|
||||
*/
|
||||
dimensionStart?: FloorplanPoint
|
||||
dimensionEnd?: FloorplanPoint
|
||||
/** Outward-pointing unit normal — the dimension line offsets along this. */
|
||||
offsetNormal: FloorplanPoint
|
||||
/** Distance (plan units) from the edge to the dimension line. */
|
||||
offsetDistance: number
|
||||
/** How far past the offset point the extension line continues. */
|
||||
extensionOvershoot: number
|
||||
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
|
||||
extensionStartGap?: number
|
||||
/** Dimension-line terminator. Defaults to an architectural tick. */
|
||||
terminator?: DimensionTerminator
|
||||
/** Dimension text position relative to the baseline. Defaults above the line. */
|
||||
textPosition?: DimensionTextPosition
|
||||
text: string
|
||||
/** Optional override for the line/text colour. Defaults to the palette accent. */
|
||||
stroke?: string
|
||||
}
|
||||
| {
|
||||
kind: 'dimension-string'
|
||||
segments: readonly {
|
||||
start: FloorplanPoint
|
||||
end: FloorplanPoint
|
||||
/**
|
||||
* Optional explicit dimension-line endpoints. Use these when the
|
||||
* measured origins sit at different depths, such as stepped facades or
|
||||
* an exterior column row. Extension lines still originate at
|
||||
* `start`/`end`, while the measurement is drawn between these aligned
|
||||
* baseline points.
|
||||
*/
|
||||
dimensionStart?: FloorplanPoint
|
||||
dimensionEnd?: FloorplanPoint
|
||||
text: string
|
||||
}[]
|
||||
/** Outward-pointing unit normal shared by every segment in the string. */
|
||||
offsetNormal: FloorplanPoint
|
||||
/** Distance (plan units) from each measured origin to its dimension line. */
|
||||
offsetDistance: number
|
||||
/** How far past each offset point the extension line continues. */
|
||||
extensionOvershoot: number
|
||||
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
|
||||
extensionStartGap?: number
|
||||
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
|
||||
terminator?: DimensionTerminator
|
||||
/** Dimension text position shared by every segment. Defaults above the line. */
|
||||
textPosition?: DimensionTextPosition
|
||||
/** Optional override for the line/text colour. Defaults to the palette accent. */
|
||||
stroke?: string
|
||||
/** Opaque renderer/plugin metadata. Core never interprets these values. */
|
||||
metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// ─── FloorplanAffordance ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
/** Opaque host/plugin contributions. Core stores but never interprets them. */
|
||||
extensions?: Readonly<Record<string, unknown>>
|
||||
surfaceRole?: SurfaceRole
|
||||
/**
|
||||
* Show a floor direction-triangle while placing/moving — the kind has a
|
||||
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
portConnectivityFollow?: boolean
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
|
||||
capabilities: Capabilities
|
||||
relations?: Relations
|
||||
|
||||
@@ -51,8 +51,33 @@ export {
|
||||
ColumnStyle,
|
||||
ColumnSupportStyle,
|
||||
} from './nodes/column'
|
||||
export {
|
||||
CONSTRUCTION_DRAWING_TYPES,
|
||||
ConstructionDimensionBaseline,
|
||||
ConstructionDimensionChainMode,
|
||||
ConstructionDimensionDatumPolicy,
|
||||
ConstructionDimensionDrawingOverride,
|
||||
ConstructionDimensionDrawingPresentation,
|
||||
ConstructionDimensionImperialPrecision,
|
||||
ConstructionDimensionMetricNotation,
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
ConstructionDimensionTerminator,
|
||||
ConstructionDimensionTextPosition,
|
||||
ConstructionDrawingType,
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
resolveConstructionDimensionDrawingOverride,
|
||||
resolveConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingPresentation,
|
||||
setConstructionDimensionDrawingSuppressedSegments,
|
||||
} from './nodes/construction-dimension'
|
||||
export { CupolaNode } from './nodes/cupola'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export {
|
||||
DoorNode,
|
||||
DoorSegment,
|
||||
OpeningConstructionType,
|
||||
OpeningDimensionReference,
|
||||
} from './nodes/door'
|
||||
export {
|
||||
DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
@@ -60,6 +85,25 @@ export {
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
remapDrawingSheetReferences,
|
||||
} from './nodes/drawing-sheet'
|
||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -184,7 +228,7 @@ export {
|
||||
SkylightType,
|
||||
type SkylightTypePreset,
|
||||
} from './nodes/skylight'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab'
|
||||
export {
|
||||
SolarPanelMaterialRole,
|
||||
SolarPanelNode,
|
||||
@@ -200,9 +244,13 @@ export {
|
||||
StairType,
|
||||
} from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { StructuralGridNode } from './nodes/structural-grid'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { TurbineVentNode } from './nodes/turbine-vent'
|
||||
export type {
|
||||
WallAssemblyDatumReference,
|
||||
WallAssemblyDatumSide,
|
||||
WallAssemblyLayer,
|
||||
WallBandSurfaceSlotId,
|
||||
WallFaceBand,
|
||||
WallFaceBandConfig,
|
||||
@@ -215,11 +263,18 @@ export {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyLayers,
|
||||
getWallAssemblyThickness,
|
||||
getWallBandSlotId,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallSurfaceMaterialSignature,
|
||||
getWallSurfaceSideFromBandSlot,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -230,11 +285,18 @@ export {
|
||||
WALL_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WALL_TRIM_DEFAULTS,
|
||||
WallAssemblyLayerRole,
|
||||
WallDimensionDatum,
|
||||
WallNode,
|
||||
WallTreatmentSide,
|
||||
WallTrimProfile,
|
||||
} from './nodes/wall'
|
||||
export { WindowNode, WindowType } from './nodes/window'
|
||||
export {
|
||||
WindowConstructionType,
|
||||
WindowDimensionReference,
|
||||
WindowNode,
|
||||
WindowType,
|
||||
} from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { DrawingSheetNode } from './drawing-sheet'
|
||||
import { ElevatorNode } from './elevator'
|
||||
import { LevelNode } from './level'
|
||||
|
||||
export const BuildingNode = BaseNode.extend({
|
||||
id: objectId('building'),
|
||||
type: nodeType('building'),
|
||||
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
|
||||
children: z
|
||||
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
|
||||
.default([]),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
}).describe(
|
||||
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
|
||||
Building node - used to represent a building
|
||||
- position: position in site coordinate system
|
||||
- rotation: rotation in site coordinate system
|
||||
- children: array of level nodes and building-level systems such as elevators
|
||||
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
|
||||
const cabinetBoxFields = {
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 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),
|
||||
depth: z.number().min(0.3).max(1.2).default(0.5),
|
||||
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
|
||||
|
||||
@@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).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),
|
||||
}).describe(
|
||||
dedent`
|
||||
@@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({
|
||||
- polygon: array of [x, z] points defining the ceiling boundary
|
||||
- holes: array of polygons representing holes in the ceiling
|
||||
- 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
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({
|
||||
type: nodeType('column'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.number().default(0),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
supportSlabId: z.string().optional(),
|
||||
style: ColumnStyle.default('plain'),
|
||||
crossSection: ColumnCrossSection.default('round'),
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
|
||||
export type DoorSegment = z.infer<typeof DoorSegment>
|
||||
|
||||
export const DoorCategory = z.enum(['interior', 'garage'])
|
||||
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
|
||||
export const OpeningDimensionReference = z.enum([
|
||||
'nominal',
|
||||
'rough-opening',
|
||||
'masonry-opening',
|
||||
'finish-opening',
|
||||
])
|
||||
export const DoorType = z.enum([
|
||||
'hinged',
|
||||
'double',
|
||||
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
|
||||
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
|
||||
|
||||
export type DoorCategory = z.infer<typeof DoorCategory>
|
||||
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
|
||||
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
|
||||
export type DoorType = z.infer<typeof DoorType>
|
||||
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
|
||||
|
||||
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
|
||||
width: z.number().default(0.9),
|
||||
height: z.number().default(2.1),
|
||||
|
||||
// Construction-document identity. `mark` overrides the deterministic
|
||||
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
|
||||
// because they are manufacturer/framing inputs, not safe derivations from
|
||||
// the nominal modeled size.
|
||||
mark: z.string().trim().max(16).optional(),
|
||||
constructionType: OpeningConstructionType.default('framed'),
|
||||
dimensionReference: OpeningDimensionReference.default('nominal'),
|
||||
roughOpeningWidth: z.number().positive().optional(),
|
||||
roughOpeningHeight: z.number().positive().optional(),
|
||||
masonryOpeningWidth: z.number().positive().optional(),
|
||||
masonryOpeningHeight: z.number().positive().optional(),
|
||||
finishOpeningWidth: z.number().positive().optional(),
|
||||
finishOpeningHeight: z.number().positive().optional(),
|
||||
|
||||
// Door family
|
||||
doorCategory: DoorCategory.default('interior'),
|
||||
doorType: DoorType.default('hinged'),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { BuildingNode } from './building'
|
||||
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
|
||||
|
||||
describe('DrawingSheetNode', () => {
|
||||
test('creates persistent sheet defaults', () => {
|
||||
const sheet = DrawingSheetNode.parse({})
|
||||
|
||||
expect(sheet.type).toBe('drawing-sheet')
|
||||
expect(sheet.id).toMatch(/^drawing-sheet_/)
|
||||
expect(sheet).toMatchObject({
|
||||
sheetNumber: 'A1.0',
|
||||
sheetTitle: 'Floor Plan',
|
||||
paperSize: 'arch-b',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
annotationProfile: 'architectural-default',
|
||||
placedViews: [],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [],
|
||||
keyedNoteInstances: [],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
titleBlock: {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('stores placed views, notes, schedules, and title-block metadata', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
sheetNumber: 'A2.1',
|
||||
sheetTitle: 'Enlarged Plans',
|
||||
paperSize: 'custom',
|
||||
customPaperWidth: 24,
|
||||
customPaperHeight: 36,
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '2',
|
||||
title: 'Main Floor Plan',
|
||||
levelId: 'level_main',
|
||||
scale: '1/4"=1\'-0"',
|
||||
viewport: { x: 1, y: 1, width: 12, height: 8 },
|
||||
},
|
||||
],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
|
||||
keyedNoteDefinitions: [
|
||||
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
|
||||
],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-1',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
},
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-2',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
position: [5, 4],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
|
||||
documentMarkers: [
|
||||
{
|
||||
id: 'sheet-marker_wall-a',
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [2, 3],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_revision-a',
|
||||
kind: 'revision-cloud',
|
||||
label: '1',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
],
|
||||
schedules: [
|
||||
{
|
||||
id: 'sheet-schedule_room',
|
||||
scheduleType: 'room',
|
||||
title: 'Room Schedule',
|
||||
region: { x: 15, y: 1, width: 6, height: 5 },
|
||||
},
|
||||
],
|
||||
titleBlock: {
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sheet.placedViews[0]).toMatchObject({
|
||||
drawingType: 'floor-plan',
|
||||
levelId: 'level_main',
|
||||
annotationProfile: 'architectural-default',
|
||||
showNorthArrow: true,
|
||||
showGraphicScale: true,
|
||||
})
|
||||
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
|
||||
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
|
||||
expect(sheet.generalNoteSets[0]).toMatchObject({
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ text: 'COORDINATE WITH OWNER.' }],
|
||||
})
|
||||
expect(sheet.keyedNoteLegend[0]).toEqual({
|
||||
key: 'A',
|
||||
text: 'ALIGN WITH EXISTING WALL.',
|
||||
})
|
||||
expect(sheet.keyedNoteDefinitions[0]).toEqual({
|
||||
id: 'keyed-note_patch-slab',
|
||||
key: 'A',
|
||||
text: 'PATCH EXISTING SLAB.',
|
||||
})
|
||||
expect(sheet.keyedNoteInstances).toHaveLength(2)
|
||||
expect(sheet.keyedNoteInstances[0]).toMatchObject({
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
})
|
||||
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
|
||||
expect(sheet.documentMarkers).toHaveLength(2)
|
||||
expect(sheet.documentMarkers[0]).toMatchObject({
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
position: [2, 3],
|
||||
})
|
||||
expect(sheet.documentMarkers[1]).toMatchObject({
|
||||
kind: 'revision-cloud',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
})
|
||||
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
|
||||
expect(sheet.titleBlock).toMatchObject({
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
drawnBy: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('can live under a building instead of a level', () => {
|
||||
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
|
||||
|
||||
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
|
||||
'level_main',
|
||||
sheet.id,
|
||||
])
|
||||
})
|
||||
|
||||
test('remaps sheet-local identities and their references together', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a1',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
},
|
||||
],
|
||||
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
|
||||
schedules: [{ id: 'sheet-schedule_a' }],
|
||||
})
|
||||
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
|
||||
|
||||
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
|
||||
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
|
||||
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
|
||||
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
|
||||
sheet.generalNoteSets[0]?.notes[0]?.id,
|
||||
)
|
||||
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
|
||||
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, generateId, nodeType, objectId } from '../base'
|
||||
import { ConstructionDrawingType } from './construction-dimension'
|
||||
|
||||
const PositiveFinite = z.number().finite().positive()
|
||||
const SheetCoordinate = z.number().finite().min(0)
|
||||
|
||||
export const DrawingSheetPaperSize = z.enum([
|
||||
'letter',
|
||||
'tabloid',
|
||||
'arch-a',
|
||||
'arch-b',
|
||||
'arch-c',
|
||||
'a4',
|
||||
'a3',
|
||||
'custom',
|
||||
])
|
||||
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
|
||||
export const DrawingSheetScale = z.enum([
|
||||
'1:20',
|
||||
'1:25',
|
||||
'1:50',
|
||||
'1:75',
|
||||
'1:100',
|
||||
'1/8"=1\'-0"',
|
||||
'1/4"=1\'-0"',
|
||||
'1/2"=1\'-0"',
|
||||
'1"=1\'-0"',
|
||||
])
|
||||
export const DrawingSheetAnnotationProfile = z.enum([
|
||||
'architectural-default',
|
||||
'presentation',
|
||||
'permit',
|
||||
])
|
||||
|
||||
export const DrawingSheetRect = z.object({
|
||||
x: SheetCoordinate.default(0),
|
||||
y: SheetCoordinate.default(0),
|
||||
width: PositiveFinite.default(1),
|
||||
height: PositiveFinite.default(1),
|
||||
})
|
||||
|
||||
export const DrawingSheetPlacedView = z.object({
|
||||
id: objectId('drawing-view'),
|
||||
drawingType: ConstructionDrawingType.default('floor-plan'),
|
||||
drawingNumber: z.string().trim().min(1).max(24).default('1'),
|
||||
title: z.string().trim().min(1).max(80).default('Floor Plan'),
|
||||
levelId: objectId('level').nullable().default(null),
|
||||
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
|
||||
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
showNorthArrow: z.boolean().default(true),
|
||||
showGraphicScale: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNote = z.object({
|
||||
id: objectId('sheet-note'),
|
||||
number: z.number().int().positive().default(1),
|
||||
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNoteSet = z.object({
|
||||
id: objectId('sheet-note-set'),
|
||||
name: z.string().trim().min(1).max(80).default('General Notes'),
|
||||
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNote = z.object({
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteDefinition = z.object({
|
||||
id: objectId('keyed-note'),
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteInstance = z.object({
|
||||
id: objectId('keyed-note-instance'),
|
||||
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
})
|
||||
|
||||
export const DrawingSheetDocumentMarkerKind = z.enum([
|
||||
'wall-tag',
|
||||
'glazing-tag',
|
||||
'assembly-tag',
|
||||
'section-callout',
|
||||
'elevation-callout',
|
||||
'detail-reference',
|
||||
'delta-marker',
|
||||
'revision-cloud',
|
||||
])
|
||||
|
||||
export const DrawingSheetDocumentMarker = z.object({
|
||||
id: objectId('sheet-marker'),
|
||||
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
label: z.string().trim().min(1).max(32).default('1'),
|
||||
title: z.string().trim().max(120).default(''),
|
||||
sheetReference: z.string().trim().max(24).default(''),
|
||||
drawingReference: z.string().trim().max(24).default(''),
|
||||
revisionId: z.string().trim().max(16).default(''),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
|
||||
points: z
|
||||
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
|
||||
.max(64)
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetSchedulePlacement = z.object({
|
||||
id: objectId('sheet-schedule'),
|
||||
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
|
||||
title: z.string().trim().min(1).max(80).default('Room Schedule'),
|
||||
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
|
||||
})
|
||||
|
||||
export const DrawingSheetTitleBlock = z.object({
|
||||
projectName: z.string().trim().max(120).default(''),
|
||||
projectNumber: z.string().trim().max(40).default(''),
|
||||
clientName: z.string().trim().max(120).default(''),
|
||||
drawnBy: z.string().trim().max(40).default(''),
|
||||
checkedBy: z.string().trim().max(40).default(''),
|
||||
issueDate: z.string().trim().max(40).default(''),
|
||||
revision: z.string().trim().max(20).default(''),
|
||||
})
|
||||
|
||||
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
}
|
||||
|
||||
export const DrawingSheetNode = BaseNode.extend({
|
||||
id: objectId('drawing-sheet'),
|
||||
type: nodeType('drawing-sheet'),
|
||||
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
|
||||
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
|
||||
paperSize: DrawingSheetPaperSize.default('arch-b'),
|
||||
orientation: DrawingSheetOrientation.default('landscape'),
|
||||
customPaperWidth: PositiveFinite.nullable().default(null),
|
||||
customPaperHeight: PositiveFinite.nullable().default(null),
|
||||
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
|
||||
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
|
||||
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
|
||||
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
|
||||
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
|
||||
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
|
||||
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
|
||||
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
|
||||
}).describe(
|
||||
dedent`
|
||||
Drawing sheet node - persistent construction-document sheet metadata
|
||||
- sheetNumber/sheetTitle: sheet identity in the drawing set
|
||||
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
|
||||
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
|
||||
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
|
||||
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
|
||||
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
|
||||
- schedules/titleBlock: sheet-level documentation content and title-block metadata
|
||||
`,
|
||||
)
|
||||
|
||||
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
|
||||
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
|
||||
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
|
||||
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
|
||||
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
|
||||
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
|
||||
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
|
||||
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
|
||||
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
|
||||
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
|
||||
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
|
||||
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
|
||||
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
|
||||
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
|
||||
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
|
||||
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
|
||||
|
||||
/**
|
||||
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
|
||||
* External scene references are preserved when they are not present in
|
||||
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
|
||||
*/
|
||||
export function remapDrawingSheetReferences(
|
||||
sheet: DrawingSheetNode,
|
||||
sceneIdMap: ReadonlyMap<string, string>,
|
||||
): DrawingSheetNode {
|
||||
const placedViewIds = new Map(
|
||||
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
|
||||
)
|
||||
const noteSetIds = new Map(
|
||||
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
|
||||
)
|
||||
const noteIds = new Map(
|
||||
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
|
||||
(note) => [note.id, generateId('sheet-note')] as const,
|
||||
),
|
||||
)
|
||||
const keyedDefinitionIds = new Map(
|
||||
sheet.keyedNoteDefinitions.map(
|
||||
(definition) => [definition.id, generateId('keyed-note')] as const,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...sheet,
|
||||
placedViews: sheet.placedViews.map((view) => ({
|
||||
...view,
|
||||
id: placedViewIds.get(view.id)!,
|
||||
levelId: view.levelId
|
||||
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
|
||||
: null,
|
||||
})),
|
||||
generalNoteSetIds: sheet.generalNoteSetIds.map(
|
||||
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
|
||||
),
|
||||
generalNoteSets: sheet.generalNoteSets.map((set) => ({
|
||||
...set,
|
||||
id: noteSetIds.get(set.id)!,
|
||||
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
})),
|
||||
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
|
||||
...definition,
|
||||
id: keyedDefinitionIds.get(definition.id)!,
|
||||
})),
|
||||
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
|
||||
...instance,
|
||||
id: generateId('keyed-note-instance'),
|
||||
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
|
||||
instance.definitionId) as typeof instance.definitionId,
|
||||
placedViewId: instance.placedViewId
|
||||
? ((placedViewIds.get(instance.placedViewId) ??
|
||||
instance.placedViewId) as typeof instance.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
documentMarkers: sheet.documentMarkers.map((marker) => ({
|
||||
...marker,
|
||||
id: generateId('sheet-marker'),
|
||||
placedViewId: marker.placedViewId
|
||||
? ((placedViewIds.get(marker.placedViewId) ??
|
||||
marker.placedViewId) as typeof marker.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
schedules: sheet.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
id: generateId('sheet-schedule'),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Yaw in radians.
|
||||
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'),
|
||||
// Which surface the terminal mounts on. Drives face orientation and
|
||||
// which way the collar (and its port) points.
|
||||
|
||||
@@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({
|
||||
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
|
||||
height: z.number().default(1.8),
|
||||
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(),
|
||||
baseHeight: z.number().default(0.22),
|
||||
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
|
||||
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
|
||||
- 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)
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- 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]),
|
||||
// Yaw in radians.
|
||||
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'),
|
||||
// Cabinet dimensions in meters. Defaults match a typical upflow
|
||||
// furnace cabinet (~22" × 28" footprint, ~43" tall).
|
||||
|
||||
@@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({
|
||||
roofSegmentId: z.string().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
|
||||
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'])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import type { CeilingNode } from './ceiling'
|
||||
import type { ColumnNode } from './column'
|
||||
import type { ConstructionDimensionNode } from './construction-dimension'
|
||||
import type { DuctFittingNode } from './duct-fitting'
|
||||
import type { DuctSegmentNode } from './duct-segment'
|
||||
import type { DuctTerminalNode } from './duct-terminal'
|
||||
@@ -22,6 +23,7 @@ import type { ShelfNode } from './shelf'
|
||||
import type { SlabNode } from './slab'
|
||||
import type { SpawnNode } from './spawn'
|
||||
import type { StairNode } from './stair'
|
||||
import type { StructuralGridNode } from './structural-grid'
|
||||
import type { WallNode } from './wall'
|
||||
import type { ZoneNode } from './zone'
|
||||
|
||||
@@ -29,6 +31,8 @@ type CoreLevelChildId =
|
||||
| WallNode['id']
|
||||
| FenceNode['id']
|
||||
| ColumnNode['id']
|
||||
| ConstructionDimensionNode['id']
|
||||
| StructuralGridNode['id']
|
||||
| ItemNode['id']
|
||||
| ZoneNode['id']
|
||||
| SlabNode['id']
|
||||
@@ -60,11 +64,18 @@ export const LevelNode = BaseNode.extend({
|
||||
children: z.array(LevelChildId).default([]),
|
||||
// Specific props
|
||||
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(
|
||||
dedent`
|
||||
Level node - used to represent a level in the building
|
||||
- children: array of architectural, equipment, and MEP distribution nodes
|
||||
- level: level number
|
||||
- height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({
|
||||
children: z.array(ItemNode.shape.id).default([]),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
supportSlabId: z.string().optional(),
|
||||
|
||||
// Dimensions (meters). Schema-level defaults intentionally reproduce
|
||||
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
|
||||
|
||||
@@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
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({
|
||||
id: objectId('slab'),
|
||||
type: nodeType('slab'),
|
||||
@@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).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),
|
||||
}).describe(
|
||||
dedent`
|
||||
@@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({
|
||||
- polygon: array of [x, z] points defining the slab boundary
|
||||
- holes: array of [x, z] polygons representing cutouts in the slab
|
||||
- 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
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({
|
||||
type: nodeType('spawn'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 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>
|
||||
|
||||
@@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
supportSlabId: z.string().optional(),
|
||||
stairType: StairType.default('straight'),
|
||||
fromLevelId: 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'),
|
||||
openingOffset: z.number().default(0),
|
||||
width: z.number().default(1.0),
|
||||
totalRise: z.number().default(2.5),
|
||||
totalRise: z.number().optional(),
|
||||
stepCount: z.number().default(10),
|
||||
thickness: z.number().default(0.25),
|
||||
fillToFloor: z.boolean().default(true),
|
||||
@@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({
|
||||
- rotation: rotation around Y axis
|
||||
- stairType: straight (segment-based), curved (arc-based), or spiral
|
||||
- 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
|
||||
- openingOffset: extra opening expansion applied after the cutout polygon is computed
|
||||
- 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>
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -13,7 +19,8 @@ import {
|
||||
WALL_SKIRTING_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WallFaceBandConfig,
|
||||
type WallNode,
|
||||
WallNode,
|
||||
type WallNode as WallNodeType,
|
||||
WallTrimConfig,
|
||||
} from './wall'
|
||||
|
||||
@@ -41,7 +48,8 @@ describe('wall face bands', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
getWallFaceBandConfig({
|
||||
getWallFaceBandConfig(
|
||||
{
|
||||
height: 2.5,
|
||||
faceBands: {
|
||||
enabled: true,
|
||||
@@ -50,7 +58,9 @@ describe('wall face bands', () => {
|
||||
middleHeight: 0.61,
|
||||
upperHeight: 0.61,
|
||||
},
|
||||
}),
|
||||
},
|
||||
2.5,
|
||||
),
|
||||
).toMatchObject({
|
||||
count: 3,
|
||||
lowerTop: 0.84,
|
||||
@@ -60,7 +70,8 @@ describe('wall face bands', () => {
|
||||
|
||||
test('four bands adds an upper split below the final top band', () => {
|
||||
expect(
|
||||
getWallFaceBandConfig({
|
||||
getWallFaceBandConfig(
|
||||
{
|
||||
height: 2.5,
|
||||
faceBands: {
|
||||
enabled: true,
|
||||
@@ -69,7 +80,9 @@ describe('wall face bands', () => {
|
||||
middleHeight: 0.6,
|
||||
upperHeight: 0.7,
|
||||
},
|
||||
}),
|
||||
},
|
||||
2.5,
|
||||
),
|
||||
).toMatchObject({
|
||||
count: 4,
|
||||
lowerTop: 0.5,
|
||||
@@ -93,7 +106,7 @@ describe('wall face bands', () => {
|
||||
lowerInterior: 'library:stale-lower',
|
||||
middleExterior: 'library:stale-middle',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>)
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
|
||||
|
||||
expect(patch.faceBands).toEqual({
|
||||
enabled: true,
|
||||
@@ -129,7 +142,7 @@ describe('wall face bands', () => {
|
||||
exterior: 'scene:exterior-finish',
|
||||
topInterior: 'library:stale-top',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
3,
|
||||
)
|
||||
|
||||
@@ -153,7 +166,7 @@ describe('wall face bands', () => {
|
||||
middleInterior: 'library:stale-middle',
|
||||
upperExterior: 'library:stale-upper',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>)
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
|
||||
|
||||
expect(patch.slots).toEqual({
|
||||
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
|
||||
@@ -181,7 +194,7 @@ describe('wall face bands', () => {
|
||||
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
|
||||
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
3,
|
||||
)
|
||||
|
||||
@@ -213,7 +226,7 @@ describe('wall face bands', () => {
|
||||
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
|
||||
upperExterior: 'library:painted-top-exterior',
|
||||
},
|
||||
} as Pick<WallNode, 'faceBands' | 'slots'>,
|
||||
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
|
||||
4,
|
||||
)
|
||||
|
||||
@@ -254,3 +267,206 @@ describe('wall trim profiles', () => {
|
||||
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wall assembly layers', () => {
|
||||
test('defaults to legacy thickness when no assembly layers are modeled', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(wall.assemblyLayers).toEqual([])
|
||||
expect(getWallAssemblyThickness(wall)).toBe(0.14)
|
||||
})
|
||||
|
||||
test('stores role, side, thickness, material reference, and datum eligibility', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face', 'finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
|
||||
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
|
||||
'interior-gwb',
|
||||
'brick-veneer',
|
||||
])
|
||||
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
|
||||
{ id: 'stud-core', role: 'structure', side: 'core' },
|
||||
])
|
||||
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
|
||||
interior: -0.061,
|
||||
exterior: 0.135,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves stable datum references for legacy single-thickness walls', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
|
||||
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
|
||||
{
|
||||
id: 'wall:structural-face:interior',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:structural-face:exterior',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:interior',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:exterior',
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-sheathing',
|
||||
role: 'exterior-sheathing',
|
||||
side: 'exterior',
|
||||
thickness: 0.012,
|
||||
materialRef: 'library:sheathing',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const references = resolveWallAssemblyDatumReferences(wall)
|
||||
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:centerline:center',
|
||||
datum: 'centerline',
|
||||
side: 'center',
|
||||
offset: 0,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:interior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
layerId: 'stud-core',
|
||||
offset: -0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:exterior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
layerId: 'stud-core',
|
||||
offset: 0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:finish-face:interior:interior-gwb',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
layerId: 'interior-gwb',
|
||||
offset: -0.061,
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
layerId: 'exterior-sheathing',
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
)?.offset,
|
||||
).toBeCloseTo(0.057)
|
||||
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
})
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
|
||||
?.offset,
|
||||
).toBeCloseTo(0.147)
|
||||
expect(
|
||||
resolveWallAssemblyDatumReference(
|
||||
wall,
|
||||
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
offset: 0.147,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
|
||||
|
||||
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
|
||||
|
||||
export const WallAssemblyLayerRole = z.enum([
|
||||
'structure',
|
||||
'interior-finish',
|
||||
'exterior-sheathing',
|
||||
'exterior-finish',
|
||||
'masonry-veneer',
|
||||
'air-space',
|
||||
'concrete-block',
|
||||
'structural-masonry',
|
||||
'solid-concrete',
|
||||
'furring',
|
||||
])
|
||||
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
|
||||
|
||||
export const WallDimensionDatum = z.enum([
|
||||
'centerline',
|
||||
'structural-face',
|
||||
'finish-face',
|
||||
'veneer-face',
|
||||
])
|
||||
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
|
||||
|
||||
export const WallAssemblyLayer = z.object({
|
||||
id: z.string().trim().min(1).max(80).default('structure'),
|
||||
role: WallAssemblyLayerRole.default('structure'),
|
||||
side: z.enum(['core', 'interior', 'exterior']).default('core'),
|
||||
thickness: z.number().finite().positive().default(0.1),
|
||||
materialRef: z.string().trim().max(120).default(''),
|
||||
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
|
||||
})
|
||||
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
|
||||
|
||||
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
|
||||
|
||||
export type WallAssemblyDatumReference = {
|
||||
id: string
|
||||
datum: WallDimensionDatum
|
||||
side: WallAssemblyDatumSide
|
||||
layerId?: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const WallNode = BaseNode.extend({
|
||||
id: objectId('wall'),
|
||||
type: nodeType('wall'),
|
||||
@@ -149,8 +191,11 @@ export const WallNode = BaseNode.extend({
|
||||
// in a follow-up once migrated scenes are the norm.
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
thickness: z.number().optional(),
|
||||
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
|
||||
height: z.number().optional(),
|
||||
curveOffset: z.number().optional(),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
supportSlabId: z.string().optional(),
|
||||
faceBands: WallFaceBandConfig.optional(),
|
||||
skirting: WallTrimConfig.optional(),
|
||||
crown: WallTrimConfig.optional(),
|
||||
@@ -165,6 +210,7 @@ export const WallNode = BaseNode.extend({
|
||||
dedent`
|
||||
Wall node - used to represent a wall in the building
|
||||
- thickness: thickness in meters
|
||||
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
|
||||
- height: height in meters
|
||||
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
|
||||
- start: start point of the wall in level coordinate system
|
||||
@@ -188,6 +234,222 @@ export type WallBandSurfaceSlotId =
|
||||
| 'upperExterior'
|
||||
| 'topExterior'
|
||||
|
||||
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
|
||||
return wall.assemblyLayers ?? []
|
||||
}
|
||||
|
||||
export function getWallAssemblyThickness(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): number {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return wall.thickness ?? 0.1
|
||||
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
}
|
||||
|
||||
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
|
||||
interior: number
|
||||
exterior: number
|
||||
} {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return { interior: -halfThickness, exterior: halfThickness }
|
||||
}
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const interiorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'interior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
const exteriorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'exterior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
|
||||
return {
|
||||
interior: -coreThickness / 2 - interiorFinishThickness,
|
||||
exterior: coreThickness / 2 + exteriorFinishThickness,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWallDatumEligibleLayers(
|
||||
wall: Pick<WallNode, 'assemblyLayers'>,
|
||||
datum: WallDimensionDatum,
|
||||
): WallAssemblyLayer[] {
|
||||
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
|
||||
}
|
||||
|
||||
export function getWallAssemblyDatumReferenceId(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
layerId?: string,
|
||||
): string {
|
||||
return ['wall', datum, side, layerId].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
type WallAssemblyLayerSpan = {
|
||||
layer: WallAssemblyLayer
|
||||
interiorOffset: number
|
||||
exteriorOffset: number
|
||||
}
|
||||
|
||||
function getWallAssemblyLayerSpans(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyLayerSpan[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const coreInteriorFace = -coreThickness / 2
|
||||
const coreExteriorFace = coreThickness / 2
|
||||
const spans: WallAssemblyLayerSpan[] = []
|
||||
|
||||
let coreOffset = coreInteriorFace
|
||||
for (const layer of coreLayers) {
|
||||
const interiorOffset = coreOffset
|
||||
const exteriorOffset = coreOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
||||
coreOffset = exteriorOffset
|
||||
}
|
||||
|
||||
let interiorOffset = coreInteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
||||
const exteriorOffset = interiorOffset
|
||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
||||
interiorOffset = nextInteriorOffset
|
||||
}
|
||||
|
||||
let exteriorOffset = coreExteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
||||
const interiorFaceOffset = exteriorOffset
|
||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
||||
exteriorOffset = nextExteriorOffset
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
function createWallAssemblyDatumReference(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
offset: number,
|
||||
layerId?: string,
|
||||
): WallAssemblyDatumReference {
|
||||
return {
|
||||
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
|
||||
datum,
|
||||
side,
|
||||
...(layerId ? { layerId } : {}),
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReferences(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyDatumReference[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
const references: WallAssemblyDatumReference[] = [
|
||||
createWallAssemblyDatumReference('centerline', 'center', 0),
|
||||
]
|
||||
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return [
|
||||
...references,
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
]
|
||||
}
|
||||
|
||||
const spans = getWallAssemblyLayerSpans(wall)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.layer.datumEligible.includes('structural-face')) {
|
||||
if (span.layer.side === 'core') {
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'interior',
|
||||
span.interiorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'exterior',
|
||||
span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const side = span.layer.side
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
side,
|
||||
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('finish-face')) {
|
||||
const side = span.layer.side === 'core' ? 'center' : span.layer.side
|
||||
const offset =
|
||||
span.layer.side === 'interior'
|
||||
? span.interiorOffset
|
||||
: span.layer.side === 'exterior'
|
||||
? span.exteriorOffset
|
||||
: (span.interiorOffset + span.exteriorOffset) / 2
|
||||
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('veneer-face')) {
|
||||
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
|
||||
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
|
||||
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'structural-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'finish-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReference(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
referenceId: string,
|
||||
): WallAssemblyDatumReference | null {
|
||||
return (
|
||||
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Declared default appearance for an unpainted wall face in colored mode —
|
||||
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
||||
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
||||
@@ -198,8 +460,11 @@ export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
|
||||
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
|
||||
}
|
||||
|
||||
export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands'>) {
|
||||
const wallHeight = wall.height ?? 2.5
|
||||
export function getWallFaceBandConfig(
|
||||
wall: Pick<WallNode, 'height' | 'faceBands'>,
|
||||
effectiveWallHeight: number,
|
||||
) {
|
||||
const wallHeight = Math.max(0, effectiveWallHeight)
|
||||
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 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(
|
||||
wall: Pick<WallNode, 'height' | 'faceBands'>,
|
||||
y: number,
|
||||
effectiveWallHeight: number,
|
||||
): WallFaceBand {
|
||||
const bands = getWallFaceBandConfig(wall)
|
||||
const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
|
||||
if (!bands.enabled) return 'upper'
|
||||
if (y < bands.lowerTop) return 'lower'
|
||||
if (y < bands.middleTop) return 'middle'
|
||||
|
||||
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
|
||||
])
|
||||
export type WindowType = z.infer<typeof WindowType>
|
||||
|
||||
export const WindowConstructionType = z.enum(['framed', 'masonry'])
|
||||
export const WindowDimensionReference = z.enum([
|
||||
'nominal',
|
||||
'rough-opening',
|
||||
'masonry-opening',
|
||||
'finish-opening',
|
||||
])
|
||||
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
|
||||
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
|
||||
|
||||
export const WindowNode = BaseNode.extend({
|
||||
id: objectId('window'),
|
||||
type: nodeType('window'),
|
||||
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
|
||||
width: z.number().default(1.5),
|
||||
height: z.number().default(1.5),
|
||||
|
||||
// Construction-document identity and optional manufacturer rough opening.
|
||||
// Legacy scenes omit these fields and continue to parse unchanged.
|
||||
mark: z.string().trim().max(16).optional(),
|
||||
constructionType: WindowConstructionType.default('framed'),
|
||||
dimensionReference: WindowDimensionReference.default('nominal'),
|
||||
roughOpeningWidth: z.number().positive().optional(),
|
||||
roughOpeningHeight: z.number().positive().optional(),
|
||||
masonryOpeningWidth: z.number().positive().optional(),
|
||||
masonryOpeningHeight: z.number().positive().optional(),
|
||||
finishOpeningWidth: z.number().positive().optional(),
|
||||
finishOpeningHeight: z.number().positive().optional(),
|
||||
|
||||
// Opening mode - when set to "opening", the window is only a shaped cutout
|
||||
openingKind: z.enum(['window', 'opening']).default('window'),
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { ZoneNode } from './zone'
|
||||
|
||||
describe('ZoneNode architectural room data', () => {
|
||||
test('keeps legacy zones generic while supplying room-safe defaults', () => {
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_legacy',
|
||||
name: 'Landscape area',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
],
|
||||
})
|
||||
|
||||
expect(zone).toMatchObject({
|
||||
spaceRole: 'generic',
|
||||
roomNumber: '',
|
||||
enclosureStatus: 'auto',
|
||||
floorFinish: '',
|
||||
wallFinish: '',
|
||||
ceilingFinish: '',
|
||||
ceilingHeight: 2.7,
|
||||
occupancy: '',
|
||||
clearDimensionPolicy: 'none',
|
||||
})
|
||||
})
|
||||
|
||||
test('persists a complete architectural room profile', () => {
|
||||
const room = ZoneNode.parse({
|
||||
id: 'zone_office',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
roomNumber: '101',
|
||||
enclosureStatus: 'enclosed',
|
||||
floorFinish: 'Timber',
|
||||
wallFinish: 'Paint',
|
||||
ceilingFinish: 'ACT',
|
||||
ceilingHeight: 3,
|
||||
occupancy: 'Business',
|
||||
clearDimensionPolicy: 'inside-faces',
|
||||
})
|
||||
|
||||
expect(room.spaceRole).toBe('room')
|
||||
expect(room.roomNumber).toBe('101')
|
||||
expect(room.clearDimensionPolicy).toBe('inside-faces')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
|
||||
// stored polygon remains a fallback for missing or temporarily open walls.
|
||||
autoFromWalls: z.boolean().default(false),
|
||||
boundaryWallIds: z.array(objectId('wall')).default([]),
|
||||
// Generic zones remain available for sites and analysis. Architectural
|
||||
// room documentation is opt-in so legacy zone behavior is unchanged.
|
||||
spaceRole: z.enum(['generic', 'room']).default('generic'),
|
||||
roomNumber: z.string().trim().max(32).default(''),
|
||||
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
|
||||
floorFinish: z.string().trim().max(120).default(''),
|
||||
wallFinish: z.string().trim().max(120).default(''),
|
||||
ceilingFinish: z.string().trim().max(120).default(''),
|
||||
ceilingHeight: z.number().min(0.1).default(2.7),
|
||||
occupancy: z.string().trim().max(80).default(''),
|
||||
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
|
||||
// Visual styling
|
||||
color: z.string().default('#3b82f6'), // Default blue
|
||||
metadata: z.json().optional().default({}),
|
||||
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
|
||||
- polygon: array of [x, z] points defining the zone boundary
|
||||
- autoFromWalls: whether the boundary follows an enclosed wall loop
|
||||
- boundaryWallIds: wall ids that prove the procedural enclosure
|
||||
- spaceRole: generic site/analysis zone or architectural room
|
||||
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
|
||||
- enclosureStatus: auto-detected, explicitly enclosed, or open
|
||||
- clearDimensionPolicy: optional room clear-dimension datum preference
|
||||
- color: hex color for visual styling
|
||||
- metadata: zone metadata (optional)
|
||||
`,
|
||||
|
||||
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { ChimneyNode } from './nodes/chimney'
|
||||
import { ColumnNode } from './nodes/column'
|
||||
import { ConstructionDimensionNode } from './nodes/construction-dimension'
|
||||
import { CupolaNode } from './nodes/cupola'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { DownspoutNode } from './nodes/downspout'
|
||||
import { DrawingSheetNode } from './nodes/drawing-sheet'
|
||||
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
|
||||
import { SpawnNode } from './nodes/spawn'
|
||||
import { StairNode } from './nodes/stair'
|
||||
import { StairSegmentNode } from './nodes/stair-segment'
|
||||
import { StructuralGridNode } from './nodes/structural-grid'
|
||||
import { TurbineVentNode } from './nodes/turbine-vent'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
ElevatorNode,
|
||||
LevelNode,
|
||||
ColumnNode,
|
||||
ConstructionDimensionNode,
|
||||
StructuralGridNode,
|
||||
WallNode,
|
||||
FenceNode,
|
||||
CabinetNode,
|
||||
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctSegmentNode,
|
||||
DuctFittingNode,
|
||||
DuctTerminalNode,
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export {
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
getCeilingAt,
|
||||
getCeilingHeightAt,
|
||||
getLevelHeight,
|
||||
resolveCeilingHeight,
|
||||
} from './level-height'
|
||||
export {
|
||||
type AxisLock,
|
||||
@@ -102,6 +102,19 @@ export {
|
||||
snapVec3ToGrid,
|
||||
snapWorldXZToBuildingLocal,
|
||||
} from './snap'
|
||||
export {
|
||||
CEILING_CLAMP_MARGIN,
|
||||
findLevelAboveId,
|
||||
findLevelBelowId,
|
||||
getCeilingClampBound,
|
||||
getCoveringSlabUndersideAt,
|
||||
getLevelAbove,
|
||||
getLevelBelow,
|
||||
getLevelElevations,
|
||||
getStoredLevelHeight,
|
||||
getWallPlaneTop,
|
||||
type LevelElevation,
|
||||
} from './storey'
|
||||
export {
|
||||
buildPortComponents,
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +1,68 @@
|
||||
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
|
||||
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
|
||||
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
|
||||
|
||||
/**
|
||||
* Optional resolver for a wall's rendered base Y (mesh elevation).
|
||||
*
|
||||
* `packages/core` is pure domain logic and must not read viewer/Three.js
|
||||
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
|
||||
* registry access (viewer systems, node tools) may pass a resolver so the
|
||||
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
|
||||
* omit it and get a deterministic result from serialized node data alone.
|
||||
* Effective ceiling height in level-local meters. An explicit stored
|
||||
* `height` wins; absent height means the ceiling follows the level top —
|
||||
* the same bound its write-clamp uses: min(storey plane, lowest
|
||||
* covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see
|
||||
* {@link getCeilingClampBound}). Falls back to the default plane minus
|
||||
* the same margin when the owning level is unresolvable.
|
||||
*/
|
||||
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,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
resolveWallBaseY?: WallBaseYResolver,
|
||||
): number {
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
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
|
||||
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as keyof typeof nodes]
|
||||
if (!child) continue
|
||||
for (const child of levelChildren) {
|
||||
if (child.type === 'ceiling') {
|
||||
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (ch > maxTop) maxTop = ch
|
||||
// Absence here is the PRE-migration legacy schema default (2.5), not
|
||||
// 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') {
|
||||
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
|
||||
if (baseY < 0) baseY = 0
|
||||
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||
const wall = child as WallNode
|
||||
const electedElevation = computeWallSlabSupport(
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -58,14 +86,18 @@ export function getCeilingAt(
|
||||
if (!level) return null
|
||||
|
||||
let best: CeilingNode | null = null
|
||||
let bestHeight = Number.POSITIVE_INFINITY
|
||||
for (const childId of level.children) {
|
||||
const child = nodes[childId as keyof typeof nodes]
|
||||
if (child?.type !== 'ceiling') continue
|
||||
const ceiling = child as CeilingNode
|
||||
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
|
||||
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
|
||||
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling
|
||||
const h = resolveCeilingHeight(ceiling, nodes)
|
||||
if (best === null || h < bestHeight) {
|
||||
best = ceiling
|
||||
bestHeight = h
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -82,5 +114,5 @@ export function getCeilingHeightAt(
|
||||
z: number,
|
||||
): number | null {
|
||||
const ceiling = getCeilingAt(levelId, nodes, x, z)
|
||||
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
|
||||
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const candidate = { ...currentNode, ...data }
|
||||
const candidate = mergeNodeUpdate(currentNode, data)
|
||||
const parsed = AnyNodeSchema.safeParse(candidate)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
@@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
|
||||
const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
|
||||
|
||||
if (sanitized.issues.length === 0) {
|
||||
return candidate as AnyNode
|
||||
return candidate
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) {
|
||||
(a.parentId ?? null) === (b.parentId ?? null) &&
|
||||
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.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 &&
|
||||
aExterior === bExterior &&
|
||||
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) {
|
||||
const node = nextNodes[id]
|
||||
if (!node) continue
|
||||
|
||||
@@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number
|
||||
|
||||
const SHELF_ID = 'shelf_sanitize' 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 {
|
||||
return {
|
||||
@@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => {
|
||||
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 = {
|
||||
position: [number, number, number]
|
||||
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 = {
|
||||
|
||||
@@ -666,7 +666,9 @@ describe('scene commit boundary', () => {
|
||||
const snapshot = currentSnapshot()
|
||||
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']
|
||||
const commits: SceneCommit[] = []
|
||||
@@ -674,7 +676,7 @@ describe('scene commit boundary', () => {
|
||||
useScene.getState().dirtyNodes.clear()
|
||||
|
||||
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(commits.map((commit) => commit.origin)).toEqual(['host'])
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
type SceneMaterialId,
|
||||
} from '../schema/scene-material'
|
||||
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 * as nodeActions from './actions/node-actions'
|
||||
import {
|
||||
@@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number
|
||||
}
|
||||
|
||||
function normalizeStairNode(node: Record<string, unknown>) {
|
||||
const hasTotalRise = 'totalRise' in node
|
||||
const sanitized = {
|
||||
...node,
|
||||
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'),
|
||||
openingOffset: getFiniteNumber(node.openingOffset, 0),
|
||||
width: getFiniteNumber(node.width, 1),
|
||||
totalRise: getFiniteNumber(node.totalRise, 2.5),
|
||||
totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined,
|
||||
stepCount: getFiniteNumber(node.stepCount, 10),
|
||||
thickness: getFiniteNumber(node.thickness, 0.25),
|
||||
fillToFloor: getBoolean(node.fillToFloor, true),
|
||||
@@ -117,7 +122,13 @@ function normalizeStairNode(node: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
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>) {
|
||||
@@ -559,6 +570,36 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||
return next
|
||||
}
|
||||
|
||||
function migrateConstructionDimension(node: Record<string, any>) {
|
||||
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
|
||||
const hasLegacyDrawingOverride = drawingOverrides.some(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
!Array.isArray(entry) &&
|
||||
entry.presentation === 'reference',
|
||||
)
|
||||
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
|
||||
|
||||
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
|
||||
return {
|
||||
...dimension,
|
||||
drawingOverrides: drawingOverrides.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
|
||||
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Walls whose top lands within this of the storey plane become plane-bound;
|
||||
// ceilings whose stored height lands within this of their clamp bound become
|
||||
// follows-mode (step 3f) — same census-backed threshold for both.
|
||||
// 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>): {
|
||||
nodes: Record<string, AnyNode>
|
||||
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') {
|
||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -1163,6 +1371,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
const level0 = LevelNode.parse({
|
||||
level: 0,
|
||||
children: [],
|
||||
height: 2.5,
|
||||
})
|
||||
|
||||
const building = BuildingNode.parse({
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
CeilingNode,
|
||||
ElevatorNode,
|
||||
LevelNode,
|
||||
WallNode,
|
||||
} from '../../schema'
|
||||
|
||||
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
|
||||
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
|
||||
import { getStoredLevelHeight } from '../../services/storey'
|
||||
|
||||
export type ElevatorLevelEntry = {
|
||||
id: LevelNode['id']
|
||||
@@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels(
|
||||
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(
|
||||
elevator: ElevatorNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
@@ -119,7 +89,7 @@ export function resolveElevatorLevels(
|
||||
let cumulativeY = 0
|
||||
for (const level of allLevels) {
|
||||
baseYByLevelId.set(level.id, cumulativeY)
|
||||
cumulativeY += getElevatorLevelHeight(level.id, nodes)
|
||||
cumulativeY += getStoredLevelHeight(level)
|
||||
}
|
||||
|
||||
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,
|
||||
SurfaceHoleMetadata,
|
||||
} 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 { resolveStairTotalRise } from './stair-rise'
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
@@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface(
|
||||
const layouts = getStraightStairLayouts(stair, nodes)
|
||||
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 openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
|
||||
const openingRects: AxisAlignedRect[] = []
|
||||
@@ -605,17 +607,16 @@ function getTargetSlabElevationForStair(
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const slabLevel = getLevelNumber(slabLevelId, nodes)
|
||||
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
||||
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 (
|
||||
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||
(slab.elevation ?? 0.05) -
|
||||
(stair.position[1] ?? 0)
|
||||
slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair(
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
|
||||
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
||||
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
|
||||
const ceilingElevation = elevations.get(ceilingLevelId)
|
||||
|
||||
if (fromLevel === undefined || ceilingLevel === undefined) {
|
||||
return ceiling.height ?? DEFAULT_WALL_HEIGHT
|
||||
const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record<AnyNodeId, AnyNode>)
|
||||
|
||||
if (
|
||||
!fromElevation ||
|
||||
!ceilingElevation ||
|
||||
fromElevation.buildingId !== ceilingElevation.buildingId
|
||||
) {
|
||||
return ceilingHeight
|
||||
}
|
||||
|
||||
return (
|
||||
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
|
||||
(stair.position[1] ?? 0)
|
||||
)
|
||||
return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0)
|
||||
}
|
||||
|
||||
function shouldApplyStairToSlab(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { AnyNode } from '../../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
|
||||
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
|
||||
import useLiveTransforms from '../../store/use-live-transforms'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
hasLiveStairOpeningInputs,
|
||||
} from './stair-opening-preview'
|
||||
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
import { syncStairRises } from './stair-rise'
|
||||
|
||||
function isOpeningRelevantNode(node: AnyNode | undefined) {
|
||||
return (
|
||||
@@ -47,7 +48,7 @@ export const StairOpeningSystem = () => {
|
||||
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
|
||||
const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
scheduleAutoSync()
|
||||
|
||||
const unsubscribeScene = useScene.subscribe((state, prevState) => {
|
||||
if (syncingAutoOpeningsRef.current) return
|
||||
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
|
||||
applyUpdates(syncAutoStairOpenings(state.nodes))
|
||||
refreshLivePreview()
|
||||
scheduleAutoSync()
|
||||
})
|
||||
|
||||
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
|
||||
@@ -122,6 +149,7 @@ export const StairOpeningSystem = () => {
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribeScene()
|
||||
unsubscribeLiveTransforms()
|
||||
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
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
|
||||
}
|
||||
}
|
||||
|
||||
function getWallArcData(wall: WallCurveLike) {
|
||||
export function getWallArcData(wall: WallCurveLike) {
|
||||
const chord = getWallChordFrame(wall)
|
||||
const sagitta = getClampedWallCurveOffset(wall)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
|
||||
visible: true,
|
||||
parentId: 'level_test',
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
thickness: 0.1,
|
||||
|
||||
@@ -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 type { CollectionId } from '../schema/collections'
|
||||
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 {
|
||||
return {
|
||||
@@ -71,3 +76,163 @@ describe('forkSceneGraph', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { generateId } from '../schema/base'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
@@ -44,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
|
||||
for (const [oldId, node] of Object.entries(nodes)) {
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
|
||||
// Remap parentId
|
||||
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
|
||||
@@ -85,9 +90,33 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
) 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') {
|
||||
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
|
||||
}
|
||||
@@ -202,7 +231,7 @@ export function cloneLevelSubtree(
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
|
||||
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
|
||||
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
|
||||
let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
|
||||
;(cloned as Record<string, unknown>).id = newId
|
||||
|
||||
// Remap parentId — but only for descendants, not the level node itself
|
||||
@@ -240,9 +269,27 @@ export function cloneLevelSubtree(
|
||||
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') {
|
||||
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
|
||||
}
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
clonedNodes.push(cloned)
|
||||
}
|
||||
|
||||
@@ -40,16 +40,16 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@visual-json/react": "^0.4.0",
|
||||
"blob-stream": "^0.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"svg2pdf.js": "^2.7.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "~0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
@@ -59,8 +59,10 @@
|
||||
"@pascal-app/core": "^0.9.2",
|
||||
"@pascal-app/viewer": "^0.9.2",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
|
||||
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
|
||||
const guides = useAlignmentGuides((s) => s.guides)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const metricNotation = useViewer((s) => s.metricNotation)
|
||||
const ctx = useFloorplanRender()
|
||||
|
||||
if (guides.length === 0) return null
|
||||
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
|
||||
// offset along X.
|
||||
const pillX = axis === 'x' ? midX + pillOffset : midX
|
||||
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
|
||||
const distLabel = formatMeasurement(distMeters, unit)
|
||||
const distLabel = formatMeasurement(distMeters, unit, metricNotation)
|
||||
const charWidth = pillFontSize * 0.55
|
||||
const pillWidth = distLabel.length * charWidth + pillPadX * 2
|
||||
const pillHeight = pillFontSize + pillPadY * 2
|
||||
|
||||
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
const draftLevelId = useMeasurementDraft((state) => state.levelId)
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
|
||||
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
|
||||
screenUpright: false,
|
||||
text: formatLinearMeasurement(measurementDistance(start, end), unit),
|
||||
text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
|
||||
}
|
||||
} else if (kind === 'angle' && livePoints.length >= 3) {
|
||||
const anglePoints = livePoints.slice(0, 3) as [
|
||||
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
text:
|
||||
kind === 'area'
|
||||
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
|
||||
}
|
||||
}
|
||||
} else if (kind === 'volume' && center && baseNormal) {
|
||||
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
|
||||
(segmentStart[1] + segmentEnd[1]) / 2,
|
||||
(segmentStart[2] + segmentEnd[2]) / 2,
|
||||
],
|
||||
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
|
||||
text: formatLinearMeasurement(
|
||||
measurementDistance(segmentStart, segmentEnd),
|
||||
unit,
|
||||
metricNotation,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
|
||||
polygonPoints,
|
||||
segmentLabel,
|
||||
}
|
||||
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
|
||||
}, [
|
||||
axisGuide,
|
||||
baseNormal,
|
||||
extrusionHeight,
|
||||
hover,
|
||||
kind,
|
||||
metricNotation,
|
||||
points,
|
||||
stage,
|
||||
unit,
|
||||
vertexDrag,
|
||||
])
|
||||
|
||||
if (smartActive) return <FloorplanQuickMeasureLayer />
|
||||
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
|
||||
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
|
||||
text={`${hover.semantic.label}${
|
||||
hover.semantic.length === null
|
||||
? ''
|
||||
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
|
||||
: ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
|
||||
}`}
|
||||
textColor={labelText}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
type FloorplanToolContext,
|
||||
getFloorplanNodeExtension,
|
||||
} from '../../lib/floorplan/floorplan-extension'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
|
||||
|
||||
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
|
||||
if (!tool) return null
|
||||
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
|
||||
if (!loader) return null
|
||||
const cached = lazyToolCache.get(loader)
|
||||
if (cached) return cached
|
||||
const component = lazy(loader)
|
||||
lazyToolCache.set(loader, component)
|
||||
return component
|
||||
}
|
||||
|
||||
export function FloorplanRegisteredToolLayer() {
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||
const toolDefaults = useEditor((state) =>
|
||||
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
|
||||
)
|
||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const metricNotation = useViewer((state) => state.metricNotation)
|
||||
const sceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const selectNode = useCallback(
|
||||
(id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
|
||||
useViewer.getState().setSelection({ selectedIds: [id] }),
|
||||
[],
|
||||
)
|
||||
const finishTool = useCallback(() => {
|
||||
useEditor.getState().setTool(null)
|
||||
useEditor.getState().setMode('select')
|
||||
}, [])
|
||||
if (mode !== 'build') return null
|
||||
const Tool = registeredFloorplanTool(tool)
|
||||
return Tool ? (
|
||||
<Suspense fallback={null}>
|
||||
<Tool
|
||||
activeLevelId={activeLevelId}
|
||||
finishTool={finishTool}
|
||||
gridSnapStep={gridSnapStep}
|
||||
metricNotation={metricNotation}
|
||||
sceneApi={sceneApi}
|
||||
selectNode={selectNode}
|
||||
toolDefaults={toolDefaults}
|
||||
unit={unit}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useReducedMotion } from '../../hooks/use-reduced-motion'
|
||||
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
||||
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
createFreshPlacementSubtree,
|
||||
duplicatesAsFreshSubtree,
|
||||
} from '../../lib/fresh-planar-placement'
|
||||
import { curveReshapeScope } from '../../lib/interaction/scope'
|
||||
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { cn } from '../../lib/utils'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { useMovingNode } from '../../store/use-interaction-scope'
|
||||
import useInteractionScope, {
|
||||
useIsCurveReshape,
|
||||
useMovingNode,
|
||||
} from '../../store/use-interaction-scope'
|
||||
import { NodeActionMenu } from '../editor/node-action-menu'
|
||||
import { IconRefGlyph } from '../ui/icon-ref'
|
||||
|
||||
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
|
||||
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
|
||||
* Walls are excluded — their move is reached via the side-arrow
|
||||
* handles emitted from `def.floorplan`, not via a menu button.
|
||||
* - Curve (wall only): enters curve reshape mode. The selected wall's
|
||||
* midpoint curve handle remains visible so it can be dragged in plan.
|
||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
|
||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||
* `relations.cascadeDelete` if declared on the def.
|
||||
*
|
||||
* Hidden while in a move state (so we don't show buttons over a ghost).
|
||||
* Hidden while moving or curving so the menu does not compete with the active affordance.
|
||||
*/
|
||||
export function FloorplanRegistryActionMenu() {
|
||||
const reducedMotion = useReducedMotion()
|
||||
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
||||
) as AnyNodeId | undefined
|
||||
const movingNode = useMovingNode()
|
||||
const isCurveReshape = useIsCurveReshape()
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
|
||||
// Only show for registered kinds (skip legacy kinds — they have their
|
||||
// own FloorplanActionMenuLayer entries).
|
||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||
const canCurve = useScene((s) => {
|
||||
if (!selectedId) return false
|
||||
const selectedNode = s.nodes[selectedId]
|
||||
if (!selectedNode) return false
|
||||
const definition = nodeRegistry.get(selectedNode.type)
|
||||
const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
|
||||
return (
|
||||
!!definition?.floorplanAffordances?.curve &&
|
||||
!!canCurveNode?.({
|
||||
node: selectedNode as never,
|
||||
nodes: s.nodes,
|
||||
})
|
||||
)
|
||||
})
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible =
|
||||
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
|
||||
isRegistryKind &&
|
||||
def?.presentation?.actionMenu !== false &&
|
||||
!movingNode &&
|
||||
!isCurveReshape &&
|
||||
isFloorplanHovered
|
||||
const isWall = selectedKind === 'wall'
|
||||
const quickActionNodes = useScene(
|
||||
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
|
||||
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleCurve = () => {
|
||||
if (!canCurve) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
||||
}
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (!node.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={canAddHole ? handleAddHole : undefined}
|
||||
onCurve={canCurve ? handleCurve : undefined}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
onDuplicate={canDuplicate ? handleDuplicate : undefined}
|
||||
onMove={canMove ? handleMove : undefined}
|
||||
|
||||
+467
@@ -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,
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
computeArchitecturalDimensionLayout,
|
||||
FloorplanDimensionRenderer,
|
||||
FloorplanDimensionStringRenderer,
|
||||
floorplanDimensionAnnotationPriority,
|
||||
} from './floorplan-dimension-renderer'
|
||||
|
||||
const dimension = {
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0.45,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `13'-1 1/2"`,
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
|
||||
describe('architectural floor-plan dimensions', () => {
|
||||
test('leaves a paper-space gap before solid extension lines', () => {
|
||||
const layout = computeArchitecturalDimensionLayout(dimension, 0)
|
||||
|
||||
expect(layout).not.toBeNull()
|
||||
expect(layout?.extensionStart).toEqual([0, 0.075])
|
||||
expect(layout?.extensionEnd).toEqual([4, 0.075])
|
||||
expect(layout?.extensionStartTip[0]).toBe(0)
|
||||
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57)
|
||||
expect(layout?.extensionEndTip[0]).toBe(4)
|
||||
expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57)
|
||||
expect(layout?.dimensionStart).toEqual([0, 0.45])
|
||||
expect(layout?.dimensionEnd).toEqual([4, 0.45])
|
||||
expect(layout?.dimensionLineEnd).toEqual([4, 0.45])
|
||||
expect(layout?.labelPlacement).toBe('inside')
|
||||
})
|
||||
|
||||
test('builds consistent 45-degree architectural slash terminators', () => {
|
||||
const layout = computeArchitecturalDimensionLayout(dimension, 0)
|
||||
|
||||
expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5)
|
||||
expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5)
|
||||
})
|
||||
|
||||
test('honors dimension standard overrides for gaps, terminators, and text placement', () => {
|
||||
const customDimension = {
|
||||
...dimension,
|
||||
extensionStartGap: 0.2,
|
||||
terminator: 'dot',
|
||||
textPosition: 'centered',
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
const layout = computeArchitecturalDimensionLayout(customDimension, 0)
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer geometry={customDimension} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(layout?.extensionStart).toEqual([0, 0.2])
|
||||
expect(markup).toContain('<circle')
|
||||
expect(markup).toContain('y="0.0525"')
|
||||
})
|
||||
|
||||
test('moves a short value beyond its end tick and extends the dimension line', () => {
|
||||
const shortDimension = {
|
||||
...dimension,
|
||||
end: [0.23, 0] as [number, number],
|
||||
text: '0.23m',
|
||||
}
|
||||
const layout = computeArchitecturalDimensionLayout(shortDimension, 0)
|
||||
|
||||
expect(layout?.labelPlacement).toBe('outside-end')
|
||||
expect(layout?.labelPoint[0]).toBeGreaterThan(0.23)
|
||||
expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0)
|
||||
expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan(
|
||||
layout?.outsideStartLabelPoint?.[0] ?? 0,
|
||||
)
|
||||
expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0)
|
||||
expect(layout?.dimensionEnd).toEqual([0.23, 0.45])
|
||||
|
||||
const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01)
|
||||
expect(documentLayout?.labelPlacement).toBe('outside-end')
|
||||
expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0)
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer geometry={shortDimension} sceneRotationDeg={37} />
|
||||
</svg>,
|
||||
)
|
||||
expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
|
||||
expect(markup).not.toContain('data-floorplan-dimension-leader=""')
|
||||
|
||||
const documentMarkup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer
|
||||
annotationUnitsPerPoint={0.01}
|
||||
geometry={shortDimension}
|
||||
sceneRotationDeg={37}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
expect(documentMarkup).toContain('data-floorplan-dimension-leader=""')
|
||||
expect(documentMarkup).toContain('visibility="hidden"')
|
||||
})
|
||||
|
||||
test('aligns stepped feature origins to an explicit exterior baseline', () => {
|
||||
const layout = computeArchitecturalDimensionLayout(
|
||||
{
|
||||
...dimension,
|
||||
start: [0, 0],
|
||||
end: [4, 1],
|
||||
dimensionStart: [0, 2],
|
||||
dimensionEnd: [4, 2],
|
||||
offsetDistance: 2,
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
expect(layout?.dimensionStart).toEqual([0, 2])
|
||||
expect(layout?.dimensionEnd).toEqual([4, 2])
|
||||
expect(layout?.extensionStart).toEqual([0, 0.075])
|
||||
expect(layout?.extensionEnd).toEqual([4, 1.075])
|
||||
expect(layout?.extensionStartTip).toEqual([0, 2.12])
|
||||
expect(layout?.extensionEndTip).toEqual([4, 2.12])
|
||||
})
|
||||
|
||||
test('renders one uninterrupted line with the label above it', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer geometry={dimension} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup.match(/<line/g)).toHaveLength(5)
|
||||
expect(markup).not.toContain('stroke-dasharray')
|
||||
expect(markup).toContain('y="-0.12"')
|
||||
expect(markup).toContain('13'-1 1/2"')
|
||||
expect(markup).toContain('paint-order="stroke"')
|
||||
expect(markup).toContain('stroke="#ffffff"')
|
||||
expect(markup).toContain('data-floorplan-annotation-priority="145"')
|
||||
})
|
||||
|
||||
test('keeps farther-out architectural strings fixed before inner strings', () => {
|
||||
expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan(
|
||||
floorplanDimensionAnnotationPriority(0.55),
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps labels readable after the scene rotates', () => {
|
||||
expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180)
|
||||
})
|
||||
|
||||
test('resolves document annotation sizes from paper points', () => {
|
||||
const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01)
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer annotationUnitsPerPoint={0.01} geometry={dimension} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(layout?.extensionStart[1]).toBeCloseTo(0.03)
|
||||
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49)
|
||||
expect(markup).toContain('font-size="0.08"')
|
||||
expect(markup).toContain('y="-0.05"')
|
||||
})
|
||||
|
||||
test('uses PDF-safe label plates and hairline measurement strokes for export', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionRenderer geometry={dimension} renderMode="pdf" />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
|
||||
expect(markup).toContain('stroke-width="0.5"')
|
||||
expect(markup).not.toContain('paint-order="stroke"')
|
||||
expect(markup).not.toContain('stroke="#ffffff"')
|
||||
})
|
||||
|
||||
test('renders a dimension string with shared witness extension lines and ticks', () => {
|
||||
const stringGeometry = {
|
||||
kind: 'dimension-string',
|
||||
segments: [
|
||||
{
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
dimensionStart: [0, 1],
|
||||
dimensionEnd: [2, 1],
|
||||
text: '2m',
|
||||
},
|
||||
{
|
||||
start: [2, 0],
|
||||
end: [5, 0],
|
||||
dimensionStart: [2, 1],
|
||||
dimensionEnd: [5, 1],
|
||||
text: '3m',
|
||||
},
|
||||
],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 1,
|
||||
extensionOvershoot: 0.12,
|
||||
textPosition: 'above',
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionStringRenderer geometry={stringGeometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-dimension-string=""')
|
||||
expect(markup.match(/<line/g)).toHaveLength(8)
|
||||
expect(markup).toContain('2m')
|
||||
expect(markup).toContain('3m')
|
||||
})
|
||||
|
||||
test('offsets automatic dimension-string lines when no explicit baseline is supplied', () => {
|
||||
const automaticString = {
|
||||
kind: 'dimension-string',
|
||||
segments: [{ start: [0, 0], end: [2, 0], text: '2m' }],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0.55,
|
||||
extensionOvershoot: 0.12,
|
||||
textPosition: 'above',
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanDimensionStringRenderer geometry={automaticString} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
|
||||
expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,608 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
const EXTENSION_START_GAP = 0.075
|
||||
const TICK_HALF_LENGTH = 0.09
|
||||
const LABEL_FONT_SIZE = 0.15
|
||||
const LABEL_BASELINE_OFFSET = 0.12
|
||||
const LABEL_CHARACTER_WIDTH_RATIO = 0.62
|
||||
const LABEL_END_GAP = 0.075
|
||||
const DOCUMENT_EXTENSION_START_GAP_PT = 3
|
||||
const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
|
||||
const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
|
||||
const DOCUMENT_LABEL_FONT_SIZE_PT = 8
|
||||
const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
|
||||
const DOCUMENT_LABEL_END_GAP_PT = 3
|
||||
const LINE_STROKE_WIDTH_PX = 0.9
|
||||
const TICK_STROKE_WIDTH_PX = 1.35
|
||||
const PDF_LINE_STROKE_WIDTH_PT = 0.5
|
||||
const PDF_TICK_STROKE_WIDTH_PT = 0.75
|
||||
const SQRT_ONE_HALF = Math.SQRT1_2
|
||||
|
||||
type FloorplanDimensionRenderMode = 'screen' | 'pdf'
|
||||
|
||||
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
|
||||
type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
|
||||
|
||||
export type ArchitecturalDimensionLayout = {
|
||||
dimensionStart: FloorplanPoint
|
||||
dimensionEnd: FloorplanPoint
|
||||
dimensionLineStart: FloorplanPoint
|
||||
dimensionLineEnd: FloorplanPoint
|
||||
extensionStart: FloorplanPoint
|
||||
extensionEnd: FloorplanPoint
|
||||
extensionStartTip: FloorplanPoint
|
||||
extensionEndTip: FloorplanPoint
|
||||
tickHalfVector: FloorplanPoint
|
||||
labelPoint: FloorplanPoint
|
||||
labelAngleDeg: number
|
||||
labelPlacement: 'inside' | 'outside-end'
|
||||
outsideStartLabelPoint?: FloorplanPoint
|
||||
outsideStartDimensionLineStart?: FloorplanPoint
|
||||
}
|
||||
|
||||
export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
|
||||
return 100 + Math.round(Math.abs(offsetDistance) * 100)
|
||||
}
|
||||
|
||||
export function computeArchitecturalDimensionLayout(
|
||||
geometry: DimensionGeometry,
|
||||
sceneRotationDeg: number,
|
||||
annotationUnitsPerPoint?: number,
|
||||
): ArchitecturalDimensionLayout | null {
|
||||
const extensionStartGap = annotationUnitsPerPoint
|
||||
? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
|
||||
: (geometry.extensionStartGap ?? EXTENSION_START_GAP)
|
||||
const extensionOvershoot = annotationUnitsPerPoint
|
||||
? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
|
||||
: geometry.extensionOvershoot
|
||||
const tickHalfLength = annotationUnitsPerPoint
|
||||
? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
|
||||
: TICK_HALF_LENGTH
|
||||
const labelFontSize = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
|
||||
: LABEL_FONT_SIZE
|
||||
const labelEndGap = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
|
||||
: LABEL_END_GAP
|
||||
const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
|
||||
const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
|
||||
const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
|
||||
geometry.start[0] + offsetX,
|
||||
geometry.start[1] + offsetY,
|
||||
]
|
||||
const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
|
||||
geometry.end[0] + offsetX,
|
||||
geometry.end[1] + offsetY,
|
||||
]
|
||||
const dx = dimensionEnd[0] - dimensionStart[0]
|
||||
const dy = dimensionEnd[1] - dimensionStart[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length < 1e-6) return null
|
||||
|
||||
const dirX = dx / length
|
||||
const dirY = dy / length
|
||||
const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
|
||||
const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
|
||||
const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
|
||||
const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
|
||||
|
||||
// A 45-degree architectural slash. Every terminator within one string uses
|
||||
// this same vector instead of rotating independently around its endpoint.
|
||||
const tickHalfVector: FloorplanPoint = [
|
||||
(dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
|
||||
(dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
|
||||
]
|
||||
const labelWidth = Math.max(
|
||||
labelFontSize,
|
||||
geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
|
||||
)
|
||||
const labelPlacement =
|
||||
length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
|
||||
const direction: FloorplanPoint = [dirX, dirY]
|
||||
const labelPoint =
|
||||
labelPlacement === 'inside'
|
||||
? ([
|
||||
(dimensionStart[0] + dimensionEnd[0]) / 2,
|
||||
(dimensionStart[1] + dimensionEnd[1]) / 2,
|
||||
] as FloorplanPoint)
|
||||
: addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
|
||||
const dimensionLineEnd =
|
||||
labelPlacement === 'inside'
|
||||
? dimensionEnd
|
||||
: addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
|
||||
const outsideStartLabelPoint =
|
||||
labelPlacement === 'outside-end'
|
||||
? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
|
||||
: undefined
|
||||
const outsideStartDimensionLineStart =
|
||||
labelPlacement === 'outside-end'
|
||||
? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
dimensionStart,
|
||||
dimensionEnd,
|
||||
dimensionLineStart: dimensionStart,
|
||||
dimensionLineEnd,
|
||||
extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
|
||||
extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
|
||||
extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
|
||||
extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
|
||||
tickHalfVector,
|
||||
labelPoint,
|
||||
labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
|
||||
labelPlacement,
|
||||
outsideStartLabelPoint,
|
||||
outsideStartDimensionLineStart,
|
||||
}
|
||||
}
|
||||
|
||||
function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
|
||||
return [left[0] - right[0], left[1] - right[1]]
|
||||
}
|
||||
|
||||
function dot(left: FloorplanPoint, right: FloorplanPoint): number {
|
||||
return left[0] * right[0] + left[1] * right[1]
|
||||
}
|
||||
|
||||
function addScaled(
|
||||
point: FloorplanPoint,
|
||||
direction: FloorplanPoint,
|
||||
distance: number,
|
||||
): FloorplanPoint {
|
||||
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
|
||||
}
|
||||
|
||||
export function FloorplanDimensionRenderer({
|
||||
geometry,
|
||||
sceneRotationDeg = 0,
|
||||
stroke = geometry.stroke ?? '#334155',
|
||||
annotationUnitsPerPoint,
|
||||
renderMode = 'screen',
|
||||
}: {
|
||||
geometry: DimensionGeometry
|
||||
sceneRotationDeg?: number
|
||||
stroke?: string
|
||||
annotationUnitsPerPoint?: number
|
||||
renderMode?: FloorplanDimensionRenderMode
|
||||
}): React.ReactElement | null {
|
||||
const layout = computeArchitecturalDimensionLayout(
|
||||
geometry,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
)
|
||||
if (!layout) return null
|
||||
|
||||
const labelFontSize = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
|
||||
: LABEL_FONT_SIZE
|
||||
const labelBaselineOffset = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
|
||||
: LABEL_BASELINE_OFFSET
|
||||
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
|
||||
|
||||
const lineProps = {
|
||||
stroke,
|
||||
strokeLinecap: 'butt' as const,
|
||||
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
|
||||
vectorEffect: 'non-scaling-stroke' as const,
|
||||
}
|
||||
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
|
||||
const terminator = geometry.terminator ?? 'architectural-tick'
|
||||
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
|
||||
const outsideStartLocalShift = layout.outsideStartLabelPoint
|
||||
? rotateVector(
|
||||
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
|
||||
(-layout.labelAngleDeg * Math.PI) / 180,
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<g data-floorplan-dimension="" pointerEvents="none">
|
||||
<line
|
||||
{...lineProps}
|
||||
x1={layout.extensionStart[0]}
|
||||
x2={layout.extensionStartTip[0]}
|
||||
y1={layout.extensionStart[1]}
|
||||
y2={layout.extensionStartTip[1]}
|
||||
/>
|
||||
<line
|
||||
{...lineProps}
|
||||
x1={layout.extensionEnd[0]}
|
||||
x2={layout.extensionEndTip[0]}
|
||||
y1={layout.extensionEnd[1]}
|
||||
y2={layout.extensionEndTip[1]}
|
||||
/>
|
||||
<line
|
||||
{...lineProps}
|
||||
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
|
||||
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
|
||||
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
|
||||
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
|
||||
data-floorplan-dimension-line=""
|
||||
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
|
||||
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
|
||||
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
|
||||
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
|
||||
x1={layout.dimensionLineStart[0]}
|
||||
x2={layout.dimensionLineEnd[0]}
|
||||
y1={layout.dimensionLineStart[1]}
|
||||
y2={layout.dimensionLineEnd[1]}
|
||||
/>
|
||||
{renderTerminator(
|
||||
terminator,
|
||||
layout.dimensionStart,
|
||||
layout.dimensionEnd,
|
||||
layout,
|
||||
lineProps,
|
||||
tickStrokeWidth,
|
||||
)}
|
||||
{renderTerminator(
|
||||
terminator,
|
||||
layout.dimensionEnd,
|
||||
layout.dimensionStart,
|
||||
layout,
|
||||
lineProps,
|
||||
tickStrokeWidth,
|
||||
)}
|
||||
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
|
||||
<line
|
||||
{...lineProps}
|
||||
data-floorplan-dimension-leader=""
|
||||
visibility="hidden"
|
||||
x1={layout.dimensionEnd[0]}
|
||||
x2={layout.labelPoint[0]}
|
||||
y1={layout.dimensionEnd[1]}
|
||||
y2={layout.labelPoint[1]}
|
||||
/>
|
||||
) : null}
|
||||
<g
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||
geometry.offsetDistance,
|
||||
)}
|
||||
data-floorplan-dimension-label-placement={layout.labelPlacement}
|
||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
|
||||
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
|
||||
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
|
||||
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
|
||||
transform={labelTransform}
|
||||
>
|
||||
<DimensionLabel
|
||||
fontSize={labelFontSize}
|
||||
renderMode={renderMode}
|
||||
stroke={stroke}
|
||||
text={geometry.text}
|
||||
y={labelY}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export function FloorplanDimensionStringRenderer({
|
||||
geometry,
|
||||
sceneRotationDeg = 0,
|
||||
stroke = geometry.stroke ?? '#334155',
|
||||
annotationUnitsPerPoint,
|
||||
renderMode = 'screen',
|
||||
}: {
|
||||
geometry: DimensionStringGeometry
|
||||
sceneRotationDeg?: number
|
||||
stroke?: string
|
||||
annotationUnitsPerPoint?: number
|
||||
renderMode?: FloorplanDimensionRenderMode
|
||||
}): React.ReactElement | null {
|
||||
const segmentLayouts = geometry.segments.flatMap((segment, index) => {
|
||||
const segmentGeometry: DimensionGeometry = {
|
||||
kind: 'dimension',
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
dimensionStart: segment.dimensionStart,
|
||||
dimensionEnd: segment.dimensionEnd,
|
||||
offsetNormal: geometry.offsetNormal,
|
||||
offsetDistance: geometry.offsetDistance,
|
||||
extensionOvershoot: geometry.extensionOvershoot,
|
||||
extensionStartGap: geometry.extensionStartGap,
|
||||
terminator: geometry.terminator,
|
||||
textPosition: geometry.textPosition,
|
||||
text: segment.text,
|
||||
stroke: geometry.stroke,
|
||||
}
|
||||
const layout = computeArchitecturalDimensionLayout(
|
||||
segmentGeometry,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
)
|
||||
return layout ? [{ index, layout, segment: segmentGeometry }] : []
|
||||
})
|
||||
if (segmentLayouts.length === 0) return null
|
||||
|
||||
const labelFontSize = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
|
||||
: LABEL_FONT_SIZE
|
||||
const labelBaselineOffset = annotationUnitsPerPoint
|
||||
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
|
||||
: LABEL_BASELINE_OFFSET
|
||||
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
|
||||
const lineProps = {
|
||||
stroke,
|
||||
strokeLinecap: 'butt' as const,
|
||||
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
|
||||
vectorEffect: 'non-scaling-stroke' as const,
|
||||
}
|
||||
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
|
||||
|
||||
const extensionLines = new Map<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
|
||||
const ticks = new Map<
|
||||
string,
|
||||
{ point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
|
||||
>()
|
||||
for (const { layout } of segmentLayouts) {
|
||||
extensionLines.set(pointKey(layout.dimensionStart), {
|
||||
start: layout.extensionStart,
|
||||
tip: layout.extensionStartTip,
|
||||
})
|
||||
extensionLines.set(pointKey(layout.dimensionEnd), {
|
||||
start: layout.extensionEnd,
|
||||
tip: layout.extensionEndTip,
|
||||
})
|
||||
ticks.set(pointKey(layout.dimensionStart), {
|
||||
point: layout.dimensionStart,
|
||||
toward: layout.dimensionEnd,
|
||||
tickHalfVector: layout.tickHalfVector,
|
||||
})
|
||||
ticks.set(pointKey(layout.dimensionEnd), {
|
||||
point: layout.dimensionEnd,
|
||||
toward: layout.dimensionStart,
|
||||
tickHalfVector: layout.tickHalfVector,
|
||||
})
|
||||
}
|
||||
const terminator = geometry.terminator ?? 'architectural-tick'
|
||||
|
||||
return (
|
||||
<g data-floorplan-dimension-string="" pointerEvents="none">
|
||||
{[...extensionLines.values()].map((line, index) => (
|
||||
<line
|
||||
{...lineProps}
|
||||
key={`extension-${index}`}
|
||||
x1={line.start[0]}
|
||||
x2={line.tip[0]}
|
||||
y1={line.start[1]}
|
||||
y2={line.tip[1]}
|
||||
/>
|
||||
))}
|
||||
{segmentLayouts.map(({ index, layout }) => (
|
||||
<line
|
||||
{...lineProps}
|
||||
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
|
||||
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
|
||||
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
|
||||
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
|
||||
data-floorplan-dimension-line=""
|
||||
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
|
||||
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
|
||||
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
|
||||
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
|
||||
key={`dimension-line-${index}`}
|
||||
x1={layout.dimensionLineStart[0]}
|
||||
x2={layout.dimensionLineEnd[0]}
|
||||
y1={layout.dimensionLineStart[1]}
|
||||
y2={layout.dimensionLineEnd[1]}
|
||||
/>
|
||||
))}
|
||||
{[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
|
||||
renderTerminator(
|
||||
terminator,
|
||||
point,
|
||||
toward,
|
||||
{ tickHalfVector },
|
||||
lineProps,
|
||||
tickStrokeWidth,
|
||||
`tick-${index}`,
|
||||
),
|
||||
)}
|
||||
{segmentLayouts.map(({ index, layout, segment }) => {
|
||||
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
|
||||
const outsideStartLocalShift = layout.outsideStartLabelPoint
|
||||
? rotateVector(
|
||||
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
|
||||
(-layout.labelAngleDeg * Math.PI) / 180,
|
||||
)
|
||||
: undefined
|
||||
return (
|
||||
<g key={`label-${index}`}>
|
||||
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
|
||||
<line
|
||||
{...lineProps}
|
||||
data-floorplan-dimension-leader=""
|
||||
visibility="hidden"
|
||||
x1={layout.dimensionEnd[0]}
|
||||
x2={layout.labelPoint[0]}
|
||||
y1={layout.dimensionEnd[1]}
|
||||
y2={layout.labelPoint[1]}
|
||||
/>
|
||||
) : null}
|
||||
<g
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||
geometry.offsetDistance,
|
||||
)}
|
||||
data-floorplan-dimension-label-placement={layout.labelPlacement}
|
||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
|
||||
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
|
||||
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
|
||||
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
|
||||
transform={labelTransform}
|
||||
>
|
||||
<DimensionLabel
|
||||
fontSize={labelFontSize}
|
||||
renderMode={renderMode}
|
||||
stroke={stroke}
|
||||
text={segment.text}
|
||||
y={labelY}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
|
||||
const cosine = Math.cos(radians)
|
||||
const sine = Math.sin(radians)
|
||||
return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
|
||||
}
|
||||
|
||||
function DimensionLabel({
|
||||
text,
|
||||
y,
|
||||
fontSize,
|
||||
stroke,
|
||||
renderMode,
|
||||
}: {
|
||||
text: string
|
||||
y: number
|
||||
fontSize: number
|
||||
stroke: string
|
||||
renderMode: FloorplanDimensionRenderMode
|
||||
}) {
|
||||
const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
|
||||
const plateWidth = width + fontSize * 0.5
|
||||
const plateHeight = fontSize * 1.2
|
||||
const plateY = y - fontSize * 0.82
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderMode === 'pdf' ? (
|
||||
<rect
|
||||
data-floorplan-dimension-label-plate=""
|
||||
fill="#ffffff"
|
||||
height={plateHeight}
|
||||
rx={fontSize * 0.12}
|
||||
ry={fontSize * 0.12}
|
||||
width={plateWidth}
|
||||
x={-plateWidth / 2}
|
||||
y={plateY}
|
||||
/>
|
||||
) : null}
|
||||
<text
|
||||
fill={stroke}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={fontSize}
|
||||
fontWeight={500}
|
||||
paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
|
||||
stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
|
||||
strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
|
||||
strokeWidth={renderMode === 'pdf' ? undefined : 3}
|
||||
textAnchor="middle"
|
||||
vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
|
||||
x={0}
|
||||
y={y}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTerminator(
|
||||
terminator: DimensionTerminator,
|
||||
point: FloorplanPoint,
|
||||
toward: FloorplanPoint,
|
||||
layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
|
||||
lineProps: {
|
||||
stroke: string
|
||||
strokeLinecap: 'butt'
|
||||
strokeWidth: number
|
||||
vectorEffect: 'non-scaling-stroke'
|
||||
},
|
||||
tickStrokeWidth: number,
|
||||
key?: string,
|
||||
): React.ReactElement | null {
|
||||
const direction = normalized(point, toward)
|
||||
if (!direction) return null
|
||||
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
|
||||
if (terminator === 'dot') {
|
||||
return (
|
||||
<circle
|
||||
fill={lineProps.stroke}
|
||||
key={key}
|
||||
r={tickHalfLength * 0.45}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
cx={point[0]}
|
||||
cy={point[1]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
|
||||
const base = addScaled(point, direction, tickHalfLength * 1.7)
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const wing = tickHalfLength * 0.65
|
||||
const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
|
||||
const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
|
||||
if (terminator === 'filled-arrow') {
|
||||
return (
|
||||
<polygon
|
||||
fill={lineProps.stroke}
|
||||
key={key}
|
||||
points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<g key={key}>
|
||||
<line
|
||||
{...lineProps}
|
||||
strokeWidth={tickStrokeWidth}
|
||||
x1={point[0]}
|
||||
x2={left[0]}
|
||||
y1={point[1]}
|
||||
y2={left[1]}
|
||||
/>
|
||||
<line
|
||||
{...lineProps}
|
||||
strokeWidth={tickStrokeWidth}
|
||||
x1={point[0]}
|
||||
x2={right[0]}
|
||||
y1={point[1]}
|
||||
y2={right[1]}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
const [tickX, tickY] = layout.tickHalfVector
|
||||
return (
|
||||
<line
|
||||
{...lineProps}
|
||||
key={key}
|
||||
strokeWidth={tickStrokeWidth}
|
||||
x1={point[0] - tickX}
|
||||
x2={point[0] + tickX}
|
||||
y1={point[1] - tickY}
|
||||
y2={point[1] + tickY}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const magnitude = Math.hypot(dx, dy)
|
||||
return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
|
||||
}
|
||||
|
||||
function pointKey(point: FloorplanPoint): string {
|
||||
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
|
||||
describe('FloorplanGeometryRenderer static labels', () => {
|
||||
test('renders a measurement value together with its geometry', () => {
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' },
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: 1,
|
||||
cy: 0,
|
||||
text: '2.00m',
|
||||
angle: 0,
|
||||
offsetPx: 14,
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('<line')
|
||||
expect(markup).toContain('2.00m')
|
||||
expect(markup).toContain('translate(0 -0.14)')
|
||||
})
|
||||
|
||||
test('keeps screen-upright measurement labels readable in rotated exports', () => {
|
||||
const geometry = {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
text: 'A 6.0m²',
|
||||
angle: Math.PI / 3,
|
||||
screenUpright: true,
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} sceneRotationDeg={90} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('A 6.0m²')
|
||||
expect(markup).toContain('rotate(-90)')
|
||||
})
|
||||
|
||||
test('uses paper-point sizing when document scale is provided', () => {
|
||||
const geometry = {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
text: '2.00m',
|
||||
angle: 0,
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('font-size="0.08"')
|
||||
})
|
||||
|
||||
test('uses live screen sizing without enabling document annotation styles', () => {
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: 'LIVE TEXT',
|
||||
fontSize: 0.16,
|
||||
upright: true,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
text: '2.00m',
|
||||
angle: 0,
|
||||
offsetPx: 14,
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} screenUnitsPerPixel={0.02} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('font-size="0.16"')
|
||||
expect(markup).toContain('font-size="0.24"')
|
||||
expect(markup).toContain('translate(0 -0.28)')
|
||||
})
|
||||
|
||||
test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => {
|
||||
const geometry = {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
text: '2.00m',
|
||||
angle: 0,
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer
|
||||
geometry={geometry}
|
||||
renderMode="pdf"
|
||||
screenUnitsPerPixel={0.02}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
|
||||
expect(markup).toContain('fill="#111827"')
|
||||
expect(markup).not.toContain('paint-order="stroke"')
|
||||
expect(markup).not.toContain('fill="#ffffff" font-family=')
|
||||
})
|
||||
|
||||
test('caps PDF annotation linework without changing live stroke widths', () => {
|
||||
const geometry = {
|
||||
kind: 'line',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 2,
|
||||
y2: 0,
|
||||
stroke: '#334155',
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const liveMarkup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
const pdfMarkup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(liveMarkup).toContain('stroke-width="2"')
|
||||
expect(pdfMarkup).toContain('stroke-width="0.5"')
|
||||
})
|
||||
|
||||
test('removes unsupported paint-order outlines from generic PDF text', () => {
|
||||
const geometry = {
|
||||
kind: 'text',
|
||||
x: 1,
|
||||
y: 2,
|
||||
text: '101',
|
||||
fontSize: 0.15,
|
||||
fill: '#ffffff',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.04,
|
||||
paintOrder: 'stroke',
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('fill="#334155"')
|
||||
expect(markup).not.toContain('paint-order')
|
||||
expect(markup).not.toContain('stroke=')
|
||||
})
|
||||
|
||||
test('resolves generic annotation text from paper points only in document mode', () => {
|
||||
const geometry = {
|
||||
kind: 'text',
|
||||
x: 2,
|
||||
y: 3,
|
||||
text: 'VERIFY DIMENSIONS',
|
||||
fontSize: 0.16,
|
||||
upright: true,
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const liveMarkup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
const documentMarkup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(liveMarkup).toContain('font-size="0.16"')
|
||||
expect(documentMarkup).toContain('font-size="0.08"')
|
||||
})
|
||||
|
||||
test('uses a room-label paper profile while preserving room label hierarchy', () => {
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: 'KITCHEN',
|
||||
fontSize: 0.2,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
||||
upright: true,
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
x: 0,
|
||||
y: 0.18,
|
||||
text: '101',
|
||||
fontSize: 0.16,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
||||
upright: true,
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
x: 0,
|
||||
y: 0.36,
|
||||
text: 'CH: 2700',
|
||||
fontSize: 0.11,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
||||
upright: true,
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('font-size="0.08"')
|
||||
expect(markup).toContain('font-size="0.07"')
|
||||
expect(markup).toContain('font-size="0.055"')
|
||||
expect(markup).toMatch(/translate\(0 0\.08625/)
|
||||
expect(markup).toMatch(/translate\(0 0\.18625/)
|
||||
expect(markup).toMatch(/translate\(0 0\.27375/)
|
||||
})
|
||||
|
||||
test('uses paper stroke profiles for leaders and opening marks in document mode', () => {
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
|
||||
children: [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1.4, 0],
|
||||
],
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
{
|
||||
kind: 'rect',
|
||||
x: 2,
|
||||
y: 2,
|
||||
width: 0.42,
|
||||
height: 0.32,
|
||||
fill: '#ffffff',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.02,
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('stroke-width="0.9"')
|
||||
expect(markup).toContain('vector-effect="non-scaling-stroke"')
|
||||
expect(markup).toContain('height="0.14"')
|
||||
expect(markup).toContain('rx="0.07"')
|
||||
})
|
||||
|
||||
test('registers fixed mark pills as annotation obstacles', () => {
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
|
||||
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
|
||||
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={geometry} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-annotation-obstacle=""')
|
||||
})
|
||||
|
||||
test('registers semantic plan primitives as annotation obstacles', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer
|
||||
geometry={{
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 0.2],
|
||||
],
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
|
||||
}}
|
||||
/>
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
|
||||
})
|
||||
|
||||
test('registers fixed annotation categories as obstacles', () => {
|
||||
const roomLabel = {
|
||||
kind: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: 'KITCHEN',
|
||||
fontSize: 0.18,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
|
||||
} satisfies FloorplanGeometry
|
||||
const stairArrow = {
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
],
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<svg>
|
||||
<FloorplanGeometryRenderer geometry={roomLabel} />
|
||||
<FloorplanGeometryRenderer geometry={stairArrow} />
|
||||
</svg>,
|
||||
)
|
||||
|
||||
expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
|
||||
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
|
||||
})
|
||||
})
|
||||
+425
-32
@@ -2,6 +2,29 @@
|
||||
|
||||
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
floorplanAnnotationObstacleMode,
|
||||
isFloorplanAnnotationObstacleGeometry,
|
||||
} from './floorplan-annotation-layout'
|
||||
import {
|
||||
FloorplanDimensionRenderer,
|
||||
FloorplanDimensionStringRenderer,
|
||||
} from './floorplan-dimension-renderer'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
|
||||
const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
|
||||
const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
|
||||
const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
|
||||
const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
|
||||
const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
|
||||
const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
|
||||
const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
|
||||
const DOCUMENT_MARK_HEIGHT_PT = 14
|
||||
const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
|
||||
|
||||
type FloorplanRenderMode = 'screen' | 'pdf'
|
||||
|
||||
/**
|
||||
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
|
||||
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
|
||||
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
|
||||
geometry,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg = 0,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode = 'screen',
|
||||
}: {
|
||||
geometry: FloorplanGeometry
|
||||
pointerEventsOverride?: string
|
||||
sceneRotationDeg?: number
|
||||
annotationUnitsPerPoint?: number
|
||||
screenUnitsPerPixel?: number
|
||||
renderMode?: FloorplanRenderMode
|
||||
}) {
|
||||
return renderNode(geometry, 0, pointerEventsOverride)
|
||||
return renderNode(
|
||||
geometry,
|
||||
0,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode,
|
||||
)
|
||||
})
|
||||
|
||||
function styleAttrs(
|
||||
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
|
||||
pointerEventsOverride?: string,
|
||||
annotationUnitsPerPoint?: number,
|
||||
renderMode: FloorplanRenderMode = 'screen',
|
||||
) {
|
||||
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
|
||||
// primitive switch arms terse and ensures new style fields land
|
||||
@@ -54,37 +95,241 @@ function styleAttrs(
|
||||
pointerEvents?: string
|
||||
cursor?: string
|
||||
}
|
||||
const annotationMetadata = readFloorplanGeometryMetadata(g)
|
||||
const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
|
||||
const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
|
||||
const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
|
||||
const strokeWidth =
|
||||
renderMode === 'pdf' &&
|
||||
vectorEffect === 'non-scaling-stroke' &&
|
||||
resolvedStrokeWidth !== undefined
|
||||
? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
|
||||
: resolvedStrokeWidth
|
||||
return {
|
||||
fill: s.fill ?? 'none',
|
||||
'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
|
||||
'data-floorplan-annotation-role': annotationMetadata.annotationRole,
|
||||
fill: documentStyle.fill ?? s.fill ?? 'none',
|
||||
fillOpacity: s.fillOpacity,
|
||||
stroke: s.stroke,
|
||||
strokeWidth: s.strokeWidth,
|
||||
stroke: documentStyle.stroke ?? s.stroke,
|
||||
strokeWidth,
|
||||
strokeDasharray: s.strokeDasharray,
|
||||
strokeLinecap: s.strokeLinecap,
|
||||
strokeLinejoin: s.strokeLinejoin,
|
||||
strokeOpacity: s.strokeOpacity,
|
||||
opacity: s.opacity,
|
||||
vectorEffect: s.vectorEffect,
|
||||
vectorEffect,
|
||||
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
|
||||
style: s.cursor ? { cursor: s.cursor } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDocumentFloorplanAnnotationStyle(
|
||||
geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
|
||||
annotationUnitsPerPoint?: number,
|
||||
): {
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
vectorEffect?: 'non-scaling-stroke'
|
||||
} {
|
||||
if (annotationUnitsPerPoint === undefined) return {}
|
||||
const styled = geometry as FloorplanGeometry & {
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
vectorEffect?: 'non-scaling-stroke'
|
||||
}
|
||||
if (!styled.stroke && geometry.kind !== 'text') return {}
|
||||
|
||||
if (geometry.kind === 'text') {
|
||||
const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
|
||||
const sourceStrokeWidth = geometry.strokeWidth ?? 0
|
||||
const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
|
||||
const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
|
||||
return {
|
||||
strokeWidth:
|
||||
geometry.stroke && outlineRatio > 0
|
||||
? Math.max(
|
||||
DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
|
||||
fontSize * outlineRatio,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
}
|
||||
}
|
||||
|
||||
function documentTextFontSize(
|
||||
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
|
||||
annotationUnitsPerPoint: number,
|
||||
): number {
|
||||
return documentTextSizePt(geometry) * annotationUnitsPerPoint
|
||||
}
|
||||
|
||||
function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
|
||||
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
|
||||
case 'room-label':
|
||||
if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
|
||||
if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
|
||||
return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
|
||||
case 'column-center':
|
||||
case 'stair-annotation':
|
||||
return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
|
||||
default:
|
||||
return DOCUMENT_DEFAULT_TEXT_SIZE_PT
|
||||
}
|
||||
}
|
||||
|
||||
function documentStrokeWidth(
|
||||
geometry: { strokeWidth?: number },
|
||||
annotationUnitsPerPoint: number,
|
||||
): number {
|
||||
return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
|
||||
}
|
||||
|
||||
export function documentRectGeometryAttrs(
|
||||
geometry: Extract<FloorplanGeometry, { kind: 'rect' }>,
|
||||
annotationUnitsPerPoint?: number,
|
||||
) {
|
||||
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
|
||||
return {
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
width: geometry.width,
|
||||
height: geometry.height,
|
||||
rx: geometry.rx,
|
||||
ry: geometry.ry,
|
||||
}
|
||||
}
|
||||
|
||||
const centerX = geometry.x + geometry.width / 2
|
||||
const centerY = geometry.y + geometry.height / 2
|
||||
const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
|
||||
const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
|
||||
return {
|
||||
x: centerX - width / 2,
|
||||
y: centerY - height / 2,
|
||||
width,
|
||||
height,
|
||||
rx: height / 2,
|
||||
ry: height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
function isAnnotationMarkRect(geometry: Extract<FloorplanGeometry, { kind: 'rect' }>): boolean {
|
||||
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
|
||||
}
|
||||
|
||||
export function documentCircleGeometryAttrs(
|
||||
geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
|
||||
annotationUnitsPerPoint?: number,
|
||||
) {
|
||||
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
|
||||
return { r: geometry.r }
|
||||
}
|
||||
return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
|
||||
}
|
||||
|
||||
function isAnnotationMarkCircle(geometry: Extract<FloorplanGeometry, { kind: 'circle' }>): boolean {
|
||||
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
|
||||
}
|
||||
|
||||
export function resolveDocumentAnnotationGroupChildren(
|
||||
children: FloorplanGeometry[],
|
||||
annotationUnitsPerPoint?: number,
|
||||
): FloorplanGeometry[] {
|
||||
if (annotationUnitsPerPoint === undefined) return children
|
||||
|
||||
const next = [...children]
|
||||
let start = 0
|
||||
while (start < next.length) {
|
||||
const first = next[start]
|
||||
if (!isDocumentTextLine(first)) {
|
||||
start++
|
||||
continue
|
||||
}
|
||||
|
||||
let end = start + 1
|
||||
while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
|
||||
if (end - start > 1) {
|
||||
const run = next.slice(start, end) as Extract<FloorplanGeometry, { kind: 'text' }>[]
|
||||
const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
|
||||
for (let index = 0; index < adjusted.length; index++) {
|
||||
const line = adjusted[index]
|
||||
if (line) next[start + index] = line
|
||||
}
|
||||
}
|
||||
start = end
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function isDocumentTextLine(
|
||||
geometry: FloorplanGeometry | undefined,
|
||||
): geometry is Extract<FloorplanGeometry, { kind: 'text' }> {
|
||||
return geometry?.kind === 'text' && geometry.upright === true
|
||||
}
|
||||
|
||||
function isSameDocumentTextRun(
|
||||
first: Extract<FloorplanGeometry, { kind: 'text' }>,
|
||||
candidate: FloorplanGeometry | undefined,
|
||||
): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
|
||||
return (
|
||||
isDocumentTextLine(candidate) &&
|
||||
Math.abs(candidate.x - first.x) < 1e-6 &&
|
||||
candidate.textAnchor === first.textAnchor &&
|
||||
readFloorplanGeometryMetadata(candidate).annotationRole ===
|
||||
readFloorplanGeometryMetadata(first).annotationRole
|
||||
)
|
||||
}
|
||||
|
||||
function positionDocumentTextRun(
|
||||
run: Extract<FloorplanGeometry, { kind: 'text' }>[],
|
||||
annotationUnitsPerPoint: number,
|
||||
): Extract<FloorplanGeometry, { kind: 'text' }>[] {
|
||||
const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
|
||||
const steps = run.slice(0, -1).map((line, index) => {
|
||||
const next = run[index + 1] ?? line
|
||||
const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
|
||||
return largerFontPt * 1.25 * annotationUnitsPerPoint
|
||||
})
|
||||
const totalHeight = steps.reduce((sum, step) => sum + step, 0)
|
||||
let y = centerY - totalHeight / 2
|
||||
return run.map((line, index) => {
|
||||
if (index > 0) y += steps[index - 1] ?? 0
|
||||
return { ...line, y }
|
||||
})
|
||||
}
|
||||
|
||||
function renderNode(
|
||||
g: FloorplanGeometry,
|
||||
keyHint: number,
|
||||
pointerEventsOverride?: string,
|
||||
sceneRotationDeg = 0,
|
||||
annotationUnitsPerPoint?: number,
|
||||
screenUnitsPerPixel?: number,
|
||||
renderMode: FloorplanRenderMode = 'screen',
|
||||
): React.ReactElement | null {
|
||||
switch (g.kind) {
|
||||
case 'path':
|
||||
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
|
||||
return (
|
||||
<path
|
||||
d={g.d}
|
||||
key={keyHint}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'polygon':
|
||||
return (
|
||||
<polygon
|
||||
key={keyHint}
|
||||
points={pointsToAttr(g.points)}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -93,34 +338,38 @@ function renderNode(
|
||||
<polyline
|
||||
key={keyHint}
|
||||
points={pointsToAttr(g.points)}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'rect':
|
||||
case 'rect': {
|
||||
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
|
||||
return (
|
||||
<rect
|
||||
height={g.height}
|
||||
height={attrs.height}
|
||||
key={keyHint}
|
||||
rx={g.rx}
|
||||
ry={g.ry}
|
||||
width={g.width}
|
||||
x={g.x}
|
||||
y={g.y}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
rx={attrs.rx}
|
||||
ry={attrs.ry}
|
||||
width={attrs.width}
|
||||
x={attrs.x}
|
||||
y={attrs.y}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'circle':
|
||||
case 'circle': {
|
||||
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
|
||||
return (
|
||||
<circle
|
||||
cx={g.cx}
|
||||
cy={g.cy}
|
||||
key={keyHint}
|
||||
r={g.r}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
r={attrs.r}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
case 'line':
|
||||
return (
|
||||
@@ -130,25 +379,65 @@ function renderNode(
|
||||
x2={g.x2}
|
||||
y1={g.y1}
|
||||
y2={g.y2}
|
||||
{...styleAttrs(g, pointerEventsOverride)}
|
||||
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
case 'text': {
|
||||
const fontSize =
|
||||
annotationUnitsPerPoint !== undefined
|
||||
? documentTextFontSize(g, annotationUnitsPerPoint)
|
||||
: g.fontSize
|
||||
const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
|
||||
const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
|
||||
const fill =
|
||||
pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
|
||||
? g.stroke
|
||||
: (g.fill ?? '#171717')
|
||||
if (g.upright) {
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
||||
key={keyHint}
|
||||
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
|
||||
>
|
||||
<text
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
fill={g.fill ?? '#171717'}
|
||||
fill={fill}
|
||||
fontFamily={g.fontFamily}
|
||||
fontSize={g.fontSize}
|
||||
fontSize={fontSize}
|
||||
fontWeight={g.fontWeight}
|
||||
opacity={g.opacity}
|
||||
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
|
||||
pointerEvents={pointerEventsOverride}
|
||||
stroke={pdfOutlinedText ? undefined : g.stroke}
|
||||
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
|
||||
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
|
||||
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
|
||||
textAnchor={g.textAnchor ?? 'start'}
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{g.text}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<text
|
||||
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
fill={fill}
|
||||
fontFamily={g.fontFamily}
|
||||
fontSize={fontSize}
|
||||
fontWeight={g.fontWeight}
|
||||
key={keyHint}
|
||||
opacity={g.opacity}
|
||||
paintOrder={g.paintOrder}
|
||||
stroke={g.stroke}
|
||||
strokeLinecap={g.stroke ? 'round' : undefined}
|
||||
strokeLinejoin={g.stroke ? 'round' : undefined}
|
||||
strokeWidth={g.strokeWidth}
|
||||
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
|
||||
stroke={pdfOutlinedText ? undefined : g.stroke}
|
||||
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
|
||||
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
|
||||
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
|
||||
textAnchor={g.textAnchor ?? 'start'}
|
||||
pointerEvents={pointerEventsOverride}
|
||||
x={g.x}
|
||||
@@ -157,6 +446,93 @@ function renderNode(
|
||||
{g.text}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
case 'dimension':
|
||||
return (
|
||||
<FloorplanDimensionRenderer
|
||||
geometry={g}
|
||||
key={keyHint}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
annotationUnitsPerPoint={annotationUnitsPerPoint}
|
||||
renderMode={renderMode}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'dimension-string':
|
||||
return (
|
||||
<FloorplanDimensionStringRenderer
|
||||
annotationUnitsPerPoint={annotationUnitsPerPoint}
|
||||
geometry={g}
|
||||
key={keyHint}
|
||||
renderMode={renderMode}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'dimension-label': {
|
||||
const unitsPerPixel =
|
||||
annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
|
||||
const documentMode = annotationUnitsPerPoint !== undefined
|
||||
const outlined = g.appearance === 'outlined'
|
||||
const pdfOutlined = outlined && renderMode === 'pdf'
|
||||
const padX = unitsPerPixel * 6
|
||||
const padY = unitsPerPixel * 3
|
||||
const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
|
||||
const textWidth = g.text.length * unitsPerPixel * 6.2
|
||||
const plateW = textWidth + padX * 2
|
||||
const plateH = fontSize + padY * 2
|
||||
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
|
||||
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
|
||||
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority="20"
|
||||
key={keyHint}
|
||||
pointerEvents="none"
|
||||
transform={labelTransform}
|
||||
>
|
||||
{outlined && !pdfOutlined ? null : (
|
||||
<rect
|
||||
data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
|
||||
fill="#ffffff"
|
||||
height={plateH}
|
||||
opacity={0.92}
|
||||
rx={unitsPerPixel * 3}
|
||||
ry={unitsPerPixel * 3}
|
||||
stroke={pdfOutlined ? undefined : '#334155'}
|
||||
strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
|
||||
width={plateW}
|
||||
x={-plateW / 2}
|
||||
y={-plateH / 2}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
|
||||
fontFamily={
|
||||
outlined && !pdfOutlined
|
||||
? 'system-ui, -apple-system, sans-serif'
|
||||
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
||||
}
|
||||
fontSize={fontSize}
|
||||
fontWeight={outlined ? 500 : 600}
|
||||
paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
|
||||
stroke={outlined && !pdfOutlined ? '#334155' : undefined}
|
||||
strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
|
||||
strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
|
||||
strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{g.text}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
case 'image':
|
||||
return (
|
||||
@@ -174,15 +550,32 @@ function renderNode(
|
||||
|
||||
case 'group': {
|
||||
const transform = formatTransform(g.transform)
|
||||
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
|
||||
return (
|
||||
<g key={keyHint} transform={transform}>
|
||||
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
|
||||
<g
|
||||
data-floorplan-annotation-obstacle={
|
||||
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
|
||||
}
|
||||
key={keyHint}
|
||||
transform={transform}
|
||||
>
|
||||
{children.map((child, i) =>
|
||||
renderNode(
|
||||
child,
|
||||
i,
|
||||
pointerEventsOverride,
|
||||
sceneRotationDeg,
|
||||
annotationUnitsPerPoint,
|
||||
screenUnitsPerPixel,
|
||||
renderMode,
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
// The interactive primitives (hatch / hit-line / endpoint-handle /
|
||||
// dimension-label) need the SVG context + theme palette + units-per-
|
||||
// The remaining interactive primitives (hatch / hit-line / endpoint-handle)
|
||||
// need the SVG context + theme palette + units-per-
|
||||
// pixel that only the registry layer has access to. They're rendered
|
||||
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
|
||||
// a caller routes one of these through this pure renderer it
|
||||
|
||||
@@ -3,15 +3,25 @@ import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
FloorplanAffordanceSession,
|
||||
FloorplanGeometry,
|
||||
LiveNodeOverrides,
|
||||
} from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
FLOORPLAN_NODE_EXTENSION_KEY,
|
||||
floorplanGeometryMetadata,
|
||||
} from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
cancelFloorplanAffordanceDrag,
|
||||
collectFloorplanDependencyNodes,
|
||||
collectFloorplanLinkedLevelNodes,
|
||||
computeAffectedSiblingIds,
|
||||
floorplanHandleDoubleClickAffordance,
|
||||
InteractiveGeometry,
|
||||
splitFloorplanOverlay,
|
||||
subscribeFloorplanAffordanceToolCancel,
|
||||
} from './floorplan-registry-layer'
|
||||
|
||||
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan annotation overlay routing', () => {
|
||||
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
|
||||
const noop = () => {}
|
||||
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
'svg',
|
||||
null,
|
||||
createElement(InteractiveGeometry, {
|
||||
activeDragId: null,
|
||||
activeRotateNodeId: null,
|
||||
geometry,
|
||||
hatchPatternId: undefined,
|
||||
hoveredHandleId: null,
|
||||
isMarqueeSelectionActive: false,
|
||||
nodeId: 'wall_test' as AnyNodeId,
|
||||
onHandleDoubleClick: noop,
|
||||
onHandleHoverChange: noop,
|
||||
onHandlePointerDown: noop,
|
||||
onMoveHandlePointerDown: noop,
|
||||
palette: undefined,
|
||||
sceneRotationDeg: 180,
|
||||
unitsPerPixel: 0.01,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const dimensionString = (
|
||||
end: readonly [number, number],
|
||||
offsetNormal: readonly [number, number],
|
||||
): FloorplanGeometry => ({
|
||||
kind: 'dimension-string',
|
||||
segments: [{ start: [0, 0], end, text: '2m' }],
|
||||
offsetNormal,
|
||||
offsetDistance: 0.55,
|
||||
extensionOvershoot: 0.12,
|
||||
textPosition: 'above',
|
||||
})
|
||||
|
||||
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
|
||||
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
|
||||
})
|
||||
|
||||
test('keeps a fixed mark pill together in the overlay pass', () => {
|
||||
const mark = {
|
||||
kind: 'group',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
|
||||
children: [
|
||||
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
|
||||
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
|
||||
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
|
||||
})
|
||||
|
||||
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
|
||||
const columnCenter = {
|
||||
kind: 'line',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAffectedSiblingIds', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFloorplanLinkedLevelNodes', () => {
|
||||
test('projects a node onto a linked destination level with its real children', () => {
|
||||
nodeRegistry._reset()
|
||||
registerNode({
|
||||
kind: 'linked-floorplan-test',
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
|
||||
category: 'structure',
|
||||
defaults: () => ({}) as never,
|
||||
floorplan: () => null,
|
||||
extensions: {
|
||||
[FLOORPLAN_NODE_EXTENSION_KEY]: {
|
||||
linkedLevelIds: () => ['level_upper' as AnyNodeId],
|
||||
},
|
||||
},
|
||||
} as unknown as AnyNodeDefinition)
|
||||
const child = {
|
||||
id: 'linked_child',
|
||||
type: 'linked-child',
|
||||
parentId: 'linked_parent',
|
||||
} as unknown as AnyNode
|
||||
const parent = {
|
||||
id: 'linked_parent',
|
||||
type: 'linked-floorplan-test',
|
||||
parentId: 'level_lower',
|
||||
children: [child.id],
|
||||
} as unknown as AnyNode
|
||||
const nodes = { [parent.id]: parent, [child.id]: child }
|
||||
|
||||
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
|
||||
{ id: parent.id, node: parent, children: [child] },
|
||||
])
|
||||
expect(
|
||||
collectFloorplanLinkedLevelNodes(
|
||||
nodes,
|
||||
'level_upper' as AnyNodeId,
|
||||
new Set([parent.id as AnyNodeId]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user