editor: improve floorplan modes and annotations (#549)
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled

* Add roof surface placement support for items

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

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

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

* fixed conflict

* fix(editor): anchor floorplan cursor to snapped point

* fix(nodes): preview floorplan edits through live overrides

* feat(editor): add context-aware floorplan modes

* fix(nodes): render crisp wall selection hatching

* fix(editor): cap floorplan handles at extreme zoom

* fix(editor): keep zone labels upright after rotation

* refactor(editor): make referenced annotations registry-driven

* refactor(nodes): colocate contextual dimension builders

* fix(editor): use mode-driven angle snapping

* fix(nodes): use mode-driven move snapping

* chore(editor): update react scan tooling

* refactor(floorplan): streamline construction documentation

* refactor(floorplan): remove wall assembly roadmap

* fix(floorplan): migrate retired scene data

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-27 18:57:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent daa1f3e99b
commit ab76686b8b
121 changed files with 3535 additions and 7840 deletions
-2
View File
@@ -5,7 +5,6 @@ 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,
@@ -90,7 +89,6 @@ 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">
+16 -3
View File
@@ -2,10 +2,13 @@
import { nodeRegistry } from '@pascal-app/core'
import {
type FloorplanMode,
getFloorplanNodeExtension,
isFloorplanToolAvailableInMode,
MaterialPaintPanel,
triggerSFX,
useEditor,
useFloorplanMode,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
@@ -73,7 +76,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
]
function collectBuildTypes(): BuildType[] {
function collectBuildTypes(floorplanMode: FloorplanMode): 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,
@@ -86,6 +89,7 @@ function collectBuildTypes(): BuildType[] {
if (
baseKinds.has(kind) ||
!extension?.tool ||
!isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) ||
!presentation ||
presentation.hidden ||
presentation.paletteSection !== 'structure'
@@ -126,7 +130,15 @@ const MEP_ITEMS: MepItem[] = [
*/
function activateBuildTool(kind: string): void {
const ed = useEditor.getState()
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
const definition = nodeRegistry.get(kind)
const extension = getFloorplanNodeExtension(definition)
if (
!isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode)
) {
useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind)
return
}
const preferredView = extension?.preferredView
if (preferredView) ed.setViewMode(preferredView)
ed.setPhase('structure')
ed.setStructureLayer('elements')
@@ -184,9 +196,10 @@ const MEP_TOOL_KINDS = new Set<string>([
export function BuildTab() {
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const floorplanMode = useFloorplanMode((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
const buildTypes = useMemo(collectBuildTypes, [])
const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode])
// 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
@@ -1,70 +0,0 @@
'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
}
+84 -88
View File
@@ -2,7 +2,6 @@
import { Icon as IconifyIcon } from '@iconify/react'
import {
DRAWING_TYPE_OPTIONS,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -11,9 +10,9 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDrawingView,
useEditor,
useFloorplanAnnotationVisibility,
useFloorplanMode,
useSidebarStore,
type ViewMode,
} from '@pascal-app/editor'
@@ -150,6 +149,19 @@ const FLOORPLAN_ANNOTATION_OPTIONS = [
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
] as const
const FLOORPLAN_MODE_OPTIONS = [
{
id: 'default',
name: 'Default',
detail: 'Clean plan; dimensions appear with selection',
},
{
id: 'expert',
name: 'Expert',
detail: 'Full documentation and annotation controls',
},
] 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' },
@@ -188,49 +200,6 @@ 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)
@@ -373,6 +342,8 @@ function DisplayMenu() {
const setWallDimensionReference = useFloorplanAnnotationVisibility(
(state) => state.setWallDimensionReference,
)
const floorplanMode = useFloorplanMode((state) => state.mode)
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
@@ -432,62 +403,88 @@ function DisplayMenu() {
<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>Floor plan mode</span>
<span className="ml-auto text-muted-foreground text-xs">
{
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
(option) => option.id === wallDimensionReference,
)?.name
}
{floorplanMode === 'default' ? 'Default' : 'Expert'}
</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))
}
>
{FLOORPLAN_MODE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setFloorplanMode(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 ? (
{floorplanMode === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{floorplanMode === 'expert' ? (
<>
<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}
</>
) : null}
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
@@ -694,7 +691,6 @@ export function CommunityViewerToolbarLeft() {
<>
<CollapseSidebarButton />
<ViewModeControl />
<DrawingTypeControl />
</>
)
}
+2 -2
View File
@@ -42,8 +42,8 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^3.0.2",
"react-grab": "^0.1.29",
"react-scan": "^0.5.3",
"react-grab": "^0.1.50",
"react-scan": "^0.5.7",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3"
}
+13 -6
View File
@@ -58,8 +58,8 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^3.0.2",
"react-grab": "^0.1.29",
"react-scan": "^0.5.3",
"react-grab": "^0.1.50",
"react-scan": "^0.5.7",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3",
},
@@ -315,6 +315,7 @@
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/three": "0.184.1",
"react-grab": "0.1.50",
"three": "0.185.1",
},
"packages": {
@@ -784,7 +785,7 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
"@react-grab/cli": ["@react-grab/cli@0.1.44", "", { "dependencies": { "agent-install": "^0.0.5", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A=="],
"@react-grab/cli": ["@react-grab/cli@0.1.50", "", { "dependencies": { "agent-install": "^0.0.6", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-Px/Hwhhyk2PubCA4ZaRFsfvwxhbxXsetJyvqC6aFFi8WhJhA+oVC33aTzuAeWmM3fhb4/8ce8YsHXI1d6ChcKg=="],
"@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="],
@@ -944,7 +945,7 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="],
"agent-install": ["agent-install@0.0.6", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA=="],
"agentation": ["agentation@3.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA=="],
@@ -988,7 +989,7 @@
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
"bippy": ["bippy@0.6.1", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ=="],
"blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="],
@@ -1644,7 +1645,7 @@
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-grab": ["react-grab@0.1.44", "", { "dependencies": { "@react-grab/cli": "0.1.44", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A=="],
"react-grab": ["react-grab@0.1.50", "", { "dependencies": { "@react-grab/cli": "0.1.50", "bippy": "^0.6.1" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-zRkHKq/8a1msCpEOp8BDROeQZT50m0OH2XPrP6jk5op+JAHrlsm3pj7eAQMOsct87EZDeGNnu4r+sGsJJzyw1Q=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -1984,8 +1985,12 @@
"postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="],
"react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"react-scan/bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
"react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
@@ -2012,6 +2017,8 @@
"next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"deslop-js/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+1
View File
@@ -39,6 +39,7 @@
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/three": "0.184.1",
"react-grab": "0.1.50",
"three": "0.185.1"
},
"optionalDependencies": {
-3
View File
@@ -14,7 +14,6 @@ import type {
DoorNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctFittingNode,
DuctSegmentNode,
DuctTerminalNode,
@@ -126,7 +125,6 @@ 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>
@@ -327,7 +325,6 @@ 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> &
-1
View File
@@ -12,7 +12,6 @@ export type {
ConstructionDimensionEvent,
DoorEvent,
DormerEvent,
DrawingSheetEvent,
ElevatorEvent,
EventSuffix,
FenceEvent,
@@ -226,38 +226,6 @@ describe('cloneNodesInto', () => {
}
})
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], {
-5
View File
@@ -3,7 +3,6 @@ import {
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
@@ -176,10 +175,6 @@ export function cloneNodesInto(
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) {
;(cloned as { position: [number, number, number] }).position = [
-31
View File
@@ -85,25 +85,6 @@ 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'
@@ -248,9 +229,6 @@ 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,
@@ -263,18 +241,11 @@ 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,
@@ -285,8 +256,6 @@ export {
WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode,
WallTreatmentSide,
WallTrimProfile,
+2 -5
View File
@@ -1,16 +1,13 @@
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, DrawingSheetNode.shape.id]))
.default([]),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.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(
@@ -18,7 +15,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, building-level systems such as elevators, and drawing sheets
- children: array of level nodes and building-level systems such as elevators
`,
)
@@ -1,222 +0,0 @@
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)
})
})
@@ -1,263 +0,0 @@
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'),
})),
}
}
-210
View File
@@ -2,13 +2,7 @@ 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,
@@ -19,7 +13,6 @@ import {
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig,
WallNode,
type WallNode as WallNodeType,
WallTrimConfig,
} from './wall'
@@ -267,206 +260,3 @@ 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,
})
})
})
-260
View File
@@ -127,48 +127,6 @@ 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'),
@@ -191,7 +149,6 @@ 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.
@@ -210,7 +167,6 @@ 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
@@ -234,222 +190,6 @@ 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.
-2
View File
@@ -10,7 +10,6 @@ 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'
@@ -84,7 +83,6 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctSegmentNode,
DuctFittingNode,
DuctTerminalNode,
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { type AnyNode, AnyNode as AnyNodeSchema } from '../schema'
import useScene from './use-scene'
function resetScene() {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
materials: {},
} as never)
useScene.temporal.getState().clear()
}
function baseScene(levelChildren: string[]): Record<string, AnyNode> {
return {
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'],
position: [0, 0, 0],
rotation: [0, 0, 0],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: levelChildren,
level: 0,
height: 2.5,
},
} as unknown as Record<string, AnyNode>
}
describe('retired floor-plan data migration', () => {
beforeEach(resetScene)
test('removes drawing-sheet nodes and their parent references', () => {
const nodes = baseScene([])
;(nodes.building_test as { children: string[] }).children.push('drawing-sheet_a101')
;(nodes as Record<string, unknown>)['drawing-sheet_a101'] = {
object: 'node',
id: 'drawing-sheet_a101',
type: 'drawing-sheet',
parentId: 'building_test',
visible: true,
metadata: {},
sheetNumber: 'A1.01',
sheetTitle: 'Floor Plan',
}
useScene.getState().setScene(nodes, ['site_test'] as never)
const migrated = useScene.getState().nodes
expect(migrated['drawing-sheet_a101' as keyof typeof migrated]).toBeUndefined()
expect((migrated.building_test as { children: string[] }).children).toEqual(['level_test'])
expect(Object.values(migrated).every((node) => AnyNodeSchema.safeParse(node).success)).toBe(
true,
)
})
test('converts wall assembly width to plain thickness and removes the legacy field', () => {
const nodes = baseScene(['wall_test'])
;(nodes as Record<string, unknown>).wall_test = {
object: 'node',
id: 'wall_test',
type: 'wall',
parentId: 'level_test',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [4, 0],
thickness: 0.1,
assemblyLayers: [
{ id: 'finish', role: 'interior-finish', side: 'interior', thickness: 0.0125 },
{ id: 'stud', role: 'structure', side: 'core', thickness: 0.1 },
{ id: 'sheathing', role: 'exterior-sheathing', side: 'exterior', thickness: 0.02 },
],
}
useScene.getState().setScene(nodes, ['site_test'] as never)
const wall = useScene.getState().nodes.wall_test as AnyNode & Record<string, unknown>
expect(wall.thickness).toBeCloseTo(0.1325)
expect(Object.hasOwn(wall, 'assemblyLayers')).toBe(false)
expect(AnyNodeSchema.safeParse(wall).success).toBe(true)
})
})
+41 -1
View File
@@ -591,6 +591,42 @@ function migrateConstructionDimension(node: Record<string, any>) {
}
}
function removeRetiredDrawingSheets(nodes: Record<string, any>) {
const retiredIds = new Set(
Object.entries(nodes)
.filter(([, node]) => node?.type === 'drawing-sheet')
.map(([id]) => id),
)
if (retiredIds.size === 0) return
for (const id of retiredIds) delete nodes[id]
for (const [id, node] of Object.entries(nodes)) {
if (!Array.isArray(node?.children)) continue
const children = getStringArray(node.children)
if (!children.some((childId) => retiredIds.has(childId))) continue
nodes[id] = {
...node,
children: children.filter((childId) => !retiredIds.has(childId)),
}
}
}
function migrateWallAssembly(node: Record<string, any>) {
if (!Object.hasOwn(node, 'assemblyLayers')) return node
const assemblyThickness = Array.isArray(node.assemblyLayers)
? node.assemblyLayers.reduce((total: number, layer: unknown) => {
if (!(layer && typeof layer === 'object')) return total
const thickness = (layer as { thickness?: unknown }).thickness
return typeof thickness === 'number' && Number.isFinite(thickness) && thickness > 0
? total + thickness
: total
}, 0)
: 0
const { assemblyLayers: _assemblyLayers, ...wall } = node
return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall
}
// 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.
@@ -608,6 +644,7 @@ function migrateNodes(nodes: Record<string, any>): {
// any per-type migration runs, so already-saved scenes load cleanly.
const { nodes: healed } = healSceneNodes(nodes)
const patchedNodes = { ...healed } as Record<string, any>
removeRetiredDrawingSheets(patchedNodes)
// Scene materials minted while moving legacy wall fields onto `node.slots`;
// merged into the scene material map by the caller (`setScene`).
@@ -728,7 +765,10 @@ function migrateNodes(nodes: Record<string, any>): {
}
if (node.type === 'wall') {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials)
patchedNodes[id] = migrateWallSurfaceMaterials(
migrateWallAssembly(patchedNodes[id]),
mintedMaterials,
)
}
// Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
@@ -10,7 +10,6 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true,
parentId: 'level_test',
children: [],
assemblyLayers: [],
start,
end,
thickness: 0.1,
@@ -149,47 +149,6 @@ describe('construction-dimension clone references', () => {
})
})
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'] })
@@ -6,7 +6,6 @@ import {
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>
@@ -114,10 +113,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
if (clonedNode.type === 'construction-dimension') {
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
}
if (clonedNode.type === 'drawing-sheet') {
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
}
clonedNodes[newId] = clonedNode
}
@@ -287,10 +282,6 @@ export function cloneLevelSubtree(
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
clonedNodes.push(cloned)
}
@@ -1,15 +1,16 @@
'use client'
import { Icon } from '@iconify/react'
import { memo, useMemo } from 'react'
import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
import { furnishTools } from '../ui/action-menu/furnish-tools'
import { tools as structureTools } from '../ui/action-menu/structure-tools'
type SvgPoint = {
x: number
y: number
}
import {
type FloorplanCursorPoint,
projectFloorplanCursorPoint,
resolveFloorplanCursorIndicatorPosition,
} from './floorplan-cursor-indicator-position'
type FloorplanCursorIndicator =
| {
@@ -22,7 +23,7 @@ type FloorplanCursorIndicator =
}
type FloorplanCursorIndicatorOverlayProps = {
cursorPosition: SvgPoint | null
cursorPosition: FloorplanCursorPoint | null
floorplanSelectionTool: FloorplanSelectionTool
movingOpeningType: 'door' | 'window' | null
isPanning: boolean
@@ -46,6 +47,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
const tool = useEditor((state) => state.tool)
const structureLayer = useEditor((state) => state.structureLayer)
const catalogCategory = useEditor((state) => state.catalogCategory)
const cursorPoint = useFloorplanDraftPreview((state) => state.cursorPoint)
const anchorRef = useRef<HTMLDivElement>(null)
const [projectedCursorPosition, setProjectedCursorPosition] =
useState<FloorplanCursorPoint | null>(null)
const activeFloorplanToolConfig = useMemo(() => {
if (movingOpeningType) {
@@ -83,7 +88,32 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
return null
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
const position = cursorPosition
useLayoutEffect(() => {
const anchor = anchorRef.current
const overlayHost = anchor?.parentElement
const scene = overlayHost?.querySelector<SVGGElement>('[data-floorplan-scene]')
const sceneToViewport = scene?.getScreenCTM()
if (!(cursorPoint && cursorPosition && overlayHost && sceneToViewport)) {
setProjectedCursorPosition(null)
return
}
const overlayRect = overlayHost.getBoundingClientRect()
const nextPosition = projectFloorplanCursorPoint(cursorPoint, sceneToViewport, {
x: overlayRect.left,
y: overlayRect.top,
})
setProjectedCursorPosition((currentPosition) =>
currentPosition &&
currentPosition.x === nextPosition.x &&
currentPosition.y === nextPosition.y
? currentPosition
: nextPosition,
)
}, [cursorPoint, cursorPosition])
const position = resolveFloorplanCursorIndicatorPosition(cursorPosition, projectedCursorPosition)
if (!(indicator && position) || isPanning) {
return null
@@ -93,6 +123,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
<div
aria-hidden="true"
className="pointer-events-none absolute z-20"
ref={anchorRef}
style={{ left: position.x, top: position.y }}
>
{mode === 'delete' ? (
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import {
projectFloorplanCursorPoint,
resolveFloorplanCursorIndicatorPosition,
} from './floorplan-cursor-indicator-position'
describe('projectFloorplanCursorPoint', () => {
test('projects a snapped plan point into overlay-local screen coordinates', () => {
expect(
projectFloorplanCursorPoint(
[3, 4],
{
a: 0,
b: 10,
c: -10,
d: 0,
e: 128,
f: 40,
},
{ x: 30, y: 20 },
),
).toEqual({ x: 58, y: 50 })
})
test('anchors the placement pin to the projected snap point instead of the raw pointer', () => {
expect(resolveFloorplanCursorIndicatorPosition({ x: 88, y: 97 }, { x: 58, y: 70 })).toEqual({
x: 58,
y: 70,
})
})
})
@@ -0,0 +1,39 @@
export type FloorplanCursorPoint = {
x: number
y: number
}
type Matrix2D = {
a: number
b: number
c: number
d: number
e: number
f: number
}
export function projectFloorplanCursorPoint(
point: readonly [number, number],
sceneToViewport: Matrix2D,
overlayOrigin: FloorplanCursorPoint,
): FloorplanCursorPoint {
return {
x:
sceneToViewport.a * point[0] +
sceneToViewport.c * point[1] +
sceneToViewport.e -
overlayOrigin.x,
y:
sceneToViewport.b * point[0] +
sceneToViewport.d * point[1] +
sceneToViewport.f -
overlayOrigin.y,
}
}
export function resolveFloorplanCursorIndicatorPosition(
cursorPosition: FloorplanCursorPoint | null,
projectedCursorPosition: FloorplanCursorPoint | null,
): FloorplanCursorPoint | null {
return projectedCursorPosition ?? cursorPosition
}
@@ -6,6 +6,7 @@ import {
bboxCornerAnchors,
collectAlignmentAnchors,
DEFAULT_ANGLE_STEP,
type FloorplanGeometry,
type FloorplanPalette,
pauseSceneHistory,
pauseSpaceDetection,
@@ -22,13 +23,16 @@ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contex
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
import { clientToPlan } from '../../lib/floorplan/plan-coords'
import { isHistoryShortcut } from '../../lib/history'
import { formatLinearMeasurement } from '../../lib/measurements'
import { sfxEmitter } from '../../lib/sfx-bus'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, {
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../store/use-editor'
import useFloorplanMode from '../../store/use-floorplan-mode'
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
import {
classifyParticipant,
@@ -44,6 +48,8 @@ import {
} from '../editor/group-transform-shared'
import { swallowNextClick } from '../editor/handles/use-handle-drag'
import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
import { useFloorplanSceneRotation } from './floorplan-render-context'
import { FloorplanDimensionRenderer } from './renderers/floorplan-dimension-renderer'
// 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
// any selected element of a multi-selection slides the whole selection
@@ -441,9 +447,9 @@ export function startFloorplanGroupRotate(event: {
let delta = angleOf([plan[0], plan[1]]) - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
// 15° increments by default; Shift rotates freely — the same contract
// as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
if (isAngleSnapActive()) {
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
}
const entries = rotateGroupPatches(starts, links, pivot, delta)
const patchById = new Map(entries)
@@ -586,11 +592,15 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
}) {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const levelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const nodes = useScene((s) => s.nodes)
const delta = useFloorplanGroupDrag((s) => s.delta)
const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
const movingNode = useMovingNode()
const mode = useEditor((s) => s.mode)
const floorplanMode = useFloorplanMode((s) => s.mode)
const sceneRotationDeg = useFloorplanSceneRotation()
// While a selection modifier is held the box steps aside so clicks reach
// the entries underneath (toggle membership) instead of starting a drag.
@@ -638,6 +648,27 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
const pad = 6 * unitsPerPixel
const stroke = palette?.selectedStroke ?? '#3b82f6'
const interactive = !modifierHeld && !!onPointerDown
const dimensionOffset = pad + 0.28
const widthDimension = {
kind: 'dimension',
start: [box.x, box.z + box.depth],
end: [box.x + box.width, box.z + box.depth],
offsetNormal: [0, 1],
offsetDistance: dimensionOffset,
extensionOvershoot: 0.08,
text: formatLinearMeasurement(box.width, unit, metricNotation),
stroke,
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
const depthDimension = {
kind: 'dimension',
start: [box.x + box.width, box.z],
end: [box.x + box.width, box.z + box.depth],
offsetNormal: [1, 0],
offsetDistance: dimensionOffset,
extensionOvershoot: 0.08,
text: formatLinearMeasurement(box.depth, unit, metricNotation),
stroke,
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
// Mid-gesture the box rides the live delta (group move) or spins around the
// rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
// point, and positive matches the atan2 x→z sense on the y-down plan.
@@ -664,6 +695,18 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
x={box.x - pad}
y={box.z - pad}
/>
{floorplanMode === 'default' ? (
<g pointerEvents="none">
<FloorplanDimensionRenderer
geometry={widthDimension}
sceneRotationDeg={sceneRotationDeg}
/>
<FloorplanDimensionRenderer
geometry={depthDimension}
sceneRotationDeg={sceneRotationDeg}
/>
</g>
) : null}
{/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
drag a corner to spin the group (15° steps, Shift = free). */}
{interactive && onRotatePointerDown
@@ -7,13 +7,23 @@ import {
type FloorplanToolContext,
getFloorplanNodeExtension,
} from '../../lib/floorplan/floorplan-extension'
import {
type FloorplanMode,
isFloorplanToolAvailableInMode,
} from '../../lib/floorplan/floorplan-mode'
import useEditor from '../../store/use-editor'
import useFloorplanMode from '../../store/use-floorplan-mode'
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
function registeredFloorplanTool(
tool: string | null,
mode: FloorplanMode,
): ComponentType<FloorplanToolContext> | null {
if (!tool) return null
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
const loader = extension?.tool
if (!loader) return null
const cached = lazyToolCache.get(loader)
if (cached) return cached
@@ -25,6 +35,7 @@ function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanTo
export function FloorplanRegisteredToolLayer() {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const floorplanMode = useFloorplanMode((state) => state.mode)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const toolDefaults = useEditor((state) =>
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
@@ -43,7 +54,7 @@ export function FloorplanRegisteredToolLayer() {
useEditor.getState().setMode('select')
}, [])
if (mode !== 'build') return null
const Tool = registeredFloorplanTool(tool)
const Tool = registeredFloorplanTool(tool, floorplanMode)
return Tool ? (
<Suspense fallback={null}>
<Tool
@@ -1,7 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
collectAnnotationLayoutPreflightIssues,
floorplanAnnotationObstacleMode,
polylineObstacleRectangles,
resolveAnnotationLabelRectangles,
@@ -43,66 +42,6 @@ describe('floorplanAnnotationObstacleMode', () => {
})
})
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([
@@ -341,6 +280,6 @@ describe('resolveSvgAnnotationCollisions', () => {
},
} as unknown as SVGSVGElement
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toEqual([])
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toBeUndefined()
})
})
@@ -8,8 +8,6 @@ export type AnnotationLabelRectangle = {
width: number
height: number
priority: number
text?: string
labelPlacement?: 'inside' | 'outside-end'
pinnedShift?: { dx: number; dy: number }
tangentX?: number
tangentY?: number
@@ -79,16 +77,6 @@ class AnnotationObstacleIndex {
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[],
@@ -131,16 +119,15 @@ export function resolveSvgAnnotationCollisions(
labels?: readonly SVGGElement[]
layoutOverrides?: AnnotationLayoutOverrides
} = {},
): AnnotationPreflightIssue[] {
): void {
const labels =
options.labels ??
Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
if (labels.length === 0) return []
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
}
@@ -188,9 +175,6 @@ export function resolveSvgAnnotationCollisions(
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,
@@ -201,7 +185,6 @@ export function resolveSvgAnnotationCollisions(
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]
@@ -209,7 +192,6 @@ export function resolveSvgAnnotationCollisions(
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()
@@ -227,78 +209,7 @@ export function resolveSvgAnnotationCollisions(
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 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 {
@@ -4,6 +4,7 @@ import type {
AnyNodeId,
FloorplanAffordanceSession,
FloorplanGeometry,
FloorplanPalette,
LiveNodeOverrides,
} from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
@@ -23,10 +24,68 @@ import {
floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
isFloorplanOpeningPlacementState,
resolveFloorplanHandleUnitsPerPixel,
splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer'
describe('floorplan selection handle sizing', () => {
test('caps visual handle growth at extreme zoom-out', () => {
expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01)
expect(resolveFloorplanHandleUnitsPerPixel(0.1)).toBe(0.015)
const palette = {
selectedStroke: '#111111',
selectedFill: '#ffffff',
selectedHatch: '#111111',
wallHoverStroke: '#111111',
endpointHandleFill: '#ffffff',
endpointHandleStroke: '#111111',
endpointHandleHoverStroke: '#222222',
endpointHandleActiveFill: '#333333',
endpointHandleActiveStroke: '#444444',
curveHandleFill: '#ffffff',
curveHandleStroke: '#008080',
curveHandleHoverStroke: '#00aaaa',
measurementStroke: '#111111',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#111111',
} satisfies FloorplanPalette
const noop = () => {}
const markup = renderToStaticMarkup(
createElement(
'svg',
null,
createElement(InteractiveGeometry, {
activeDragId: null,
activeRotateNodeId: null,
geometry: {
kind: 'endpoint-handle',
point: [0, 0],
state: 'idle',
affordance: 'move-endpoint',
payload: { endpoint: 'start' },
},
hatchPatternId: undefined,
hoveredHandleId: null,
isMarqueeSelectionActive: false,
nodeId: 'wall_test' as AnyNodeId,
onHandleDoubleClick: noop,
onHandleHoverChange: noop,
onHandlePointerDown: noop,
onMoveHandlePointerDown: noop,
palette,
sceneRotationDeg: 0,
unitsPerPixel: 0.1,
}),
),
)
expect(markup).toContain('r="0.12"')
expect(markup).not.toContain('r="0.8"')
})
})
describe('floorplan affordance ownership', () => {
test('keeps the wall center curve drag owned by the floorplan dispatcher', () => {
expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({
@@ -262,6 +321,62 @@ describe('floorplan vertex double-click routing', () => {
})
describe('floorplan annotation overlay routing', () => {
test('keeps explicitly layered selection chrome above selected body fills', () => {
const selectionHatch = {
kind: 'line',
x1: 0,
y1: 0,
x2: 0.2,
y2: 0.2,
stroke: '#3b82f6',
metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(selectionHatch)).toEqual({
base: null,
overlay: selectionHatch,
})
})
test('registers upright zone labels for rotation-only presentation updates', () => {
const noop = () => {}
const markup = renderToStaticMarkup(
createElement(
'svg',
null,
createElement(InteractiveGeometry, {
activeDragId: null,
activeRotateNodeId: null,
geometry: {
kind: 'text',
x: 4,
y: 6,
text: 'Kitchen',
fontSize: 0.2,
upright: true,
},
hatchPatternId: undefined,
hoveredHandleId: null,
isMarqueeSelectionActive: false,
nodeId: 'zone_test' as AnyNodeId,
onHandleDoubleClick: noop,
onHandleHoverChange: noop,
onHandlePointerDown: noop,
onMoveHandlePointerDown: noop,
palette: undefined,
sceneRotationDeg: 180,
unitsPerPixel: 0.01,
}),
),
)
expect(markup).not.toContain('data-floorplan-annotation-label=""')
expect(markup).toContain('data-floorplan-annotation-angle-radians="0"')
expect(markup).toContain('data-floorplan-annotation-screen-upright="true"')
expect(markup).toContain('data-floorplan-annotation-transform-before-rotation="translate(4 6)"')
expect(markup).toContain('transform="translate(4 6) rotate(-180)"')
})
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
const noop = () => {}
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
@@ -56,9 +56,17 @@ import {
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
import {
createFloorplanContextExtensions,
type FloorplanAnnotationRole,
type FloorplanWallDimensionReference,
getFloorplanNodeExtension,
readFloorplanGeometryMetadata,
withFloorplanGeometryMetadata,
} from '../../../lib/floorplan/floorplan-extension'
import {
type FloorplanMode,
resolveFloorplanAnnotationVisibility,
resolveFloorplanWallDimensionReference,
} from '../../../lib/floorplan/floorplan-mode'
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import {
type ActiveInteractionScope,
@@ -75,9 +83,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useDrawingView from '../../../store/use-drawing-view'
import useEditor from '../../../store/use-editor'
import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useFloorplanMode from '../../../store/use-floorplan-mode'
import useInteractionScope, {
getMovingNode,
useEndpointReshape,
@@ -135,8 +143,8 @@ import {
*/
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
// dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
// screen size at any zoom.
// dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
// until the world-space ceiling takes over at extreme zoom-out.
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
@@ -148,6 +156,11 @@ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
const DIRECT_DRAG_THRESHOLD_PX = 4
const DIRECT_ROTATE_EPSILON = 1e-6
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
}
const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
@@ -313,6 +326,7 @@ type FloorplanEntryDescriptor = {
}
type NodeDeps = {
automaticDimensions: boolean
node: AnyNode
live: LiveTransform | undefined
unit: 'metric' | 'imperial'
@@ -415,11 +429,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
const setHoveredId = useViewer((s) => s.setHoveredId)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const installedPlugins = useScene((s) => s.installedPlugins)
@@ -491,26 +500,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const drawingType = useDrawingView((s) => s.drawingType)
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
const floorplanMode = useFloorplanMode((s) => s.mode)
const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
floorplanMode,
wallDimensionReference,
)
// Elevator builders read runtime state imperatively, so entries include this
// rare-changing ref in their cache deps.
const interactiveElevators = useInteractive((s) => s.elevators)
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
// Marquee preview selection — matches the legacy `highlightedIdSet` use
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
// Multi-selection: members show highlight only (per-node edit chrome hidden)
// and transformable members advertise the drag-to-move gesture.
const isMultiSelect = selectedIds.length > 1
const groupParticipantIdSet = useMemo(() => {
if (selectedIds.length < 2 || !levelId) return null
return new Set(
selectedIds.filter(
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
),
)
}, [selectedIds, levelId, nodes])
// Interactive state lives in refs; only the visible feedback bits go
// into React state to keep re-renders cheap during drag.
const dragRef = useRef<ActiveDrag | null>(null)
@@ -730,7 +728,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
startX,
pointerEvent.clientX,
DIRECT_ROTATE_RADIANS_PER_PIXEL,
pointerEvent.shiftKey,
!isAngleSnapActive(),
)
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
lastPatch = null
@@ -1196,9 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
let delta = current - rot.initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
// Match the affordance's 15° angle step (Shift = free) so the wedge +
// degree chip read the committed rotation, not the raw pointer bearing.
delta = snapDirectRotationDelta(delta, event.shiftKey)
delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
if (Math.abs(delta) < 0.0087) {
setRotationOverlay(null)
} else {
@@ -1381,13 +1377,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
{entries.map((entry) => (
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanMode={floorplanMode}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
highlighted={highlightedIdSet.has(entry.id)}
hovered={hoveredId === entry.id}
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
interactiveElevators={interactiveElevators}
isMarqueeSelectionActive={isMarqueeSelectionActive}
@@ -1404,19 +1398,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onHandleHoverChange={setHoveredHandleId}
onHandleDoubleClick={commitAffordanceAction}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
palette={palette}
pass="base"
sceneRotationDeg={sceneRotationDeg}
selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
wallDimensionReference={effectiveWallDimensionReference}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
/>
@@ -1433,13 +1423,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
{entries.map((entry) => (
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanMode={floorplanMode}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
highlighted={highlightedIdSet.has(entry.id)}
hovered={hoveredId === entry.id}
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
interactiveElevators={interactiveElevators}
isMarqueeSelectionActive={isMarqueeSelectionActive}
@@ -1456,19 +1444,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onHandleHoverChange={setHoveredHandleId}
onHandleDoubleClick={commitAffordanceAction}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
palette={palette}
pass="overlay"
sceneRotationDeg={sceneRotationDeg}
selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
wallDimensionReference={effectiveWallDimensionReference}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
/>
@@ -1500,6 +1484,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const markerRef = useRef<SVGGElement>(null)
const collisionLabelElementsRef = useRef<SVGGElement[]>([])
const registryLabelElementsRef = useRef<SVGGElement[]>([])
const registryOrientationElementsRef = useRef<SVGGElement[]>([])
const appliedRotationDegRef = useRef<number | null>(null)
const appliedLayoutInputsRef = useRef<object | null>(null)
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
@@ -1511,6 +1496,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const wallDimensionReference = useFloorplanAnnotationVisibility(
(state) => state.wallDimensionReference,
)
const floorplanMode = useFloorplanMode((state) => state.mode)
const layoutInputs = useMemo(
() => ({
annotationVisibility,
@@ -1519,6 +1505,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
interactionIdle,
settledLayoutEpoch,
wallDimensionReference,
floorplanMode,
}),
[
annotationVisibility,
@@ -1527,11 +1514,10 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
interactionIdle,
settledLayoutEpoch,
wallDimensionReference,
floorplanMode,
],
)
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
const layoutEnabled = active && interactionIdle
useEffect(() => {
@@ -1575,7 +1561,6 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
// Explicit layout inputs replace the former whole-subtree MutationObserver.
if (!active) {
appliedLayoutInputsRef.current = null
resetPreflightIssues()
return
}
if (!interactionIdle) return
@@ -1595,37 +1580,30 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
registryLayer.contains(label),
)
registryOrientationElementsRef.current = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
)
}
const labels = registryLabelElementsRef.current
if (update.updateLabelPresentation) {
updateSvgFloorplanLabelOrientations(labels, sceneRotationDeg)
updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
appliedRotationDegRef.current = sceneRotationDeg
}
if (!update.resolveCollisions) return
const svg = markerRef.current?.ownerSVGElement
if (!svg) return
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
resolveSvgAnnotationCollisions(svg, {
labels: collisionLabelElementsRef.current,
layoutOverrides: annotationLayoutOverrides,
})
setPreflightIssues(preflightIssues)
appliedLayoutInputsRef.current = layoutInputs
for (const [index, label] of labels.entries()) {
for (const [index, label] of registryLabelElementsRef.current.entries()) {
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
label.style.pointerEvents = 'all'
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
}
}, [
active,
annotationLayoutOverrides,
interactionIdle,
layoutInputs,
resetPreflightIssues,
sceneRotationDeg,
setPreflightIssues,
])
}, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
useEffect(() => {
if (!layoutEnabled) return
@@ -1762,14 +1740,12 @@ function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
type FloorplanRegistryEntryProps = {
activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
annotationVisibility: FloorplanAnnotationVisibility
floorplanMode: FloorplanMode
ctxOverrides: FloorplanContextOverrides | undefined
floorplanVisible: boolean
geometryCacheRef: { current: Map<string, CacheEntry> }
hatchPatternId: string | undefined
highlighted: boolean
hovered: boolean
hoveredHandleId: string | null
interactiveElevators: unknown
isMarqueeSelectionActive: boolean
@@ -1779,10 +1755,6 @@ type FloorplanRegistryEntryProps = {
node: AnyNode
nodeId: AnyNodeId
nodes: Record<string, AnyNode>
/** Selected member of a multi-selection: hide its per-node edit chrome. */
suppressHandles: boolean
/** Transformable member of a multi-selection: advertise drag-to-move. */
groupMoveCursor: boolean
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
@@ -1801,11 +1773,9 @@ type FloorplanRegistryEntryProps = {
event: ReactPointerEvent<SVGGElement>,
rotationPivot?: FloorplanPoint,
) => void
onHoveredIdChange: (id: AnyNodeId | null) => void
palette: FloorplanPalette | undefined
pass: FloorplanRenderPass
sceneRotationDeg: number
selected: boolean
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
siblingEpoch: number
@@ -1817,14 +1787,12 @@ type FloorplanRegistryEntryProps = {
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
activeDragId,
activeRotateNodeId,
annotationVisibility,
floorplanMode,
ctxOverrides,
floorplanVisible,
geometryCacheRef,
hatchPatternId,
highlighted,
hovered,
hoveredHandleId,
interactiveElevators,
isMarqueeSelectionActive,
@@ -1834,19 +1802,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
node,
nodeId,
nodes,
suppressHandles,
groupMoveCursor,
onClickStop,
onEntryPointerDown,
onGroupMovePointerDown,
onHandleHoverChange,
onHandleDoubleClick,
onHandlePointerDown,
onHoveredIdChange,
palette,
pass,
sceneRotationDeg,
selected,
setMovingNode,
setMovingNodeOrigin,
siblingEpoch,
@@ -1855,6 +1819,27 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
wallDimensionReference,
visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null {
const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
const suppressHandles = useViewer(
(state) =>
state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const selectionProxyId = resolveSelectionProxyId(
node,
nodes as Record<string, AnyNode | undefined>,
)
const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
const setHoveredId = useViewer((state) => state.setHoveredId)
const referencedAnnotationRole = useViewer((state) =>
floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
)
const activeRotateNodeId = useDirectManipulationFeedback((state) =>
state.activeRotateNodeId === nodeId ? nodeId : null,
)
const groupMoveCursor =
suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
const liveOverride = useLiveNodeOverrides((s) =>
floorplanVisible ? s.overrides.get(nodeId) : undefined,
@@ -1862,6 +1847,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
const liveOverrides = floorplanVisible
? useLiveNodeOverrides.getState().overrides
: EMPTY_LIVE_OVERRIDES
const presentationVisibility = resolveFloorplanAnnotationVisibility(
floorplanMode,
annotationVisibility,
{
referencedAnnotationRole,
selected,
target: 'editor',
},
)
const handlePointerDown = useCallback(
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
@@ -1871,16 +1865,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
// the highlight halo in 3D as well as registry floor-plan hover strokes.
const handlePointerEnter = useCallback(() => {
const node = useScene.getState().nodes[nodeId]
onHoveredIdChange(
node
const currentNode = useScene.getState().nodes[nodeId]
setHoveredId(
currentNode
? resolveSelectionProxyId(
node,
currentNode,
useScene.getState().nodes as Record<string, AnyNode | undefined>,
)
: nodeId,
)
}, [nodeId, onHoveredIdChange])
}, [nodeId, setHoveredId])
const handlePointerLeave = useCallback(() => {
const node = useScene.getState().nodes[nodeId]
@@ -1890,8 +1884,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
useScene.getState().nodes as Record<string, AnyNode | undefined>,
)
: nodeId
if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
}, [nodeId, onHoveredIdChange])
if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
}, [nodeId, setHoveredId])
const handleHandlePointerDown = useCallback(
(
@@ -1940,6 +1934,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
)
const cacheEntry = buildFloorplanEntryGeometry({
automaticDimensions: presentationVisibility.automaticDimensions,
ctxOverrides,
geometryCache: geometryCacheRef.current,
highlighted,
@@ -1964,7 +1959,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
})
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
const visibleGeometry = rawGeometry
? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
: null
// Multi-selection shows highlight only: strip this member's edit handles /
// dimension chrome (all of which live in the overlay pass) while keeping
@@ -2008,6 +2003,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
}, shallowPropsAreEqual)
type BuildFloorplanEntryGeometryArgs = {
automaticDimensions: boolean
ctxOverrides: FloorplanContextOverrides | undefined
geometryCache: Map<string, CacheEntry>
highlighted: boolean
@@ -2052,7 +2048,20 @@ export function collectFloorplanDependencyNodes(
})
}
function floorplanEntryReferencedAnnotationRole(
node: AnyNode,
selectedIds: ReadonlySet<string>,
): FloorplanAnnotationRole | undefined {
if (selectedIds.size === 0) return undefined
const definition = nodeRegistry.get(node.type)
const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
if (!role) return undefined
const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
}
function buildFloorplanEntryGeometry({
automaticDimensions,
ctxOverrides,
geometryCache,
highlighted,
@@ -2094,6 +2103,7 @@ function buildFloorplanEntryGeometry({
)
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
const deps: NodeDeps = {
automaticDimensions,
node,
live,
unit,
@@ -2174,6 +2184,7 @@ function buildFloorplanEntryGeometry({
levelDataCache,
)
const viewState = {
automaticDimensions,
selected,
unit,
metricNotation,
@@ -2197,6 +2208,7 @@ function buildFloorplanEntryGeometry({
parent: ctxOverrides.parent,
levelData,
extensions: createFloorplanContextExtensions({
automaticDimensions,
metricNotation,
purpose: 'edit',
wallDimensionReference,
@@ -2216,10 +2228,20 @@ function buildFloorplanEntryGeometry({
...buildContext(effectiveNode, contextNodes, viewState, levelData),
resolve: resolveContextNode,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
effectiveNode,
ctx,
)
const contextualGeometry = selected
? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
: null
const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
annotationRole: 'contextual-dimension',
})
const geometry =
modelGeometry && taggedContextualGeometry
? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
: (modelGeometry ?? taggedContextualGeometry)
const { base, overlay } = geometry
? splitFloorplanOverlay(geometry)
: { base: null, overlay: null }
@@ -2318,6 +2340,9 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
}: InteractiveGeometryProps): React.ReactElement {
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
// Keep handles pixel-sized through normal navigation, then let them shrink
// with the plan once zoom compensation would make them dominate the geometry.
const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
return renderInteractive(geometry, 0)
@@ -2394,10 +2419,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
: palette.endpointHandleFill
const outerRadius =
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
unitsPerPixel
handleUnitsPerPixel
const dotRadius =
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
unitsPerPixel
handleUnitsPerPixel
return (
<g
key={keyHint}
@@ -2413,7 +2438,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
r={outerRadius}
stroke={hoverStroke}
strokeOpacity={isActive ? 0.24 : 0.16}
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/>
@@ -2425,7 +2450,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
r={outerRadius}
stroke={hoverStroke}
strokeOpacity={isActive ? 0.72 : 0.52}
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/>
@@ -2786,8 +2811,8 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
// Slightly smaller than endpoint dots; hover-expanded.
const baseRadiusPx = 6
const hoverRadiusPx = 8
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
const plusHalf = 3 * unitsPerPixel
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
const plusHalf = 3 * handleUnitsPerPixel
return (
<g
key={keyHint}
@@ -2800,10 +2825,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
cy={g.point[1]}
fill="none"
pointerEvents="none"
r={radius + 2 * unitsPerPixel}
r={radius + 2 * handleUnitsPerPixel}
stroke={hoverStroke}
strokeOpacity={0.16}
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
vectorEffect="non-scaling-stroke"
/>
@@ -3007,11 +3032,17 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
// Counter-rotate by the scene rotation so the label reads
// horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°).
const beforeRotation = `translate(${g.x} ${g.y})`
const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
return (
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
data-floorplan-annotation-angle-radians={0}
data-floorplan-annotation-default-transform={transform}
data-floorplan-annotation-screen-upright="true"
data-floorplan-annotation-transform-before-rotation={beforeRotation}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
transform={transform}
>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
@@ -3117,6 +3148,7 @@ export function buildContext(
node: AnyNode,
nodes: Record<string, AnyNode>,
viewState: {
automaticDimensions?: boolean
selected: boolean
unit: 'metric' | 'imperial'
metricNotation?: 'meters' | 'millimeters'
@@ -3162,6 +3194,7 @@ export function buildContext(
parent,
levelData,
extensions: createFloorplanContextExtensions({
automaticDimensions: viewState.automaticDimensions,
metricNotation: viewState.metricNotation ?? 'meters',
purpose: viewState.purpose ?? 'edit',
wallDimensionReference: viewState.wallDimensionReference,
@@ -3267,6 +3300,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
} {
if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
return { base: null, overlay: g }
}
if (isFloorplanAnnotationObstacleGeometry(g)) {
return { base: null, overlay: g }
}
@@ -3282,12 +3318,10 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
if (split.overlay) overlayChildren.push(split.overlay)
}
const base: FloorplanGeometry | null =
baseChildren.length > 0
? { kind: 'group', children: baseChildren, transform: g.transform }
: null
baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
const overlay: FloorplanGeometry | null =
overlayChildren.length > 0
? { kind: 'group', children: overlayChildren, transform: g.transform }
? { ...g, children: overlayChildren, transform: g.transform }
: null
return { base, overlay }
}
@@ -3428,6 +3462,7 @@ export function computeAffectedSiblingIds(
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
const keys: Array<keyof NodeDeps> = [
'automaticDimensions',
'node',
'live',
'unit',
@@ -10,8 +10,6 @@ const baseArgs = {
modifierKeys: { meta: false, ctrl: false, shift: false },
planPoint: [0, 0] as [number, number],
structureLayer: 'elements',
toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
visibleZonePolygons: [],
}
describe('resolveFloorplanBackgroundSelection', () => {
@@ -55,4 +53,20 @@ describe('resolveFloorplanBackgroundSelection', () => {
preserveSelection: true,
})
})
test('uses the registry hit result for zone selection', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
canSelectElementFloorplanGeometry: false,
canSelectFloorplanZones: true,
getFloorplanHitIdAtPoint: () => 'zone_1',
structureLayer: 'zones',
})
expect(result).toEqual({
handled: true,
kind: 'select-zone',
zoneId: 'zone_1',
})
})
})
@@ -1,7 +1,6 @@
'use client'
import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
import { isPointInsidePolygon } from '../../lib/floorplan'
import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type ModifierKeys = {
@@ -10,13 +9,6 @@ type ModifierKeys = {
shift: boolean
}
type ZoneHitEntry = {
zone: {
id: ZoneNodeType['id']
}
polygon: Point2D[]
}
type ResolveFloorplanBackgroundSelectionArgs = {
canSelectElementFloorplanGeometry: boolean
canSelectFloorplanZones: boolean
@@ -26,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
modifierKeys: ModifierKeys
planPoint: WallPlanPoint
structureLayer: string
toPoint2D: (point: WallPlanPoint) => Point2D
visibleZonePolygons: ZoneHitEntry[]
}
export type FloorplanBackgroundSelectionResult =
@@ -63,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
modifierKeys,
planPoint,
structureLayer,
toPoint2D,
visibleZonePolygons,
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
if (canSelectFloorplanZones) {
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
isPointInsidePolygon(toPoint2D(planPoint), polygon),
)
if (zoneHit) {
const zoneId = getFloorplanHitIdAtPoint(planPoint)
if (zoneId) {
return {
handled: true,
kind: 'select-zone',
zoneId: zoneHit.zone.id,
zoneId: zoneId as ZoneNodeType['id'],
}
}
}
@@ -0,0 +1,82 @@
'use client'
import { emitter, nodeRegistry } from '@pascal-app/core'
import { X } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import { isFloorplanToolAvailableInMode } from '../../lib/floorplan/floorplan-mode'
import useEditor from '../../store/use-editor'
import useFloorplanMode from '../../store/use-floorplan-mode'
function getToolLabel(tool: string): string {
return nodeRegistry.get(tool)?.presentation?.label ?? tool
}
export function FloorplanModeCoordinator() {
const editorMode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const floorplanMode = useFloorplanMode((state) => state.mode)
const notice = useFloorplanMode((state) => state.notice)
const dismissNotice = useFloorplanMode((state) => state.dismissNotice)
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
const showExpertModeNotice = useFloorplanMode((state) => state.showExpertModeNotice)
const showNotice = useFloorplanMode((state) => state.showNotice)
const previousFloorplanMode = useRef(floorplanMode)
useEffect(() => {
const priorFloorplanMode = previousFloorplanMode.current
previousFloorplanMode.current = floorplanMode
if (floorplanMode !== 'default' || editorMode !== 'build' || !tool) return
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
if (isFloorplanToolAvailableInMode(extension?.availableModes, floorplanMode)) return
const toolLabel = getToolLabel(tool)
emitter.emit('tool:cancel')
useEditor.getState().setMode('select')
if (priorFloorplanMode === 'expert') {
showNotice(
`Switched to Default. The unfinished ${toolLabel} draft was canceled; saved Expert annotations are hidden, not deleted.`,
)
} else {
showExpertModeNotice(toolLabel)
}
}, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool])
useEffect(() => {
if (!notice || notice.kind === 'switch-to-expert') return
const timer = window.setTimeout(dismissNotice, 6000)
return () => window.clearTimeout(timer)
}, [dismissNotice, notice])
if (!notice) return null
return (
<div
aria-live="polite"
className="fixed top-16 left-1/2 z-[100] flex max-w-md -translate-x-1/2 items-center gap-3 rounded-lg border border-border/60 bg-background/95 px-3 py-2 text-sm text-foreground shadow-elevation-3 backdrop-blur-xl"
role="status"
>
<span>{notice.message}</span>
{notice.kind === 'switch-to-expert' ? (
<button
className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
onClick={() => {
setFloorplanMode('expert')
dismissNotice()
}}
type="button"
>
Switch to Expert
</button>
) : null}
<button
aria-label="Dismiss"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
onClick={dismissNotice}
type="button"
>
<X aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</div>
)
}
@@ -35,10 +35,8 @@ import {
type SiteNode,
type SlabNode,
SlabNode as SlabNodeSchema,
type SpawnNode,
type StairNode,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
sampleWallCenterline,
sceneRegistry,
@@ -464,21 +462,6 @@ type PendingFenceDragState = {
startClientY: number
}
type ElevatorResizeHandle =
| 'width-negative'
| 'width-positive'
| 'depth-negative'
| 'depth-positive'
type ElevatorResizeDragState = {
center: Point2D
elevatorId: ElevatorNode['id']
handle: ElevatorResizeHandle
pointerId: number
rotation: number
shaftWallThickness: number
}
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -643,22 +626,11 @@ type SitePolygonEntry = {
points: string
}
type ZonePolygonEntry = {
zone: ZoneNodeType
polygon: Point2D[]
points: string
}
type FloorplanLineSegment = {
start: Point2D
end: Point2D
}
type FloorplanPolygonEntry = {
points: string
polygon: Point2D[]
}
type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode
@@ -674,52 +646,6 @@ type FloorplanItemEntry = {
depth: number
}
type FloorplanSpawnEntry = {
spawn: SpawnNode
position: Point2D
rotation: number
}
type FloorplanColumnEntry = {
column: ColumnNode
points: string
polygon: Point2D[]
}
type FloorplanElevatorServedLevel = {
id: LevelNode['id']
isCurrent: boolean
isDisabled: boolean
isQueued: boolean
isServiceOnly: boolean
isTarget: boolean
label: string
}
type FloorplanElevatorEntry = {
cabCenterLocalY: number
cabDepth: number
cabWidth: number
center: Point2D
doorStyle: ElevatorNode['doorStyle']
doorWidth: number
elevator: ElevatorNode
frontEdge: FloorplanLineSegment
frontNormal: Point2D
isCarOnLevel: boolean
isQueuedLevel: boolean
isTargetLevel: boolean
outerHalfDepth: number
outerHalfWidth: number
points: string
polygon: Point2D[]
rotation: number
servedLevels: FloorplanElevatorServedLevel[]
shaftDepth: number
shaftWallThickness: number
shaftWidth: number
}
type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[]
columnEntries: ReferenceFloorColumnEntry[]
@@ -764,42 +690,6 @@ const REFERENCE_REGISTRY_KINDS = new Set<AnyNode['type']>([
'elevator',
])
type FloorplanStairSegmentEntry = {
centerLine: FloorplanLineSegment | null
innerPoints: string
innerPolygon: Point2D[]
segment: StairSegmentNode
points: string
polygon: Point2D[]
treadBars: FloorplanPolygonEntry[]
treadThickness: number
}
type FloorplanStairArrowEntry = {
head: Point2D[]
polyline: Point2D[]
}
type FloorplanStairEntry = {
arrow: FloorplanStairArrowEntry | null
hitPolygons: Point2D[][]
stair: StairNode
segments: FloorplanStairSegmentEntry[]
}
type FloorplanRoofSegmentEntry = {
segment: RoofSegmentNode
polygon: Point2D[]
points: string
ridgeLine: FloorplanLineSegment | null
}
type FloorplanRoofEntry = {
roof: RoofNode
center: Point2D
segments: FloorplanRoofSegmentEntry[]
}
type FloorplanPalette = {
surface: string
minorGrid: string
@@ -944,18 +834,6 @@ function resolveFloorplanViewWidth(
return clamp(requestedWidth, Math.min(minWidth, currentWidth), Math.max(maxWidth, currentWidth))
}
function roundPlanMeters(value: number) {
return Math.round(value * 100) / 100
}
function getElevatorResizeAxis(handle: ElevatorResizeHandle) {
return handle.startsWith('width') ? 'width' : 'depth'
}
function getElevatorResizeSign(handle: ElevatorResizeHandle) {
return handle.endsWith('positive') ? 1 : -1
}
function getSelectionModifierKeys(event?: {
metaKey?: boolean
ctrlKey?: boolean
@@ -2465,7 +2343,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start,
end,
frontSide: 'unknown',
@@ -4165,262 +4042,6 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
)
})
const FloorplanZoneLayer = memo(function FloorplanZoneLayer({
canSelectZones,
hoveredZoneId,
isDeleteMode,
onZoneHoverChange,
onZoneSelect,
palette,
selectedZoneId,
zonePolygons,
}: {
canSelectZones: boolean
hoveredZoneId: ZoneNodeType['id'] | null
isDeleteMode: boolean
onZoneHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
onZoneSelect: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette
selectedZoneId: ZoneNodeType['id'] | null
zonePolygons: ZonePolygonEntry[]
}) {
return (
<>
{zonePolygons.map(({ zone, points }) => {
const isSelected = selectedZoneId === zone.id
const isHovered = hoveredZoneId === zone.id
const isDeleteHovered = isDeleteMode && isHovered
return (
<g key={zone.id}>
<polygon
fill={isDeleteHovered ? palette.deleteFill : zone.color}
fillOpacity={isDeleteHovered ? 0.22 : isSelected ? 0.28 : 0.16}
pointerEvents="none"
points={points}
stroke={
isDeleteHovered
? palette.deleteStroke
: isSelected
? palette.selectedStroke
: zone.color
}
strokeLinejoin="round"
strokeOpacity={isDeleteHovered || isSelected ? 0.96 : 0.72}
strokeWidth={isDeleteHovered || isSelected ? '0.08' : '0.05'}
vectorEffect="non-scaling-stroke"
/>
{canSelectZones && (
<polygon
fill="none"
onClick={(event) => {
event.stopPropagation()
onZoneSelect(zone.id, event)
}}
onPointerEnter={() => onZoneHoverChange(zone.id)}
onPointerLeave={() => onZoneHoverChange(null)}
pointerEvents="stroke"
points={points}
stroke="transparent"
strokeLinejoin="round"
strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH}
style={{ cursor: EDITOR_CURSOR }}
vectorEffect="non-scaling-stroke"
/>
)}
</g>
)
})}
</>
)
})
const FLOORPLAN_ZONE_LABEL_FONT_SIZE = 0.2
function FloorplanZoneLabelInput({
centroid,
svgRef,
viewBox,
zone,
onDone,
}: {
centroid: { x: number; y: number }
svgRef: React.RefObject<SVGSVGElement | null>
viewBox: { minX: number; minY: number; width: number; height: number }
zone: ZoneNodeType
onDone: () => void
}) {
const updateNode = useScene((s) => s.updateNode)
const [value, setValue] = useState(zone.name)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
}, [])
const save = useCallback(() => {
const trimmed = value.trim()
if (trimmed && trimmed !== zone.name) {
updateNode(zone.id, { name: trimmed })
}
onDone()
}, [value, zone.id, zone.name, updateNode, onDone])
// Convert SVG coordinates to screen pixel position
const svgEl = svgRef.current
if (!svgEl) return null
const rect = svgEl.getBoundingClientRect()
const screenX = ((centroid.x - viewBox.minX) / viewBox.width) * rect.width + rect.left
const screenY = ((centroid.y - viewBox.minY) / viewBox.height) * rect.height + rect.top
return createPortal(
<input
onBlur={save}
onChange={(e) => setValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
save()
}
if (e.key === 'Escape') {
e.preventDefault()
onDone()
}
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
ref={inputRef}
style={{
position: 'fixed',
left: screenX,
top: screenY,
transform: 'translate(-50%, -50%)',
border: 'none',
borderBottom: `1px solid ${zone.color}`,
background: 'transparent',
color: 'white',
textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
outline: 'none',
textAlign: 'center',
fontSize: '14px',
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: '2px 4px',
margin: 0,
zIndex: 100,
width: `${Math.max((value || zone.name || '').length + 2, 6)}ch`,
}}
type="text"
value={value}
/>,
document.body,
)
}
// Pencil icon as an SVG path (Lucide pencil simplified), rendered relative to the label
const PENCIL_ICON_SIZE = FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.6
function FloorplanZoneLabel({
centroid,
onHoverChange,
onLabelClick,
zone,
}: {
centroid: { x: number; y: number }
onHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
onLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
zone: ZoneNodeType
}) {
const [hovered, setHovered] = useState(false)
const textRef = useRef<SVGTextElement>(null)
const [textWidth, setTextWidth] = useState(0)
const mode = useEditor((s) => s.mode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection)
useEffect(() => {
if (textRef.current) {
setTextWidth(textRef.current.getComputedTextLength())
}
}, [])
const isDeleteMode = mode === 'delete'
return (
<g
cursor="pointer"
onClick={(e) => {
e.stopPropagation()
if (isDeleteMode) {
sfxEmitter.emit('sfx:structure-delete')
deleteNode(zone.id as AnyNodeId)
setSelection({ zoneId: null })
return
}
onLabelClick(zone.id, e)
}}
onPointerEnter={() => {
setHovered(true)
onHoverChange(zone.id)
}}
onPointerLeave={() => {
setHovered(false)
onHoverChange(null)
}}
pointerEvents="auto"
style={{ userSelect: 'none' }}
>
<text
dominantBaseline="central"
fill={isDeleteMode && hovered ? '#fecaca' : 'white'}
fontFamily="system-ui, -apple-system, sans-serif"
fontSize={FLOORPLAN_ZONE_LABEL_FONT_SIZE}
fontWeight="500"
paintOrder="stroke"
ref={textRef}
stroke={isDeleteMode && hovered ? '#dc2626' : zone.color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.35}
textAnchor="middle"
x={centroid.x}
y={centroid.y}
>
{zone.name}
</text>
{/* Pencil icon — visible on hover */}
{hovered && textWidth > 0 && (
<g
transform={`translate(${centroid.x + textWidth / 2 + PENCIL_ICON_SIZE * 0.5}, ${centroid.y - PENCIL_ICON_SIZE / 2})`}
>
<g transform={`scale(${PENCIL_ICON_SIZE / 24})`}>
<path
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
fill="none"
paintOrder="stroke"
stroke={zone.color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
/>
<path
d="m15 5 4 4"
fill="none"
stroke={zone.color}
strokeLinecap="round"
strokeWidth={3}
/>
</g>
</g>
)}
</g>
)
}
const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
edgeHandles = [],
hoveredHandleId,
@@ -5605,24 +5226,7 @@ export function FloorplanPanel({
)
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
const [hoveredFenceId, setHoveredFenceId] = useState<FenceNode['id'] | null>(null)
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
const [elevatorResizeDragState, setElevatorResizeDragState] =
useState<ElevatorResizeDragState | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
const [hoveredCeilingHandleId, setHoveredCeilingHandleId] = useState<string | null>(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
@@ -5717,11 +5321,9 @@ export function FloorplanPanel({
if (!floorplanViewportInteractionInProgressRef.current) {
latestViewportRef.current = viewport
}
// Tight bbox of the painted floor-plan scene (the rotation `<g>`'s
// children), read via SVG `getBBox()` after content changes settle. The legacy
// polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now
// empty stubs because rendering moved to the registry layer, so
// measuring the DOM is how `fittedViewport` learns where content lives.
// Tight bbox of the registry-rendered floor-plan content, read via SVG
// `getBBox()` after geometry changes settle. `fittedViewport` combines it
// with the editor-owned site boundary below.
const [measuredSceneBBox, setMeasuredSceneBBox] = useState<{
x: number
y: number
@@ -5962,28 +5564,9 @@ export function FloorplanPanel({
return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
// Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
// The legacy entry list is permanently empty; kept as a typed stable
// reference so downstream prop sites stay typed without each having to
// declare its own `[]`.
const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
// Wall is fully registry-driven. Empty stable arrays for the legacy
// entry lists; consumers' map / iteration paths become no-ops.
const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
// Doors + windows fully registry-driven via `def.floorplan`.
const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
// Slab + ceiling fully registry-driven via `def.floorplan`. Same
// empty-stable-array pattern.
const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
// Ceilings on the active level, projected to 2D polygons for hit-testing
// ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above
// is intentionally empty (ceilings render via the registry layer); this
// memo is the placement-side counterpart, separate from rendering.
// ceiling-item placement clicks/moves. Committed ceilings render through
// the registry; this memo is placement data, not a rendering fallback.
const ceilingHitEntries = useMemo(
() =>
ceilings.map((ceiling) => ({
@@ -5995,11 +5578,6 @@ export function FloorplanPanel({
})),
[ceilings],
)
// Zone fully registry-driven via `def.floorplan`.
const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
// Column fully registry-driven via `def.floorplan`.
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes],
@@ -6022,11 +5600,6 @@ export function FloorplanPanel({
),
[levelDescendantNodes],
)
// Spawn + item fully registry-driven.
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
// Elevator fully registry-driven via `def.floorplan`.
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
const referenceFloorLevel = useMemo(() => {
if (!(showReferenceFloor && levelNode)) {
return null
@@ -6269,26 +5842,6 @@ export function FloorplanPanel({
wallPolygons,
}
}, [referenceFloorDescendants, referenceFloorLevel])
// Pending-mesh check was a flag the legacy active-level item entries
// raised when their polygon was the dimension fallback (waiting for
// the GLB to load to produce a tighter convex hull). Items are now
// registry-rendered, so the active-level entry list is always empty
// and this flag is permanently false.
const hasPendingItemMeshFootprints = false
// Stair fully registry-driven via `def.floorplan` (the parent walks
// its `stair-segment` children inside `buildStairFloorplan` to handle
// the cumulative-transform chain). `FloorplanRegistryLayer` renders
// the result; this legacy list stays empty.
const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
// Roof / roof-segment fully registry-driven via def.floorplan.
const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
// Slab / ceiling / zone are registry-driven; the polygon-handle, hole
// editor, and boundary-edit affordances live on `def.floorplanAffordances`.
// These legacy lookups stay as null stubs so the hole-editing fallbacks
// that still reference them compile cleanly.
const selectedSlabEntry = null as SlabPolygonEntry | null
const selectedCeilingEntry = null as CeilingPolygonEntry | null
const selectedZoneEntry = null as ZonePolygonEntry | null
const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs])
const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones])
const ceilingById = useMemo(
@@ -6509,7 +6062,6 @@ export function FloorplanPanel({
Boolean(visibleSitePolygon) &&
activeHandleDrag?.nodeId === visibleSitePolygon?.site.id &&
activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
const visibleZonePolygons = displayZonePolygons
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
const highlightedFloorplanIdSet = useMemo(
() => new Set([...selectedIds, ...previewSelectedIds]),
@@ -6636,31 +6188,12 @@ export function FloorplanPanel({
)
const fittedViewport = useMemo(() => {
// Collect bounds from the legacy polygon arrays first. Most are empty
// stubs (rendering moved to the registry layer), but we still honor
// anything that does emit points so the fit is correct during the
// brief window before `measuredSceneBBox` is populated.
const legacyPoints = [
...(visibleSitePolygon ? visibleSitePolygon.polygon : []),
...displayCeilingPolygons.flatMap((entry) => entry.polygon),
...displaySlabPolygons.flatMap((entry) => entry.polygon),
...floorplanElevatorEntries.flatMap((entry) => entry.polygon),
...floorplanFenceEntries.flatMap((entry) => entry.centerline),
...floorplanItemEntries.flatMap((entry) => entry.polygon),
...floorplanRoofEntries.flatMap((entry) =>
entry.segments.flatMap((segmentEntry) => segmentEntry.polygon),
),
...floorplanStairEntries.flatMap((entry) => entry.hitPolygons.flat()),
...visibleZonePolygons.flatMap((entry) => entry.polygon),
...wallPolygons.flatMap((entry) => entry.polygon),
]
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const point of legacyPoints) {
for (const point of visibleSitePolygon?.polygon ?? []) {
const svgPoint = rotateSvgPoint(toSvgPoint(point), floorplanSceneRotationDeg)
minX = Math.min(minX, svgPoint.x)
maxX = Math.max(maxX, svgPoint.x)
@@ -6708,21 +6241,7 @@ export function FloorplanPanel({
centerY,
width,
}
}, [
displayCeilingPolygons,
displaySlabPolygons,
floorplanElevatorEntries,
floorplanFenceEntries,
floorplanItemEntries,
floorplanRoofEntries,
floorplanSceneRotationDeg,
floorplanStairEntries,
measuredSceneBBox,
svgAspectRatio,
visibleSitePolygon,
visibleZonePolygons,
wallPolygons,
])
}, [floorplanSceneRotationDeg, measuredSceneBBox, svgAspectRatio, visibleSitePolygon])
latestFittedViewportRef.current = fittedViewport
// Measure the content-only subtree after its geometry settles. ViewBox-only
@@ -7825,120 +7344,6 @@ export function FloorplanPanel({
[getSvgPointFromClientPoint, buildingRotationY],
)
const previewElevatorResize = useCallback(
(dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => {
const localDeltaX = planPoint[0] - dragState.center.x
const localDeltaY = planPoint[1] - dragState.center.y
const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation)
const axis = getElevatorResizeAxis(dragState.handle)
const sign = getElevatorResizeSign(dragState.handle)
const localDistance = sign * (axis === 'width' ? localX : localY)
const nextOuterSize = Math.max(0.1, localDistance) * 2
if (axis === 'width') {
const nextShaftWidth = roundPlanMeters(
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
)
const nextCabWidth = nextShaftWidth
useLiveNodeOverrides
.getState()
.set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth })
setCursorPoint(planPoint)
return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial<ElevatorNode>
}
const nextShaftDepth = roundPlanMeters(
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
)
const nextCabDepth = nextShaftDepth
useLiveNodeOverrides
.getState()
.set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth })
setCursorPoint(planPoint)
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
},
[setCursorPoint],
)
const handleElevatorResizePointerDown = useCallback(
(
entry: FloorplanElevatorEntry,
handle: ElevatorResizeHandle,
event: ReactPointerEvent<SVGCircleElement>,
) => {
if (event.button !== 0 || mode !== 'select') {
return
}
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
setHoveredElevatorId(null)
setSelection({ selectedIds: [entry.elevator.id] })
setElevatorResizeDragState({
center: entry.center,
elevatorId: entry.elevator.id,
handle,
pointerId: event.pointerId,
rotation: entry.rotation,
shaftWallThickness: entry.shaftWallThickness,
})
},
[mode, setSelection],
)
const handleElevatorResizePointerMove = useCallback(
(event: ReactPointerEvent<SVGCircleElement>) => {
const dragState = elevatorResizeDragState
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
if (!planPoint) {
return
}
event.preventDefault()
event.stopPropagation()
previewElevatorResize(dragState, planPoint)
},
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize],
)
const handleElevatorResizePointerUp = useCallback(
(event: ReactPointerEvent<SVGCircleElement>) => {
const dragState = elevatorResizeDragState
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {}
event.preventDefault()
event.stopPropagation()
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
useLiveNodeOverrides.getState().clear(dragState.elevatorId)
if (Object.keys(updates).length > 0) {
updateNode(dragState.elevatorId as AnyNodeId, updates)
}
setElevatorResizeDragState(null)
setCursorPoint(null)
},
[
elevatorResizeDragState,
getPlanPointFromClientPoint,
previewElevatorResize,
updateNode,
setCursorPoint,
],
)
useEffect(() => {
siteBoundaryDraftRef.current = siteBoundaryDraft
}, [siteBoundaryDraft])
@@ -8451,13 +7856,11 @@ export function FloorplanPanel({
const clearWallEndpointDrag = useCallback(() => {
wallEndpointDragRef.current = null
setWallEndpointDraft(null)
setHoveredEndpointId(null)
useWallSnapIndicator.getState().clear()
}, [])
const clearWallCurveDrag = useCallback(() => {
wallCurveDragRef.current = null
setWallCurveDraft(null)
setHoveredWallCurveHandleId(null)
}, [])
const clearSiteBoundaryInteraction = useCallback(() => {
const draft = siteBoundaryDraftRef.current
@@ -8665,14 +8068,6 @@ export function FloorplanPanel({
}
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [scheduleMovingFloorplanNodeRefresh])
// Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -9779,10 +9174,6 @@ export function FloorplanPanel({
return
}
if (elevatorResizeDragState?.pointerId === event.pointerId) {
return
}
if (wallEndpointDragRef.current?.pointerId === event.pointerId) {
return
}
@@ -10135,7 +9526,6 @@ export function FloorplanPanel({
publishFloorplanNavigationPose,
referenceScaleDraft,
roofDraftStart,
elevatorResizeDragState,
siteVertexDragState,
surfaceSize.height,
surfaceSize.width,
@@ -10398,21 +9788,7 @@ export function FloorplanPanel({
],
)
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons,
columnPolygons: floorplanColumnEntries,
displaySlabPolygons,
displayWallPolygons,
floorplanElevatorEntries,
floorplanItemEntries,
floorplanRoofEntries,
floorplanStairEntries,
getFloorplanOpeningHitTolerance,
getFloorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
sceneRef: floorplanSceneRef,
})
// Wall-commit snap for the placement hook. Mirrors the move-preview branch:
// it honours the Magnetic snap toggle so a click never snaps to geometry the
@@ -10552,8 +9928,6 @@ export function FloorplanPanel({
modifierKeys,
planPoint,
structureLayer,
toPoint2D,
visibleZonePolygons,
})
if (backgroundSelection.handled) {
@@ -10618,7 +9992,6 @@ export function FloorplanPanel({
structureLayer,
getFloorplanHitIdAtPoint,
unit,
visibleZonePolygons,
emitFloorplanGridEvent,
setCursorPoint,
],
@@ -11268,7 +10641,6 @@ export function FloorplanPanel({
!panStateRef.current &&
!floorplanRotationStateRef.current &&
!guideInteractionRef.current &&
!elevatorResizeDragState &&
!wallEndpointDragRef.current &&
!siteVertexDragState
) {
@@ -11296,7 +10668,6 @@ export function FloorplanPanel({
handlePointerMove,
hasFloorplanCursorIndicator,
isSpacePanPressed,
elevatorResizeDragState,
siteVertexDragState,
setFloorplanCursorPosition,
],
@@ -12013,18 +11384,10 @@ export function FloorplanPanel({
/>
)}
{/* Registry-driven floor-plan layer. Iterates kinds whose
NodeDefinition supplies a `floorplan` builder and renders
their SVG via <FloorplanGeometryRenderer>. Sits above the
legacy inline content so newly-registered kinds (shelf
today) overlay on top until their inline equivalent is
removed in their Phase 5 migration PR.
Wrapped in <FloorplanRenderProvider> so registry-driven
kinds receive the same themed palette / units-per-pixel
the legacy layers compute. The hatch pattern id is the
legacy wall hatch kinds that opt into selection hatch
fills reuse this <defs> pattern via fill="url(...)". */}
{/* Registry-driven floor-plan layer. Every committed node kind
supplies its geometry through `NodeDefinition.floorplan`.
The provider carries editor presentation datapalette,
scale, rotation, and the shared selection-hatch pattern. */}
<FloorplanRenderProvider
getSceneRotationDeg={getFloorplanSceneRotationDeg}
hatchPatternId={wallSelectionHatchId}
@@ -12165,13 +11528,6 @@ export function FloorplanPanel({
walls={walls}
/>
{/* Wall / fence endpoint, wall curve, slab / ceiling /
zone vertex+midpoint+edge handles are all driven by the
registry's `def.floorplanAffordances` and rendered as
part of `FloorplanRegistryLayer`. The legacy handle
layers that lived here received empty handle arrays
post-migration and rendered nothing. */}
{selectedGuide && showGuides && (
<FloorplanGuideSelectionOverlay
guide={selectedGuide}
@@ -32,6 +32,7 @@ import {
} from '../../lib/scene'
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import useFloorplanMode from '../../store/use-floorplan-mode'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
@@ -65,6 +66,7 @@ import { FenceTangentLines3D } from './fence-tangent-lines-3d'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanModeCoordinator } from './floorplan-mode-coordinator'
import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid'
import { GroupFloatingActionMenu } from './group-floating-action-menu'
@@ -1176,9 +1178,11 @@ export default function Editor({
useEffect(() => {
useViewer.getState().setProjectId(projectId ?? null)
useFloorplanMode.getState().setProjectId(projectId ?? null)
return () => {
useViewer.getState().setProjectId(null)
useFloorplanMode.getState().setProjectId(null)
}
}, [projectId])
@@ -1390,6 +1394,7 @@ export default function Editor({
return (
<>
<FloorplanModeCoordinator />
{showLoader && (
<div className="fixed inset-0 z-60">
<SceneLoader className="bg-background" />
@@ -1465,6 +1470,7 @@ export default function Editor({
return (
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
<FloorplanModeCoordinator />
{showLoader && (
<div className="fixed inset-0 z-60">
<SceneLoader className="bg-background" />
@@ -0,0 +1,82 @@
import { describe, expect, test } from 'bun:test'
import { collectRegistrySelectionIdsInBounds } from './use-floorplan-hit-testing'
const identityMatrix = {
inverse() {
return this
},
}
function pointFactory() {
return {
x: 0,
y: 0,
matrixTransform() {
return pointFactoryWithPosition(this.x, this.y)
},
}
}
function pointFactoryWithPosition(x: number, y: number) {
return {
x,
y,
matrixTransform() {
return pointFactoryWithPosition(x, y)
},
}
}
function geometry(x: number, y: number, width: number, height: number) {
return {
getBBox: () => ({ x, y, width, height }),
getScreenCTM: () => identityMatrix,
} as unknown as SVGGraphicsElement
}
function entry(nodeId: string, children: SVGGraphicsElement[]) {
return {
dataset: { nodeId },
querySelectorAll: () => children,
} as unknown as SVGGElement
}
function scene(entries: SVGGElement[]) {
return {
getScreenCTM: () => identityMatrix,
ownerSVGElement: {
createSVGPoint: pointFactory,
},
querySelectorAll: () => entries,
} as unknown as SVGGElement
}
describe('collectRegistrySelectionIdsInBounds', () => {
test('selects rendered registry geometry and preserves candidate order', () => {
const renderedScene = scene([
entry('wall_1', [geometry(0, 0, 4, 0.2)]),
entry('door_1', [geometry(1, 1, 0.9, 0.9)]),
entry('outside_1', [geometry(20, 20, 1, 1)]),
])
expect(
collectRegistrySelectionIdsInBounds(renderedScene, { minX: -1, minY: -1, maxX: 5, maxY: 5 }, [
'door_1',
'outside_1',
'wall_1',
]),
).toEqual(['door_1', 'wall_1'])
})
test('ignores rendered entries outside the selectable registry candidates', () => {
const renderedScene = scene([entry('zone_1', [geometry(0, 0, 2, 2)])])
expect(
collectRegistrySelectionIdsInBounds(
renderedScene,
{ minX: -1, minY: -1, maxX: 3, maxY: 3 },
[],
),
).toEqual([])
})
})
@@ -1,195 +1,178 @@
'use client'
import type {
AnyNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { useCallback } from 'react'
import {
getFloorplanHitNodeId,
getFloorplanSelectionIdsInBounds,
} from '../../lib/floorplan/selection-tool'
import { type RefObject, useCallback } from 'react'
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
import {
type Point2 as MarqueePoint2,
polygonsIntersect,
segmentIntersectsPolygon,
} from '../tools/select/marquee-geometry'
import { collectSelectableCandidateIds } from '../tools/select/select-candidates'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type OpeningNode = WindowNode | DoorNode
const REGISTRY_ENTRY_SELECTOR = '.floorplan-registry-base .floorplan-registry-entry[data-node-id]'
const REGISTRY_GEOMETRY_SELECTOR = 'path, polygon, polyline, rect, circle, line, image'
const POINT_HIT_EPSILON = 1e-4
type WallPolygonEntry = {
wall: WallNode
polygon: Point2D[]
type FloorplanHitTestingOptions = {
sceneRef: RefObject<SVGGElement | null>
}
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
function boundsPolygon(bounds: FloorplanSelectionBounds): MarqueePoint2[] {
return [
[bounds.minX, bounds.minY],
[bounds.maxX, bounds.minY],
[bounds.maxX, bounds.maxY],
[bounds.minX, bounds.maxY],
]
}
type SlabPolygonEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
function transformElementBoxToScene(
element: SVGGraphicsElement,
scene: SVGGElement,
): MarqueePoint2[] | null {
const elementMatrix = element.getScreenCTM()
const sceneMatrix = scene.getScreenCTM()
const svg = scene.ownerSVGElement
if (!(elementMatrix && sceneMatrix && svg)) {
return null
}
let box: DOMRect
try {
box = element.getBBox()
} catch {
return null
}
const inverseSceneMatrix = sceneMatrix.inverse()
const corners: MarqueePoint2[] = [
[box.x, box.y],
[box.x + box.width, box.y],
[box.x + box.width, box.y + box.height],
[box.x, box.y + box.height],
]
return corners.map(([x, y]) => {
const point = svg.createSVGPoint()
point.x = x
point.y = y
const screenPoint = point.matrixTransform(elementMatrix)
const scenePoint = screenPoint.matrixTransform(inverseSceneMatrix)
return [scenePoint.x, scenePoint.y] as MarqueePoint2
})
}
type CeilingPolygonEntry = {
ceiling: CeilingNode
polygon: Point2D[]
holes: Point2D[][]
function elementIntersectsPlanPolygon(
element: SVGGraphicsElement,
scene: SVGGElement,
selectionPolygon: MarqueePoint2[],
): boolean {
const polygon = transformElementBoxToScene(element, scene)
if (!polygon) {
return false
}
const [first, second, third] = polygon
if (!(first && second && third)) {
return false
}
const width = Math.hypot(second[0] - first[0], second[1] - first[1])
const height = Math.hypot(third[0] - second[0], third[1] - second[1])
if (width <= POINT_HIT_EPSILON || height <= POINT_HIT_EPSILON) {
const end = width >= height ? second : third
return segmentIntersectsPolygon(first, end, selectionPolygon)
}
return polygonsIntersect(polygon, selectionPolygon)
}
type ColumnPolygonEntry = {
column: ColumnNode
polygon: Point2D[]
export function collectRegistrySelectionIdsInBounds(
scene: SVGGElement,
bounds: FloorplanSelectionBounds,
candidateIds = collectSelectableCandidateIds(),
): string[] {
const candidateIdSet = new Set(candidateIds)
const selectionPolygon = boundsPolygon(bounds)
const hitIds = new Set<string>()
for (const entry of scene.querySelectorAll<SVGGElement>(REGISTRY_ENTRY_SELECTOR)) {
const nodeId = entry.dataset.nodeId
if (!nodeId || !candidateIdSet.has(nodeId)) {
continue
}
const geometry = entry.querySelectorAll<SVGGraphicsElement>(REGISTRY_GEOMETRY_SELECTOR)
const elements = geometry.length > 0 ? Array.from(geometry) : [entry]
if (
elements.some((element) => elementIntersectsPlanPolygon(element, scene, selectionPolygon))
) {
hitIds.add(nodeId)
}
}
return candidateIds.filter((id) => hitIds.has(id))
}
type ElevatorPolygonEntry = {
elevator: ElevatorNode
polygon: Point2D[]
export function getRegistryHitIdAtPlanPoint(
scene: SVGGElement,
planPoint: WallPlanPoint,
candidateIds = collectSelectableCandidateIds(),
): string | null {
const sceneMatrix = scene.getScreenCTM()
const svg = scene.ownerSVGElement
if (!(sceneMatrix && svg)) {
return null
}
const point = svg.createSVGPoint()
point.x = planPoint[0]
point.y = planPoint[1]
const screenPoint = point.matrixTransform(sceneMatrix)
const candidateIdSet = new Set(candidateIds)
if (typeof document !== 'undefined' && typeof document.elementsFromPoint === 'function') {
for (const element of document.elementsFromPoint(screenPoint.x, screenPoint.y)) {
const entry = element.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
const nodeId = entry?.dataset.nodeId
if (entry && nodeId && candidateIdSet.has(nodeId) && scene.contains(entry)) {
return nodeId
}
}
}
const hits = collectRegistrySelectionIdsInBounds(
scene,
{
minX: planPoint[0] - POINT_HIT_EPSILON,
maxX: planPoint[0] + POINT_HIT_EPSILON,
minY: planPoint[1] - POINT_HIT_EPSILON,
maxY: planPoint[1] + POINT_HIT_EPSILON,
},
candidateIds,
)
return hits.at(-1) ?? null
}
type FloorplanRoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type FloorplanStairSegmentEntry = {
polygon: Point2D[]
segment: StairSegmentNode | AnyNode
}
type FloorplanStairEntry = {
hitPolygons: Point2D[][]
stair: StairNode
segments: FloorplanStairSegmentEntry[]
}
type UseFloorplanHitTestingArgs = {
ceilingPolygons: CeilingPolygonEntry[]
columnPolygons: ColumnPolygonEntry[]
displaySlabPolygons: SlabPolygonEntry[]
displayWallPolygons: WallPolygonEntry[]
floorplanElevatorEntries: ElevatorPolygonEntry[]
floorplanItemEntries: FloorplanItemEntry[]
getFloorplanOpeningHitTolerance: () => number
floorplanRoofEntries: FloorplanRoofEntry[]
floorplanStairEntries: FloorplanStairEntry[]
getFloorplanWallHitTolerance: () => number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
isFloorplanItemContextActive: boolean
openingsPolygons: OpeningPolygonEntry[]
phase: 'site' | 'structure' | 'furnish'
toPoint2D: (point: WallPlanPoint) => Point2D
}
export function useFloorplanHitTesting({
ceilingPolygons,
columnPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanElevatorEntries,
floorplanItemEntries,
getFloorplanOpeningHitTolerance,
floorplanRoofEntries,
floorplanStairEntries,
getFloorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
}: UseFloorplanHitTestingArgs) {
export function useFloorplanHitTesting({ sceneRef }: FloorplanHitTestingOptions) {
const getFloorplanHitIdAtPoint = useCallback(
(planPoint: WallPlanPoint) => {
const point = toPoint2D(planPoint)
return getFloorplanHitNodeId({
point,
ceilings: ceilingPolygons,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
stairs: floorplanStairEntries,
elevators: floorplanElevatorEntries,
walls: displayWallPolygons,
slabs: displaySlabPolygons,
openingHitTolerance: getFloorplanOpeningHitTolerance(),
wallHitTolerance: getFloorplanWallHitTolerance(),
columns: columnPolygons,
getOpeningCenterLine,
})
const scene = sceneRef.current
return scene ? getRegistryHitIdAtPlanPoint(scene, planPoint) : null
},
[
ceilingPolygons,
columnPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanElevatorEntries,
floorplanRoofEntries,
floorplanStairEntries,
getFloorplanOpeningHitTolerance,
getFloorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
],
[sceneRef],
)
const getFloorplanSelectionIdsInBoundsForArea = useCallback(
(bounds: FloorplanSelectionBounds) =>
getFloorplanSelectionIdsInBounds({
bounds,
ceilings: ceilingPolygons,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
walls: displayWallPolygons,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
slabs: displaySlabPolygons,
columns: columnPolygons,
elevators: floorplanElevatorEntries,
stairs: floorplanStairEntries,
}),
[
ceilingPolygons,
columnPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanElevatorEntries,
floorplanRoofEntries,
floorplanStairEntries,
isFloorplanItemContextActive,
openingsPolygons,
phase,
],
const getFloorplanSelectionIdsInBounds = useCallback(
(bounds: FloorplanSelectionBounds) => {
const scene = sceneRef.current
return scene ? collectRegistrySelectionIdsInBounds(scene, bounds) : []
},
[sceneRef],
)
return {
getFloorplanHitIdAtPoint,
getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
getFloorplanSelectionIdsInBounds,
}
}
@@ -22,6 +22,7 @@ import { useState } from 'react'
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import useFloorplanMode from '../../../store/use-floorplan-mode'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
@@ -63,6 +64,7 @@ export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const floorplanMode = useFloorplanMode((state) => state.mode)
const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
const constructionDimensionChainMode = useEditor(
@@ -130,6 +132,10 @@ export function MeasurementControl() {
dimensionMode: ConstructionDimensionMode,
chainMode: ConstructionDimensionChainMode,
) => {
if (useFloorplanMode.getState().mode !== 'expert') {
useFloorplanMode.getState().showExpertModeNotice('Construction Dimension')
return
}
setPhase('structure')
setStructureLayer('elements')
setViewMode('2d')
@@ -218,38 +224,42 @@ export function MeasurementControl() {
)
})}
<div className="my-1.5 h-px bg-border/60" />
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Floor plan
</div>
{floorplanMode === 'expert' ? (
<>
<div className="my-1.5 h-px bg-border/60" />
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Floor plan
</div>
{constructionDimensionOptions.map((option) => {
const OptionIcon = option.icon
const isSelected =
isConstructionDimensionActive && activeConstructionDimensionOption === option
return (
<button
aria-checked={isSelected}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={`${option.mode}-${option.chainMode}`}
onClick={() => {
activateConstructionDimension(option.mode, option.chainMode)
setIsOpen(false)
}}
role="menuitemradio"
type="button"
>
<OptionIcon aria-hidden="true" className="h-4 w-4" />
<span>{option.label}</span>
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
</button>
)
})}
{constructionDimensionOptions.map((option) => {
const OptionIcon = option.icon
const isSelected =
isConstructionDimensionActive && activeConstructionDimensionOption === option
return (
<button
aria-checked={isSelected}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={`${option.mode}-${option.chainMode}`}
onClick={() => {
activateConstructionDimension(option.mode, option.chainMode)
setIsOpen(false)
}}
role="menuitemradio"
type="button"
>
<OptionIcon aria-hidden="true" className="h-4 w-4" />
<span>{option.label}</span>
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
</button>
)
})}
</>
) : null}
</div>
</PopoverContent>
</Popover>
@@ -25,6 +25,7 @@ import {
} from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch'
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
import useFloorplanMode from './../../../../../store/use-floorplan-mode'
import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
@@ -192,6 +193,7 @@ export function SettingsPanel({
const exportScene = useViewer((state) => state.exportScene)
const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase)
const floorplanMode = useFloorplanMode((state) => state.mode)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
const sceneGraphValue = useMemo(
@@ -399,7 +401,10 @@ export function SettingsPanel({
</div>
<div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">Floor plan</div>
<div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
<span>Floor plan</span>
<span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
</div>
<Button
className="w-full justify-start gap-2"
onClick={() => exportFloorplanPdf('full')}
+8 -5
View File
@@ -351,6 +351,7 @@ export {
type FloorplanRenderPurpose,
type FloorplanSchedule,
type FloorplanToolContext,
type FloorplanToolMode,
floorplanGeometryMetadata,
getFloorplanNodeExtension,
readFloorplanContext,
@@ -358,6 +359,12 @@ export {
readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata,
} from './lib/floorplan/floorplan-extension'
export {
DEFAULT_FLOORPLAN_MODE,
FLOORPLAN_MODES,
type FloorplanMode,
isFloorplanToolAvailableInMode,
} from './lib/floorplan/floorplan-mode'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export'
export {
@@ -517,11 +524,7 @@ export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export {
default as useFloorplanPreflight,
type FloorplanPreflightIssue,
type FloorplanPreflightIssueKind,
} from './store/use-floorplan-preflight'
export { default as useFloorplanMode } from './store/use-floorplan-mode'
export {
default as useInteractionScope,
getEditingHole,
@@ -5,7 +5,7 @@ import {
filterFloorplanAnnotationGeometry,
normalizeFloorplanAnnotationVisibility,
} from './annotation-visibility'
import { floorplanGeometryMetadata } from './floorplan-extension'
import { floorplanGeometryMetadata, withFloorplanGeometryMetadata } from './floorplan-extension'
describe('floor-plan annotation visibility', () => {
test('fills missing persisted categories with visible defaults', () => {
@@ -136,6 +136,84 @@ describe('floor-plan annotation visibility', () => {
).toBeNull()
})
test('keeps a contextual dimension when automatic dimensions are hidden', () => {
const contextualDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [2, 0],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.08,
text: '2m',
},
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(contextualDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
contextualDimensions: true,
}),
).toEqual(contextualDimension)
})
test('removes a nested automatic wall string beside a contextual dimension', () => {
const wallBody = {
kind: 'polygon',
points: [
[0, 0],
[0.2, 0],
[0.2, 6],
[0, 6],
],
} satisfies FloorplanGeometry
const automaticDimension = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [0, 18], text: '18m' }],
offsetNormal: [1, 0],
offsetDistance: 0.55,
extensionOvershoot: 0.12,
} satisfies FloorplanGeometry
const contextualDimension = withFloorplanGeometryMetadata(
{
kind: 'dimension',
start: [0, 0],
end: [0, 6],
offsetNormal: [1, 0],
offsetDistance: 0.34,
extensionOvershoot: 0.08,
text: '6m',
} satisfies FloorplanGeometry,
{ annotationRole: 'contextual-dimension' },
)
const geometry = {
kind: 'group',
children: [
{
kind: 'group',
children: [wallBody, automaticDimension],
},
contextualDimension,
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
contextualDimensions: true,
}),
).toEqual({
kind: 'group',
children: [{ kind: 'group', children: [wallBody] }, contextualDimension],
})
})
test('hides structural grids and only the center marks within column geometry', () => {
const centerMark = {
kind: 'line',
@@ -3,6 +3,7 @@ import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './f
export type FloorplanAnnotationCategory =
| 'automaticDimensions'
| 'contextualDimensions'
| 'manualDimensions'
| 'measurements'
| 'openingMarks'
@@ -14,6 +15,7 @@ export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory,
export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
automaticDimensions: true,
contextualDimensions: false,
manualDimensions: true,
measurements: true,
openingMarks: true,
@@ -22,6 +24,14 @@ export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibil
stairAnnotations: true,
}
export function revealFloorplanAnnotationRole(
visibility: FloorplanAnnotationVisibility,
role: FloorplanAnnotationRole,
): FloorplanAnnotationVisibility {
const category = annotationCategoryForRole(role)
return visibility[category] ? visibility : { ...visibility, [category]: true }
}
export function normalizeFloorplanAnnotationVisibility(
value: unknown,
): FloorplanAnnotationVisibility {
@@ -32,6 +42,10 @@ export function normalizeFloorplanAnnotationVisibility(
typeof persisted.automaticDimensions === 'boolean'
? persisted.automaticDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
contextualDimensions:
typeof persisted.contextualDimensions === 'boolean'
? persisted.contextualDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.contextualDimensions,
manualDimensions:
typeof persisted.manualDimensions === 'boolean'
? persisted.manualDimensions
@@ -68,7 +82,7 @@ export function filterFloorplanAnnotationGeometry(
if (role && !isAnnotationRoleVisible(role, visibility)) return null
if (
!visibility.automaticDimensions &&
role !== 'manual-dimension' &&
(role === undefined || role === 'automatic-dimension') &&
(geometry.kind === 'dimension' ||
geometry.kind === 'dimension-string' ||
geometry.kind === 'dimension-label' ||
@@ -82,7 +96,10 @@ export function filterFloorplanAnnotationGeometry(
.map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
.filter((child): child is FloorplanGeometry => child !== null)
if (children.length === 0) return null
if (children.length === geometry.children.length) return geometry
const childrenUnchanged =
children.length === geometry.children.length &&
children.every((child, index) => child === geometry.children[index])
if (childrenUnchanged) return geometry
return { ...geometry, children }
}
@@ -90,21 +107,27 @@ function isAnnotationRoleVisible(
role: FloorplanAnnotationRole,
visibility: FloorplanAnnotationVisibility,
): boolean {
return visibility[annotationCategoryForRole(role)]
}
function annotationCategoryForRole(role: FloorplanAnnotationRole): FloorplanAnnotationCategory {
switch (role) {
case 'automatic-dimension':
return visibility.automaticDimensions
return 'automaticDimensions'
case 'contextual-dimension':
return 'contextualDimensions'
case 'manual-dimension':
return visibility.manualDimensions
return 'manualDimensions'
case 'measurement':
return visibility.measurements
return 'measurements'
case 'opening-mark':
return visibility.openingMarks
return 'openingMarks'
case 'structural-grid':
case 'column-center':
return visibility.structuralGrids
return 'structuralGrids'
case 'room-label':
return visibility.roomLabels
return 'roomLabels'
case 'stair-annotation':
return visibility.stairAnnotations
return 'stairAnnotations'
}
}
@@ -1,20 +1,12 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import {
DrawingSheetNode,
type FloorplanGeometry,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
import {
filterFloorplanExportOverlay,
fitPlanToBox,
isFloorplanExportAnnotationGeometry,
partitionFloorplanExportOverlay,
pointsPerMeterForDrawingScale,
resolveDrawingSheetDocumentMarkers,
resolveDrawingSheetGeneralNotes,
resolveDrawingSheetKeyedNotes,
resolveFloorplanExportAnnotationVisibility,
resolveFloorplanExportNodeGeometry,
resolveFloorplanExportPlacement,
@@ -24,40 +16,31 @@ import {
resolveFloorplanMeasurementSize,
resolveFloorplanPageLayout,
resolveFloorplanScreenUnitsPerPixel,
resolveGraphicScaleLength,
resolveSheetComposition,
resolveSheetExportLayout,
resolveSheetPageSetup,
rotateFloorplanExportBounds,
} from './floorplan-export'
import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension'
const drawingSheetExtension: FloorplanNodeExtension<DrawingSheetNode> = {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
}
beforeAll(() => {
if (nodeRegistry.has('drawing-sheet')) return
registerNode({
kind: 'drawing-sheet',
schemaVersion: 1,
schema: DrawingSheetNode,
category: 'analysis',
defaults: () => ({}) as never,
capabilities: {},
extensions: {
'pascal:editor/floorplan': drawingSheetExtension,
},
})
})
import { floorplanGeometryMetadata } from './floorplan-extension'
describe('filterFloorplanExportOverlay', () => {
test('preserves annotation metadata while splitting geometry passes', () => {
const contextualDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [2, 0],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.08,
text: '2m',
},
],
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(contextualDimension).overlay).toMatchObject(contextualDimension)
})
test('preserves value labels and removes editing handles', () => {
const label = {
kind: 'dimension-label',
@@ -242,6 +225,7 @@ describe('floor plan export policy', () => {
test('exports the same annotation categories that are visible in the live view', () => {
const liveVisibility = {
automaticDimensions: true,
contextualDimensions: false,
manualDimensions: false,
measurements: true,
openingMarks: true,
@@ -250,7 +234,27 @@ describe('floor plan export policy', () => {
stairAnnotations: true,
}
expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility)
expect(resolveFloorplanExportAnnotationVisibility('expert', liveVisibility)).toEqual(
liveVisibility,
)
})
test('exports only model geometry and room labels in Default', () => {
expect(
resolveFloorplanExportAnnotationVisibility(
'default',
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
})
test('matches live screen sizing to the fitted export viewport', () => {
@@ -298,16 +302,6 @@ describe('floor plan export policy', () => {
})
})
describe('pointsPerMeterForDrawingScale', () => {
test('converts metric ratios to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4)
})
test('converts imperial architectural scales to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4)
})
})
describe('resolveFloorplanMeasurementSize', () => {
test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
expect(
@@ -316,276 +310,10 @@ describe('resolveFloorplanMeasurementSize', () => {
})
})
describe('resolveSheetExportLayout', () => {
test('reserves a plan viewport, side panel, and title block on one sheet page', () => {
expect(resolveSheetExportLayout(842, 595)).toEqual({
planBox: { x: 36, y: 36, width: 572, height: 463 },
sidePanel: { x: 626, y: 36, width: 180, height: 463 },
titleBlock: { x: 36, y: 517, width: 770, height: 42 },
})
})
})
describe('resolveFloorplanPageLayout', () => {
test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => {
test('uses the available A4 page area for the fitted plan', () => {
expect(resolveFloorplanPageLayout(842, 595)).toEqual({
planBox: { x: 36, y: 64, width: 770, height: 495 },
})
})
})
describe('resolveGraphicScaleLength', () => {
test('chooses a model length that fits the available paper width', () => {
const scale = resolveGraphicScaleLength('1:50', 150)
expect(scale.modelMeters).toBe(2)
expect(scale.widthPt).toBeCloseTo(113.39, 2)
expect(scale.label).toBe('2 m')
})
})
describe('resolveSheetComposition', () => {
test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
placedViews: [
{
id: 'drawing-view_main',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
scale: '1:50',
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
})
expect(
resolveSheetComposition(
{ [sheet.id]: sheet },
'level_main',
'Main Level',
'floor-plan',
'Floor plan',
'1/4"=1\'-0"',
),
).toMatchObject({
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
paperSize: 'arch-b',
orientation: 'landscape',
drawingNumber: '2',
viewTitle: 'Main Floor Plan',
scale: '1:50',
generalNotes: [{ number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
keyedNoteInstances: [],
})
})
test('resolves reusable general note sets before sheet-local notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([
{ number: 1, text: 'Coordinate with structural.' },
{ number: 2, text: 'Verify dimensions.' },
])
})
test('reports duplicate reusable and sheet-local general notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([
{
severity: 'warning',
message:
'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.',
},
])
})
test('derives keyed-note legends from repeated stable instances', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' },
{ id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-1',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'keyed-note-instance_patch-2',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [4, 3],
},
],
keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }],
})
expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({
legend: [{ key: 'A', text: 'Patch existing slab.' }],
instances: [
{ id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 },
{ id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 },
],
warnings: [],
})
})
test('reports keyed-note instances with missing definitions', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
keyedNoteInstances: [
{
id: 'keyed-note-instance_missing',
definitionId: 'keyed-note_missing',
position: [2, 3],
},
],
})
expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([
{
severity: 'warning',
message:
'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.',
},
])
})
test('resolves scoped drawing sheet document markers', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
{
id: 'sheet-marker_other-view',
kind: 'detail-reference',
label: '3',
placedViewId: 'drawing-view_other',
position: [5, 5],
},
],
})
expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: '',
x: 2,
y: 3,
endX: null,
endY: null,
points: [],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: 'A',
x: 0.5,
y: 0.5,
endX: null,
endY: null,
points: [
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 1, y: 2 },
],
},
])
})
})
describe('resolveSheetPageSetup', () => {
test('resolves supported paper sizes and orientation to page points', () => {
expect(
resolveSheetPageSetup({
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
}),
).toEqual({ width: 1296, height: 864, orientation: 'landscape' })
const a3 = resolveSheetPageSetup({
paperSize: 'a3',
orientation: 'portrait',
customPaperWidth: null,
customPaperHeight: null,
})
expect(a3.width).toBeCloseTo(841.89, 2)
expect(a3.height).toBeCloseTo(1190.55, 2)
})
test('uses custom paper dimensions in inches', () => {
expect(
resolveSheetPageSetup({
paperSize: 'custom',
orientation: 'portrait',
customPaperWidth: 24,
customPaperHeight: 36,
}),
).toEqual({ width: 1728, height: 2592, orientation: 'portrait' })
})
})
@@ -4,10 +4,6 @@ import {
type AnyNode,
type AnyNodeId,
type ConstructionDrawingType,
type DrawingSheetNode,
type DrawingSheetOrientation,
type DrawingSheetPaperSize,
type DrawingSheetScale,
type FloorplanGeometry,
type FloorplanPalette,
type FloorplanPoint,
@@ -33,6 +29,7 @@ import {
import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
import useEditor from '../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
import useFloorplanMode from '../../store/use-floorplan-mode'
import {
type FloorplanAnnotationVisibility,
filterFloorplanAnnotationGeometry,
@@ -41,9 +38,15 @@ import { resolveNodeForDrawingType } from './drawing-coordination'
import {
type FloorplanMetricNotation,
type FloorplanSchedule,
type FloorplanWallDimensionReference,
getFloorplanNodeExtension,
readFloorplanGeometryMetadata,
} from './floorplan-extension'
import {
type FloorplanMode,
resolveFloorplanAnnotationVisibility,
resolveFloorplanWallDimensionReference,
} from './floorplan-mode'
import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document'
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
@@ -73,11 +76,8 @@ const PLAN_PADDING_RATIO = 0.2
/** PDF page margin + title band, in pt. */
const PAGE_MARGIN_PT = 36
const TITLE_BAND_PT = 28
const SHEET_GAP_PT = 18
const SHEET_SIDE_PANEL_WIDTH_PT = 180
const TITLE_BLOCK_HEIGHT_PT = 42
const POINTS_PER_INCH = 72
const METERS_PER_INCH = 0.0254
const A4_LANDSCAPE_WIDTH_PT = (297 / 25.4) * 72
const A4_LANDSCAPE_HEIGHT_PT = (210 / 25.4) * 72
const NEUTRAL_PALETTE: FloorplanPalette = {
selectedStroke: '#334155',
@@ -111,8 +111,16 @@ const NEUTRAL_VIEW_STATE = {
export function resolveFloorplanExportViewState(
unit: 'metric' | 'imperial',
metricNotation: FloorplanMetricNotation,
wallDimensionReference?: FloorplanWallDimensionReference,
automaticDimensions = true,
) {
return { ...NEUTRAL_VIEW_STATE, unit, metricNotation }
return {
...NEUTRAL_VIEW_STATE,
automaticDimensions,
unit,
metricNotation,
wallDimensionReference,
}
}
type ExportLevel = { id: AnyNodeId; label: string }
@@ -123,83 +131,24 @@ type ExportGeometry = {
annotations: FloorplanGeometry | null
}
type SheetComposition = {
sheetNumber: string
sheetTitle: string
paperSize: DrawingSheetPaperSize
orientation: DrawingSheetOrientation
customPaperWidth: number | null
customPaperHeight: number | null
drawingNumber: string
viewTitle: string
drawingLabel: string
scale: DrawingSheetScale
generalNotes: { number: number; text: string }[]
keyedNoteLegend: { key: string; text: string }[]
keyedNoteInstances: { id: string; key: string; x: number; y: number }[]
documentMarkers: ResolvedDocumentMarker[]
preflightIssues: SheetPreflightIssue[]
}
export type SheetExportLayout = {
planBox: { x: number; y: number; width: number; height: number }
sidePanel: { x: number; y: number; width: number; height: number }
titleBlock: { x: number; y: number; width: number; height: number }
}
export type FloorplanPageLayout = {
planBox: { x: number; y: number; width: number; height: number }
}
type ScheduleDrawResult = {
drawnSchedules: number
overflowSchedules: FloorplanSchedule[]
}
export type SheetPageSetup = {
width: number
height: number
orientation: DrawingSheetOrientation
}
export type SheetPreflightIssue = {
severity: 'warning'
message: string
}
type ResolvedGeneralNotes = {
notes: { number: number; text: string }[]
duplicateWarnings: SheetPreflightIssue[]
}
type ResolvedKeyedNotes = {
legend: { key: string; text: string }[]
instances: { id: string; key: string; x: number; y: number }[]
warnings: SheetPreflightIssue[]
}
type ResolvedDocumentMarker = {
id: string
kind: string
label: string
title: string
sheetReference: string
drawingReference: string
revisionId: string
x: number
y: number
endX: number | null
endY: number | null
points: { x: number; y: number }[]
}
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
const nodes = useScene.getState().nodes
const viewer = useViewer.getState()
const unit = viewer.unit
const metricNotation = viewer.metricNotation
const floorplanMode = useFloorplanMode.getState().mode
const expertAnnotationState = useFloorplanAnnotationVisibility.getState()
const annotationVisibility = resolveFloorplanExportAnnotationVisibility(
useFloorplanAnnotationVisibility.getState().visibility,
floorplanMode,
expertAnnotationState.visibility,
)
const wallDimensionReference = resolveFloorplanWallDimensionReference(
floorplanMode,
expertAnnotationState.wallDimensionReference,
)
const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth
const drawingType = useDrawingView.getState().drawingType
@@ -212,15 +161,9 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
return
}
const defaultPageSetup = resolveSheetPageSetup({
paperSize: 'a4',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
})
const { doc, save } = await createFloorplanPdfDocument([
defaultPageSetup.width,
defaultPageSetup.height,
A4_LANDSCAPE_WIDTH_PT,
A4_LANDSCAPE_HEIGHT_PT,
])
const host = document.createElement('div')
@@ -239,17 +182,11 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
metricNotation,
annotationVisibility,
drawingType,
wallDimensionReference,
)
const schedules = collectFloorplanSchedules(nodes, level.id, unit)
if (geometries.length === 0 && schedules.length === 0) continue
const pageSetup = resolveSheetPageSetup({
paperSize: 'a4',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
})
const layout = resolveFloorplanPageLayout(pageSetup.width, pageSetup.height)
let scheduleOverflow: FloorplanSchedule[] = [...schedules]
const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
if (geometries.length > 0) {
// Preserve the live floor-plan orientation rather than forcing north-up.
@@ -266,7 +203,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
)
if (mounted) {
try {
doc.addPage([pageSetup.width, pageSetup.height], pageSetup.orientation)
doc.addPage([A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT], 'landscape')
pageCount++
const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel(
@@ -312,8 +249,8 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
}
}
if (scheduleOverflow.length > 0) {
pageCount = drawFloorplanSchedulePages(doc, level.label, scheduleOverflow, pageCount)
if (schedules.length > 0) {
pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
}
}
@@ -359,246 +296,6 @@ export function collectFloorplanSchedules(
return schedules
}
export function resolveSheetComposition(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
levelLabel: string,
drawingType: ConstructionDrawingType,
drawingLabel: string,
fallbackScale: DrawingSheetScale,
): SheetComposition {
const sheet = findDrawingSheetForLevel(nodes, levelId, drawingType)
const placedView = sheet?.placedViews.find(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
const generalNotes = sheet
? resolveDrawingSheetGeneralNotes(sheet)
: { notes: [], duplicateWarnings: [] }
const keyedNotes = sheet
? resolveDrawingSheetKeyedNotes(sheet, placedView?.id ?? null)
: { legend: [], instances: [], warnings: [] }
const documentMarkers = sheet
? resolveDrawingSheetDocumentMarkers(sheet, placedView?.id ?? null)
: []
return {
sheetNumber: sheet?.sheetNumber ?? 'A1.0',
sheetTitle: sheet?.sheetTitle ?? drawingLabel,
paperSize: sheet?.paperSize ?? 'a4',
orientation: sheet?.orientation ?? 'landscape',
customPaperWidth: sheet?.customPaperWidth ?? null,
customPaperHeight: sheet?.customPaperHeight ?? null,
drawingNumber: placedView?.drawingNumber ?? '1',
viewTitle: placedView?.title ?? `${levelLabel} ${drawingLabel}`,
drawingLabel,
scale: placedView?.scale ?? fallbackScale,
generalNotes: generalNotes.notes,
keyedNoteLegend: keyedNotes.legend,
keyedNoteInstances: keyedNotes.instances,
documentMarkers,
preflightIssues: [...generalNotes.duplicateWarnings, ...keyedNotes.warnings],
}
}
export function resolveDrawingSheetGeneralNotes(sheet: DrawingSheetNode): ResolvedGeneralNotes {
const generalNoteSets = sheet.generalNoteSets ?? []
const generalNoteSetIds = sheet.generalNoteSetIds ?? []
const sheetNotes = sheet.generalNotes ?? []
const selectedSetIds =
generalNoteSetIds.length > 0
? new Set(generalNoteSetIds)
: new Set(generalNoteSets.map((set) => set.id))
const noteSources = [
...generalNoteSets
.filter((set) => selectedSetIds.has(set.id))
.flatMap((set) => set.notes.map((note) => ({ text: note.text, source: set.name }))),
...sheetNotes.map((note) => ({ text: note.text, source: 'sheet' })),
]
const notes = noteSources.map((note, index) => ({ number: index + 1, text: note.text }))
const duplicateWarnings: SheetPreflightIssue[] = []
const seen = new Map<string, { text: string; count: number; sources: Set<string> }>()
for (const note of noteSources) {
const key = normalizeGeneralNoteText(note.text)
const existing = seen.get(key)
if (existing) {
existing.count += 1
existing.sources.add(note.source)
continue
}
seen.set(key, { text: note.text, count: 1, sources: new Set([note.source]) })
}
for (const duplicate of seen.values()) {
if (duplicate.count < 2) continue
duplicateWarnings.push({
severity: 'warning',
message: `Duplicate general note: "${duplicate.text}" appears in ${[
...duplicate.sources,
].join(' and ')}.`,
})
}
return { notes, duplicateWarnings }
}
function normalizeGeneralNoteText(text: string): string {
return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase()
}
export function resolveDrawingSheetKeyedNotes(
sheet: DrawingSheetNode,
placedViewId: string | null = null,
): ResolvedKeyedNotes {
const definitions = sheet.keyedNoteDefinitions ?? []
const instances = sheet.keyedNoteInstances ?? []
const definitionById = new Map(definitions.map((definition) => [definition.id, definition]))
const scopedInstances = instances.filter(
(instance) => instance.placedViewId === null || instance.placedViewId === placedViewId,
)
const warnings: SheetPreflightIssue[] = []
const usedDefinitions = new Map<string, { key: string; text: string }>()
const resolvedInstances: ResolvedKeyedNotes['instances'] = []
for (const instance of scopedInstances) {
const definition = definitionById.get(instance.definitionId)
if (!definition) {
warnings.push({
severity: 'warning',
message: `Keyed-note symbol ${instance.id} references missing definition ${instance.definitionId}.`,
})
continue
}
usedDefinitions.set(definition.id, { key: definition.key, text: definition.text })
resolvedInstances.push({
id: instance.id,
key: definition.key,
x: instance.position[0],
y: instance.position[1],
})
}
const derivedLegend = [...usedDefinitions.values()].sort((left, right) =>
left.key.localeCompare(right.key, undefined, { numeric: true }),
)
return {
legend: derivedLegend.length > 0 ? derivedLegend : (sheet.keyedNoteLegend ?? []),
instances: resolvedInstances,
warnings,
}
}
export function resolveDrawingSheetDocumentMarkers(
sheet: DrawingSheetNode,
placedViewId: string | null = null,
): ResolvedDocumentMarker[] {
return (sheet.documentMarkers ?? [])
.filter((marker) => marker.placedViewId === null || marker.placedViewId === placedViewId)
.map((marker) => ({
id: marker.id,
kind: marker.kind,
label: marker.label,
title: marker.title,
sheetReference: marker.sheetReference,
drawingReference: marker.drawingReference,
revisionId: marker.revisionId,
x: marker.position[0],
y: marker.position[1],
endX: marker.endPosition?.[0] ?? null,
endY: marker.endPosition?.[1] ?? null,
points: marker.points.map(([x, y]) => ({ x, y })),
}))
}
export function resolveSheetPageSetup(
sheet: Pick<
SheetComposition,
'paperSize' | 'orientation' | 'customPaperWidth' | 'customPaperHeight'
>,
): SheetPageSetup {
const base = paperSizePoints(sheet.paperSize, sheet.customPaperWidth, sheet.customPaperHeight)
const [width, height] =
sheet.orientation === 'landscape'
? [Math.max(base.width, base.height), Math.min(base.width, base.height)]
: [Math.min(base.width, base.height), Math.max(base.width, base.height)]
return { width, height, orientation: sheet.orientation }
}
function paperSizePoints(
paperSize: DrawingSheetPaperSize,
customPaperWidth: number | null,
customPaperHeight: number | null,
): { width: number; height: number } {
switch (paperSize) {
case 'letter':
return inchesToPoints(8.5, 11)
case 'tabloid':
return inchesToPoints(11, 17)
case 'arch-a':
return inchesToPoints(9, 12)
case 'arch-b':
return inchesToPoints(12, 18)
case 'arch-c':
return inchesToPoints(18, 24)
case 'a3':
return millimetersToPoints(297, 420)
case 'custom':
return inchesToPoints(customPaperWidth ?? 18, customPaperHeight ?? 12)
case 'a4':
return millimetersToPoints(210, 297)
}
}
function inchesToPoints(width: number, height: number): { width: number; height: number } {
return { width: width * POINTS_PER_INCH, height: height * POINTS_PER_INCH }
}
function millimetersToPoints(width: number, height: number): { width: number; height: number } {
return inchesToPoints(width / 25.4, height / 25.4)
}
function findDrawingSheetForLevel(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
drawingType: ConstructionDrawingType,
): DrawingSheetNode | null {
for (const node of Object.values(nodes)) {
const resolveDrawingSheet = getFloorplanNodeExtension(
nodeRegistry.get(node.type),
)?.resolveDrawingSheet
const sheet = resolveDrawingSheet?.({ node: node as never, levelId, drawingType })
if (sheet) return sheet
}
return null
}
export function resolveSheetExportLayout(pageWidth: number, pageHeight: number): SheetExportLayout {
const contentX = PAGE_MARGIN_PT
const contentY = PAGE_MARGIN_PT
const contentWidth = pageWidth - PAGE_MARGIN_PT * 2
const contentHeight = pageHeight - PAGE_MARGIN_PT * 2
const titleBlock = {
x: contentX,
y: contentY + contentHeight - TITLE_BLOCK_HEIGHT_PT,
width: contentWidth,
height: TITLE_BLOCK_HEIGHT_PT,
}
const upperHeight = contentHeight - TITLE_BLOCK_HEIGHT_PT - SHEET_GAP_PT
const sidePanel = {
x: contentX + contentWidth - SHEET_SIDE_PANEL_WIDTH_PT,
y: contentY,
width: SHEET_SIDE_PANEL_WIDTH_PT,
height: upperHeight,
}
return {
planBox: {
x: contentX,
y: contentY,
width: contentWidth - SHEET_SIDE_PANEL_WIDTH_PT - SHEET_GAP_PT,
height: upperHeight,
},
sidePanel,
titleBlock,
}
}
export function resolveFloorplanPageLayout(
pageWidth: number,
pageHeight: number,
@@ -748,398 +445,6 @@ function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: num
return `${truncated}...`
}
function drawSheetChrome(
doc: FloorplanPdfDocument,
layout: SheetExportLayout,
composition: SheetComposition,
schedules: readonly FloorplanSchedule[],
): ScheduleDrawResult {
doc.setDrawColor('#0f172a')
doc.setLineWidth(0.6)
doc.rect(
PAGE_MARGIN_PT,
PAGE_MARGIN_PT,
doc.internal.pageSize.getWidth() - PAGE_MARGIN_PT * 2,
doc.internal.pageSize.getHeight() - PAGE_MARGIN_PT * 2,
)
doc.setDrawColor('#cbd5e1')
doc.setLineWidth(0.4)
doc.rect(layout.planBox.x, layout.planBox.y, layout.planBox.width, layout.planBox.height)
doc.rect(layout.sidePanel.x, layout.sidePanel.y, layout.sidePanel.width, layout.sidePanel.height)
doc.rect(
layout.titleBlock.x,
layout.titleBlock.y,
layout.titleBlock.width,
layout.titleBlock.height,
)
drawSheetTitleBlock(doc, layout, composition)
drawNorthArrow(doc, layout.planBox.x + layout.planBox.width - 26, layout.planBox.y + 38)
drawGraphicScale(doc, layout.planBox.x + 18, layout.planBox.y + layout.planBox.height - 22, {
scale: composition.scale,
maxWidth: Math.min(150, layout.planBox.width * 0.3),
})
drawSheetDocumentMarkers(doc, composition)
drawKeyedNoteSymbols(doc, composition)
return drawSheetSidePanel(doc, layout.sidePanel, composition, schedules)
}
function drawSheetDocumentMarkers(doc: FloorplanPdfDocument, composition: SheetComposition): void {
if (composition.documentMarkers.length === 0) return
doc.setDrawColor('#111827')
doc.setTextColor('#111827')
doc.setLineWidth(0.7)
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
for (const marker of composition.documentMarkers) {
const x = marker.x * 72
const y = marker.y * 72
const end =
marker.endX !== null && marker.endY !== null
? { x: marker.endX * 72, y: marker.endY * 72 }
: null
switch (marker.kind) {
case 'wall-tag':
case 'glazing-tag':
case 'assembly-tag':
drawTagMarker(doc, marker, x, y)
break
case 'section-callout':
case 'elevation-callout':
case 'detail-reference':
drawCalloutMarker(doc, marker, x, y, end)
break
case 'delta-marker':
drawDeltaMarker(doc, marker, x, y)
break
case 'revision-cloud':
drawRevisionCloudMarker(doc, marker, x, y)
break
}
}
}
function drawTagMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const width = Math.max(20, marker.label.length * 5 + 10)
const height = 14
if (marker.kind === 'glazing-tag') {
doc.roundedRect(x - width / 2, y - height / 2, width, height, 2, 2)
} else if (marker.kind === 'assembly-tag') {
doc.rect(x - width / 2, y - height / 2, width, height)
} else {
doc.circle(x, y, Math.max(7, width / 2))
}
doc.text(marker.label, x, y + 2.4, { align: 'center' })
}
function drawCalloutMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
end: { x: number; y: number } | null,
) {
if (end) doc.line(x, y, end.x, end.y)
doc.circle(x, y, 8)
doc.line(x - 8, y, x + 8, y)
doc.text(marker.label, x, y - 1.8, { align: 'center' })
const reference = [marker.drawingReference, marker.sheetReference].filter(Boolean).join('/')
if (reference) {
doc.setFont('helvetica', 'normal')
doc.text(reference, x, y + 6, { align: 'center' })
doc.setFont('helvetica', 'bold')
}
}
function drawDeltaMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const radius = 8
const points = [
[x, y - radius],
[x + radius * 0.87, y + radius / 2],
[x - radius * 0.87, y + radius / 2],
] as const
doc.triangle(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1])
doc.text(marker.revisionId || marker.label, x, y + 3, { align: 'center' })
}
function drawRevisionCloudMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const points: [number, number][] | null =
marker.points.length >= 3 ? marker.points.map((point) => [point.x * 72, point.y * 72]) : null
if (points) {
for (let index = 0; index < points.length; index += 1) {
const current = points[index]!
const next = points[(index + 1) % points.length]!
const [x1, y1] = current
const [x2, y2] = next
doc.line(x1, y1, x2, y2)
}
} else {
doc.roundedRect(x - 28, y - 16, 56, 32, 8, 8)
}
if (marker.revisionId) drawDeltaMarker(doc, marker, x, y)
}
function drawKeyedNoteSymbols(doc: FloorplanPdfDocument, composition: SheetComposition): void {
if (composition.keyedNoteInstances.length === 0) return
doc.setDrawColor('#111827')
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
for (const instance of composition.keyedNoteInstances) {
const x = instance.x * 72
const y = instance.y * 72
doc.circle(x, y, 6)
doc.text(instance.key, x, y + 2.4, { align: 'center' })
}
}
function drawSheetTitleBlock(
doc: FloorplanPdfDocument,
layout: SheetExportLayout,
composition: SheetComposition,
) {
const title = layout.titleBlock
const sheetNumberWidth = 86
const drawingRefWidth = 72
doc.setDrawColor('#cbd5e1')
doc.line(
title.x + title.width - sheetNumberWidth,
title.y,
title.x + title.width - sheetNumberWidth,
title.y + title.height,
)
doc.line(
title.x + title.width - sheetNumberWidth - drawingRefWidth,
title.y,
title.x + title.width - sheetNumberWidth - drawingRefWidth,
title.y + title.height,
)
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.text(composition.viewTitle.toLocaleUpperCase(), title.x + 10, title.y + 16)
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.text(`Scale: ${formatDrawingScaleLabel(composition.scale)}`, title.x + 10, title.y + 30)
doc.text(
`Drawing: ${composition.drawingNumber}`,
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
title.y + 16,
)
doc.text(
composition.drawingLabel,
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
title.y + 30,
)
doc.setFont('helvetica', 'bold')
doc.setFontSize(15)
doc.text(composition.sheetNumber, title.x + title.width - sheetNumberWidth + 10, title.y + 25)
doc.setFontSize(7)
doc.text(
composition.sheetTitle.toLocaleUpperCase(),
title.x + title.width - sheetNumberWidth + 10,
title.y + 36,
{
maxWidth: sheetNumberWidth - 20,
},
)
}
function drawNorthArrow(doc: FloorplanPdfDocument, x: number, y: number) {
doc.setDrawColor('#111827')
doc.setFillColor('#111827')
doc.setLineWidth(0.6)
doc.triangle(x, y - 24, x - 6, y - 5, x + 6, y - 5, 'F')
doc.line(x, y - 5, x, y + 14)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.text('N', x, y - 28, { align: 'center' })
}
export function resolveGraphicScaleLength(
scale: DrawingSheetScale,
maxWidthPt: number,
): { modelMeters: number; widthPt: number; label: string } {
const pointsPerMeter = pointsPerMeterForDrawingScale(scale)
const maxMeters = Math.max(0.1, maxWidthPt / pointsPerMeter)
const candidates = [50, 20, 10, 5, 2, 1, 0.5, 0.25]
const modelMeters = candidates.find((candidate) => candidate <= maxMeters) ?? 0.1
return {
modelMeters,
widthPt: modelMeters * pointsPerMeter,
label: `${modelMeters >= 1 ? modelMeters : modelMeters * 1000}${modelMeters >= 1 ? ' m' : ' mm'}`,
}
}
function drawGraphicScale(
doc: FloorplanPdfDocument,
x: number,
y: number,
options: { scale: DrawingSheetScale; maxWidth: number },
) {
const resolved = resolveGraphicScaleLength(options.scale, options.maxWidth)
const half = resolved.widthPt / 2
doc.setDrawColor('#111827')
doc.setFillColor('#111827')
doc.setLineWidth(0.6)
doc.rect(x, y, half, 5, 'F')
doc.rect(x + half, y, half, 5)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.text('0', x, y + 15, { align: 'center' })
doc.text(resolved.label, x + resolved.widthPt, y + 15, { align: 'center' })
doc.text(formatDrawingScaleLabel(options.scale), x + resolved.widthPt / 2, y - 4, {
align: 'center',
})
}
function drawSheetSidePanel(
doc: FloorplanPdfDocument,
panel: SheetExportLayout['sidePanel'],
composition: SheetComposition,
schedules: readonly FloorplanSchedule[],
): ScheduleDrawResult {
let y = panel.y + 12
const left = panel.x + 8
const width = panel.width - 16
const bottom = panel.y + panel.height - 8
y = drawSheetNotes(doc, 'GENERAL NOTES', composition.generalNotes, left, y, width, bottom)
y = drawKeyedNoteLegend(doc, composition, left, y + 8, width, bottom)
return drawInlineSchedules(doc, schedules, left, y + 8, width, bottom)
}
function drawSheetNotes(
doc: FloorplanPdfDocument,
title: string,
notes: readonly { number: number; text: string }[],
x: number,
y: number,
width: number,
bottom: number,
) {
if (notes.length === 0) return y
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text(title, x, y)
y += 9
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
for (const note of notes) {
const lines = doc.splitTextToSize(`${note.number}. ${note.text}`, width)
if (y + lines.length * 8 > bottom) break
doc.text(lines, x, y)
y += lines.length * 8 + 3
}
return y
}
function drawKeyedNoteLegend(
doc: FloorplanPdfDocument,
composition: SheetComposition,
x: number,
y: number,
width: number,
bottom: number,
) {
if (composition.keyedNoteLegend.length === 0) return y
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text('KEYED NOTES', x, y)
y += 9
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
for (const note of composition.keyedNoteLegend) {
const lines = doc.splitTextToSize(`${note.key}. ${note.text}`, width)
if (y + lines.length * 8 > bottom) break
doc.text(lines, x, y)
y += lines.length * 8 + 3
}
return y
}
function drawInlineSchedules(
doc: FloorplanPdfDocument,
schedules: readonly FloorplanSchedule[],
x: number,
y: number,
width: number,
bottom: number,
): ScheduleDrawResult {
const overflowSchedules: FloorplanSchedule[] = []
let drawnSchedules = 0
for (const schedule of schedules) {
const rowHeight = 12
const tableHeight = 18 + rowHeight * Math.min(schedule.rows.length, 6)
if (y + tableHeight > bottom) {
overflowSchedules.push(schedule)
continue
}
drawnSchedules++
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text(schedule.title.toLocaleUpperCase(), x, y)
y += 10
const widths = scheduleColumnWidths(schedule, width)
doc.setFillColor('#334155')
doc.rect(x, y, width, rowHeight, 'F')
doc.setTextColor('#ffffff')
doc.setFontSize(6)
let colX = x
schedule.columns.forEach((column, index) => {
doc.text(column.label, colX + 2, y + 8, { maxWidth: Math.max(0, (widths[index] ?? 0) - 4) })
colX += widths[index] ?? 0
})
y += rowHeight
doc.setFont('helvetica', 'normal')
doc.setTextColor('#111827')
const inlineRows = schedule.rows.slice(0, 6)
for (const row of inlineRows) {
colX = x
schedule.columns.forEach((column, index) => {
const colWidth = widths[index] ?? 0
doc.text(
truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, colWidth - 4)),
colX + 2,
y + 8,
)
colX += colWidth
})
doc.setDrawColor('#cbd5e1')
doc.rect(x, y, width, rowHeight)
y += rowHeight
}
if (schedule.rows.length > inlineRows.length) {
overflowSchedules.push({
...schedule,
title: `${schedule.title} Continued`,
rows: schedule.rows.slice(inlineRows.length),
})
}
y += 12
}
return { drawnSchedules, overflowSchedules }
}
type MountedFloorplan = {
svg: SVGSVGElement
annotationLabelShifts: readonly FloorplanPoint[]
@@ -1241,9 +546,10 @@ export function resolveFloorplanScreenUnitsPerPixel(
}
export function resolveFloorplanExportAnnotationVisibility(
mode: FloorplanMode,
liveVisibility: FloorplanAnnotationVisibility,
): FloorplanAnnotationVisibility {
return { ...liveVisibility }
return resolveFloorplanAnnotationVisibility(mode, liveVisibility, { target: 'export' })
}
export function resolveFloorplanExportRotationDeg(
@@ -1257,39 +563,6 @@ export function resolveFloorplanExportRotationDeg(
return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI
}
export function pointsPerMeterForDrawingScale(scale: DrawingSheetScale): number {
if (scale.startsWith('1:')) {
const denominator = Number.parseFloat(scale.slice(2))
if (Number.isFinite(denominator) && denominator > 0) {
return POINTS_PER_INCH / METERS_PER_INCH / denominator
}
}
const imperial = scale.match(/^(.+)"=1'-0"$/)
if (imperial) {
const paperInchesPerFoot = parseImperialPaperInches(imperial[1] ?? '')
if (paperInchesPerFoot > 0) {
return (paperInchesPerFoot / 12) * (POINTS_PER_INCH / METERS_PER_INCH)
}
}
return pointsPerMeterForDrawingScale('1/4"=1\'-0"')
}
function parseImperialPaperInches(value: string): number {
const trimmed = value.trim()
if (trimmed.includes('/')) {
const [numerator, denominator] = trimmed.split('/').map((part) => Number.parseFloat(part))
return numerator && denominator ? numerator / denominator : 0
}
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : 0
}
function formatDrawingScaleLabel(scale: DrawingSheetScale): string {
return scale.replace('=', ' = ')
}
async function mountFloorplanSvg(
parent: HTMLElement,
geometries: ExportGeometry[],
@@ -1456,6 +729,7 @@ function collectFloorplanGeometry(
metricNotation: FloorplanMetricNotation,
annotationVisibility: FloorplanAnnotationVisibility,
drawingType: ConstructionDrawingType,
wallDimensionReference: FloorplanWallDimensionReference,
): ExportGeometry[] {
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
@@ -1521,7 +795,12 @@ function collectFloorplanGeometry(
const baseContext = buildContext(
node,
nodes,
resolveFloorplanExportViewState(unit, metricNotation),
resolveFloorplanExportViewState(
unit,
metricNotation,
wallDimensionReference,
annotationVisibility.automaticDimensions,
),
levelData,
)
const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
@@ -34,4 +34,10 @@ describe('floor-plan context extensions', () => {
expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
})
test('lets presentation code suppress automatic annotation construction', () => {
expect(readFloorplanContext(context()).automaticDimensions).toBe(true)
const extensions = createFloorplanContextExtensions({ automaticDimensions: false })
expect(readFloorplanContext(context(extensions)).automaticDimensions).toBe(false)
})
})
@@ -2,7 +2,6 @@ import type {
AnyNode,
AnyNodeId,
ConstructionDrawingType,
DrawingSheetNode,
FloorplanGeometry,
GeometryContext,
NodeDefinition,
@@ -16,10 +15,12 @@ export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
export type FloorplanRenderPurpose = 'edit' | 'document'
export type FloorplanMetricNotation = 'meters' | 'millimeters'
export type FloorplanToolMode = 'default' | 'expert'
export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
export type FloorplanAnnotationRole =
| 'automatic-dimension'
| 'contextual-dimension'
| 'manual-dimension'
| 'measurement'
| 'opening-mark'
@@ -56,15 +57,13 @@ export type FloorplanToolContext = {
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
availableModes?: readonly FloorplanToolMode[]
preferredView?: '2d' | '3d'
referencedSelectionAnnotationRole?: FloorplanAnnotationRole
contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
actionMenu?: {
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
}
resolveDrawingSheet?: (args: {
node: N
levelId: AnyNodeId
drawingType: ConstructionDrawingType
}) => DrawingSheetNode | null
schedule?: (args: {
siblings: ReadonlyArray<N>
nodes: Readonly<Record<string, AnyNode>>
@@ -82,9 +81,11 @@ export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
type FloorplanGeometryMetadata = {
annotationRole?: FloorplanAnnotationRole
annotationObstacle?: 'bounds' | 'outline'
renderPass?: 'overlay'
}
type FloorplanContextExtension = {
automaticDimensions: boolean
purpose: FloorplanRenderPurpose
metricNotation: FloorplanMetricNotation
wallDimensionReference: FloorplanWallDimensionReference
@@ -137,6 +138,7 @@ export function createFloorplanContextExtensions(
): Readonly<Record<string, unknown>> {
return {
[FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
automaticDimensions: values.automaticDimensions !== false,
purpose: values.purpose === 'document' ? 'document' : 'edit',
metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
@@ -151,6 +153,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
if (value && typeof value === 'object' && !Array.isArray(value)) {
const extension = value as Partial<FloorplanContextExtension>
return {
automaticDimensions: extension.automaticDimensions !== false,
purpose: extension.purpose === 'document' ? 'document' : 'edit',
metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference(
@@ -159,6 +162,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
}
}
return {
automaticDimensions: true,
purpose: 'edit',
metricNotation: 'meters',
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
import {
isFloorplanToolAvailableInMode,
normalizeFloorplanMode,
normalizeFloorplanModesByProject,
resolveFloorplanAnnotationVisibility,
resolveFloorplanWallDimensionReference,
} from './floorplan-mode'
describe('floor-plan mode', () => {
test('defaults unknown and missing values to Default', () => {
expect(normalizeFloorplanMode(undefined)).toBe('default')
expect(normalizeFloorplanMode('legacy')).toBe('default')
expect(normalizeFloorplanMode('expert')).toBe('expert')
})
test('keeps only valid per-project preferences', () => {
expect(
normalizeFloorplanModesByProject({
alpha: 'expert',
beta: 'default',
invalid: 'legacy',
empty: null,
}),
).toEqual({ alpha: 'expert', beta: 'default' })
})
test('limits tools only when their registry extension declares available modes', () => {
expect(isFloorplanToolAvailableInMode(undefined, 'default')).toBe(true)
expect(isFloorplanToolAvailableInMode(['expert'], 'default')).toBe(false)
expect(isFloorplanToolAvailableInMode(['expert'], 'expert')).toBe(true)
expect(isFloorplanToolAvailableInMode(['default', 'expert'], 'default')).toBe(true)
})
test('preserves the existing annotation profile in Expert', () => {
const expertVisibility = {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
openingMarks: false,
roomLabels: false,
}
expect(
resolveFloorplanAnnotationVisibility('expert', expertVisibility, {
selected: false,
target: 'editor',
}),
).toBe(expertVisibility)
})
test('keeps Default clean until an item is selected', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: false,
target: 'editor',
}),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: true,
target: 'editor',
}),
).toMatchObject({
automaticDimensions: false,
contextualDimensions: true,
manualDimensions: true,
measurements: true,
})
})
test('reveals a measurement when a referenced object is selected', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
referencedAnnotationRole: 'measurement',
target: 'editor',
}).measurements,
).toBe(true)
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
referencedAnnotationRole: 'manual-dimension',
target: 'editor',
}).manualDimensions,
).toBe(true)
})
test('exports Default without selection chrome or technical annotations', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: true,
target: 'export',
}),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
})
test('uses centerline dimensions only in Default', () => {
expect(resolveFloorplanWallDimensionReference('default', 'finished-faces')).toBe('centerline')
expect(resolveFloorplanWallDimensionReference('expert', 'finished-faces')).toBe(
'finished-faces',
)
})
})
@@ -0,0 +1,73 @@
import {
type FloorplanAnnotationVisibility,
revealFloorplanAnnotationRole,
} from './annotation-visibility'
import type {
FloorplanAnnotationRole,
FloorplanToolMode,
FloorplanWallDimensionReference,
} from './floorplan-extension'
export const FLOORPLAN_MODES = ['default', 'expert'] as const
export type FloorplanMode = (typeof FLOORPLAN_MODES)[number]
export const DEFAULT_FLOORPLAN_MODE: FloorplanMode = 'default'
export type FloorplanPresentationContext = {
referencedAnnotationRole?: FloorplanAnnotationRole
selected?: boolean
target: 'editor' | 'export'
}
export function normalizeFloorplanMode(value: unknown): FloorplanMode {
return value === 'expert' ? 'expert' : DEFAULT_FLOORPLAN_MODE
}
export function normalizeFloorplanModesByProject(value: unknown): Record<string, FloorplanMode> {
if (!value || typeof value !== 'object') return {}
const modes: Record<string, FloorplanMode> = {}
for (const [projectId, mode] of Object.entries(value)) {
if (!projectId || (mode !== 'default' && mode !== 'expert')) continue
modes[projectId] = mode
}
return modes
}
export function isFloorplanToolAvailableInMode(
availableModes: readonly FloorplanToolMode[] | undefined,
mode: FloorplanMode,
): boolean {
return availableModes === undefined || availableModes.includes(mode)
}
export function resolveFloorplanAnnotationVisibility(
mode: FloorplanMode,
expertVisibility: FloorplanAnnotationVisibility,
context: FloorplanPresentationContext,
): FloorplanAnnotationVisibility {
if (mode === 'expert') return expertVisibility
const interactive = context.target === 'editor'
const selected = interactive && context.selected === true
const visibility: FloorplanAnnotationVisibility = {
automaticDimensions: false,
contextualDimensions: selected,
manualDimensions: selected,
measurements: selected,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
}
return interactive && context.referencedAnnotationRole
? revealFloorplanAnnotationRole(visibility, context.referencedAnnotationRole)
: visibility
}
export function resolveFloorplanWallDimensionReference(
mode: FloorplanMode,
expertReference: FloorplanWallDimensionReference,
): FloorplanWallDimensionReference {
return mode === 'default' ? 'centerline' : expertReference
}
@@ -1,271 +0,0 @@
import type {
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import {
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
} from './geometry'
import type { FloorplanSelectionBounds } from './types'
type OpeningNode = WindowNode | DoorNode
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
}
type ItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type StairEntry = {
hitPolygons: Point2D[][]
stair: StairNode
segments: Array<{ polygon: Point2D[] }>
}
type WallEntry = {
wall: WallNode
polygon: Point2D[]
}
type SlabEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
}
type CeilingEntry = {
ceiling: CeilingNode
polygon: Point2D[]
holes: Point2D[][]
}
type ColumnEntry = {
column: ColumnNode
polygon: Point2D[]
}
type ElevatorEntry = {
elevator: ElevatorNode
polygon: Point2D[]
}
type RoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanSelectionToolContext = {
point: Point2D
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
openings: OpeningPolygonEntry[]
stairs: StairEntry[]
walls: WallEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
elevators: ElevatorEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
}
function getItemHitId(context: FloorplanSelectionToolContext) {
if (!context.isItemContextActive) {
return null
}
const itemHit = context.items.find(({ polygon }) => isPointInsidePolygon(context.point, polygon))
return itemHit?.item.id ?? null
}
function getStairHitPolygons(stair: StairEntry) {
return stair.hitPolygons.length > 0
? stair.hitPolygons
: stair.segments.map(({ polygon }) => polygon)
}
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
if (context.phase === 'structure') {
const openingHit = context.openings.find(({ polygon }) => {
if (isPointInsidePolygon(context.point, polygon)) {
return true
}
const centerLine = context.getOpeningCenterLine(polygon)
if (!centerLine) {
return false
}
return (
getDistanceToWallSegment(
context.point,
[centerLine.start.x, centerLine.start.y],
[centerLine.end.x, centerLine.end.y],
) <= context.openingHitTolerance
)
})
if (openingHit) {
return openingHit.opening.id
}
const stairHit = context.stairs.find((stair) =>
getStairHitPolygons(stair).some((polygon) => isPointInsidePolygon(context.point, polygon)),
)
if (stairHit) {
return stairHit.stair.id
}
const elevatorHit = context.elevators.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (elevatorHit) {
return elevatorHit.elevator.id
}
const columnHit = context.columns.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (columnHit) {
return columnHit.column.id
}
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
getDistanceToWallSegment(context.point, wall.start, wall.end) <= context.wallHitTolerance,
)
if (wallHit) {
return wallHit.wall.id
}
const roofHit = context.roofs.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
)
if (roofHit) {
return roofHit.roof.id
}
const ceilingHit = context.ceilings.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (ceilingHit) {
return ceilingHit.ceiling.id
}
const slabHit = context.slabs.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (slabHit) {
return slabHit.slab.id
}
}
return getItemHitId(context)
}
type FloorplanSelectionBoundsContext = {
bounds: FloorplanSelectionBounds
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
walls: WallEntry[]
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
elevators: ElevatorEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
export function getFloorplanSelectionIdsInBounds({
bounds,
phase,
isItemContextActive,
items,
walls,
openings,
slabs,
ceilings,
columns,
elevators,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
const itemIds = isItemContextActive
? items
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ item }) => item.id)
: []
if (phase !== 'structure') {
return itemIds
}
const wallIds = walls
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ wall }) => wall.id)
const openingIds = openings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ opening }) => opening.id)
const slabIds = slabs
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id)
const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id)
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
const elevatorIds = elevators
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ elevator }) => elevator.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
doesPolygonIntersectSelectionBounds(polygon, bounds),
),
)
.map(({ stair }) => stair.id)
const roofIds = roofs
.filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
)
.map(({ roof }) => roof.id)
return Array.from(
new Set([
...itemIds,
...wallIds,
...openingIds,
...slabIds,
...ceilingIds,
...columnIds,
...elevatorIds,
...stairIds,
...roofIds,
]),
)
}
@@ -1,18 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { normalizeAnnotationLayoutOverrides, normalizeDrawingType } from './use-drawing-view'
import useDrawingView, { normalizeAnnotationLayoutOverrides } from './use-drawing-view'
describe('normalizeDrawingType', () => {
test('restores every persistent construction drawing type', () => {
expect(normalizeDrawingType('floor-plan')).toBe('floor-plan')
expect(normalizeDrawingType('foundation-plan')).toBe('foundation-plan')
expect(normalizeDrawingType('reflected-ceiling-plan')).toBe('reflected-ceiling-plan')
expect(normalizeDrawingType('roof-plan')).toBe('roof-plan')
expect(normalizeDrawingType('site-plan')).toBe('site-plan')
})
test('falls back to the floor plan for stale persisted values', () => {
expect(normalizeDrawingType('unknown')).toBe('floor-plan')
expect(normalizeDrawingType(null)).toBe('floor-plan')
describe('drawing type', () => {
test('keeps the workspace on the floor plan', () => {
expect(useDrawingView.getState().drawingType).toBe('floor-plan')
expect('setDrawingType' in useDrawingView.getState()).toBe(false)
})
})
+2 -48
View File
@@ -1,10 +1,6 @@
'use client'
import {
CONSTRUCTION_DRAWING_TYPES,
type ConstructionDrawingType,
type DrawingSheetScale,
} from '@pascal-app/core'
import type { ConstructionDrawingType } from '@pascal-app/core'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
@@ -16,18 +12,6 @@ export const DRAWING_TYPE_OPTIONS = [
{ id: 'site-plan', label: 'Site plan' },
] as const satisfies readonly { id: ConstructionDrawingType; label: string }[]
export const DRAWING_SCALE_OPTIONS = [
{ id: '1:20', label: '1:20' },
{ id: '1:25', label: '1:25' },
{ id: '1:50', label: '1:50' },
{ id: '1:75', label: '1:75' },
{ id: '1:100', label: '1:100' },
{ id: '1/8"=1\'-0"', label: '1/8" = 1\'-0"' },
{ id: '1/4"=1\'-0"', label: '1/4" = 1\'-0"' },
{ id: '1/2"=1\'-0"', label: '1/2" = 1\'-0"' },
{ id: '1"=1\'-0"', label: '1" = 1\'-0"' },
] as const satisfies readonly { id: DrawingSheetScale; label: string }[]
export type DrawingAnnotationLayoutOverride = {
dx: number
dy: number
@@ -37,33 +21,14 @@ export type DrawingAnnotationLayoutOverride = {
export type DrawingAnnotationLayoutOverrides = Record<string, DrawingAnnotationLayoutOverride>
type DrawingViewState = {
drawingType: ConstructionDrawingType
drawingScale: DrawingSheetScale
drawingType: Extract<ConstructionDrawingType, 'floor-plan'>
annotationLayoutOverrides: DrawingAnnotationLayoutOverrides
setDrawingType: (drawingType: ConstructionDrawingType) => void
setDrawingScale: (drawingScale: DrawingSheetScale) => void
setAnnotationLayoutOverride: (
id: string,
override: DrawingAnnotationLayoutOverride | null,
) => void
}
export function normalizeDrawingType(value: unknown): ConstructionDrawingType {
if (typeof value !== 'string') return 'floor-plan'
for (const drawingType of CONSTRUCTION_DRAWING_TYPES) {
if (drawingType === value) return drawingType
}
return 'floor-plan'
}
export function normalizeDrawingScale(value: unknown): DrawingSheetScale {
if (typeof value !== 'string') return '1/4"=1\'-0"'
for (const option of DRAWING_SCALE_OPTIONS) {
if (option.id === value) return option.id
}
return '1/4"=1\'-0"'
}
export function normalizeAnnotationLayoutOverrides(
value: unknown,
): DrawingAnnotationLayoutOverrides {
@@ -91,10 +56,7 @@ const useDrawingView = create<DrawingViewState>()(
persist(
(set) => ({
drawingType: 'floor-plan',
drawingScale: '1/4"=1\'-0"',
annotationLayoutOverrides: {},
setDrawingType: (drawingType) => set({ drawingType }),
setDrawingScale: (drawingScale) => set({ drawingScale }),
setAnnotationLayoutOverride: (id, override) =>
set((state) => {
const next = { ...state.annotationLayoutOverrides }
@@ -107,20 +69,12 @@ const useDrawingView = create<DrawingViewState>()(
name: 'pascal-floorplan-drawing-view',
merge: (persistedState, currentState) => ({
...currentState,
drawingType: normalizeDrawingType(
(persistedState as { drawingType?: unknown } | undefined)?.drawingType,
),
drawingScale: normalizeDrawingScale(
(persistedState as { drawingScale?: unknown } | undefined)?.drawingScale,
),
annotationLayoutOverrides: normalizeAnnotationLayoutOverrides(
(persistedState as { annotationLayoutOverrides?: unknown } | undefined)
?.annotationLayoutOverrides,
),
}),
partialize: (state) => ({
drawingType: state.drawingType,
drawingScale: state.drawingScale,
annotationLayoutOverrides: state.annotationLayoutOverrides,
}),
},
@@ -0,0 +1,110 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
DEFAULT_FLOORPLAN_MODE,
type FloorplanMode,
normalizeFloorplanModesByProject,
} from '../lib/floorplan/floorplan-mode'
export type FloorplanModeNotice = {
id: number
kind: 'info' | 'switch-to-expert'
message: string
}
type FloorplanModeState = {
mode: FloorplanMode
projectId: string | null
modesByProject: Record<string, FloorplanMode>
hasShownDefaultReassurance: boolean
notice: FloorplanModeNotice | null
dismissNotice: () => void
setMode: (mode: FloorplanMode) => void
setProjectId: (projectId: string | null) => void
showExpertModeNotice: (toolLabel: string) => void
showNotice: (message: string) => void
}
let nextNoticeId = 0
const useFloorplanMode = create<FloorplanModeState>()(
persist(
(set) => ({
mode: DEFAULT_FLOORPLAN_MODE,
projectId: null,
modesByProject: {},
hasShownDefaultReassurance: false,
notice: null,
dismissNotice: () => set({ notice: null }),
setMode: (mode) =>
set((state) => {
const shouldReassure =
state.mode === 'expert' && mode === 'default' && !state.hasShownDefaultReassurance
return {
mode,
modesByProject: state.projectId
? { ...state.modesByProject, [state.projectId]: mode }
: state.modesByProject,
hasShownDefaultReassurance: state.hasShownDefaultReassurance || shouldReassure,
notice: shouldReassure
? {
id: ++nextNoticeId,
kind: 'info',
message:
'Default mode hides Expert annotations. Your saved work was not deleted.',
}
: state.notice,
}
}),
setProjectId: (projectId) =>
set((state) => ({
projectId,
mode: projectId
? (state.modesByProject[projectId] ?? DEFAULT_FLOORPLAN_MODE)
: DEFAULT_FLOORPLAN_MODE,
notice: null,
})),
showExpertModeNotice: (toolLabel) =>
set({
notice: {
id: ++nextNoticeId,
kind: 'switch-to-expert',
message: `${toolLabel} is available in Expert mode.`,
},
}),
showNotice: (message) =>
set({
notice: {
id: ++nextNoticeId,
kind: 'info',
message,
},
}),
}),
{
name: 'pascal-floorplan-mode-by-project',
merge: (persistedState, currentState) => {
const persisted = persistedState as
| { hasShownDefaultReassurance?: unknown; modesByProject?: unknown }
| undefined
const modesByProject = normalizeFloorplanModesByProject(persisted?.modesByProject)
return {
...currentState,
modesByProject,
hasShownDefaultReassurance: persisted?.hasShownDefaultReassurance === true,
mode: currentState.projectId
? (modesByProject[currentState.projectId] ?? DEFAULT_FLOORPLAN_MODE)
: DEFAULT_FLOORPLAN_MODE,
}
},
partialize: (state) => ({
hasShownDefaultReassurance: state.hasShownDefaultReassurance,
modesByProject: state.modesByProject,
}),
},
),
)
export default useFloorplanMode
@@ -1,49 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test'
import useFloorplanPreflight, { type FloorplanPreflightIssue } from './use-floorplan-preflight'
const COLLISION_ISSUE: FloorplanPreflightIssue = {
id: 'dimension-1',
kind: 'unresolved-collision',
severity: 'warning',
message: 'The same collision remains unresolved.',
}
afterEach(() => {
useFloorplanPreflight.getState().setIssues([])
useFloorplanPreflight.getState().setAuditIssues([])
})
describe('useFloorplanPreflight', () => {
test('does not notify subscribers when layout issues are unchanged', () => {
const state = useFloorplanPreflight.getState()
state.setIssues([COLLISION_ISSUE])
let notifications = 0
const unsubscribe = useFloorplanPreflight.subscribe(() => {
notifications += 1
})
useFloorplanPreflight.getState().setIssues([{ ...COLLISION_ISSUE }])
unsubscribe()
expect(notifications).toBe(0)
})
test('still publishes changed layout issues alongside audit issues', () => {
const state = useFloorplanPreflight.getState()
state.setAuditIssues([
{
id: 'audit-1',
kind: 'dimension-completeness',
severity: 'info',
message: 'Audit issue',
},
])
useFloorplanPreflight.getState().setIssues([COLLISION_ISSUE])
expect(useFloorplanPreflight.getState().issues).toEqual([
COLLISION_ISSUE,
expect.objectContaining({ id: 'audit-1' }),
])
})
})
@@ -1,79 +0,0 @@
'use client'
import { create } from 'zustand'
export type FloorplanPreflightIssueKind =
| 'unresolved-collision'
| 'short-unreadable-segment'
| 'plan-geometry-conflict'
| 'dimension-completeness'
| 'clearance-advisory'
| 'module-advisory'
| 'sheet-content'
export type FloorplanPreflightIssue = {
id: string
kind: FloorplanPreflightIssueKind
severity: 'info' | 'warning'
message: string
}
function preflightIssuesEqual(
left: readonly FloorplanPreflightIssue[],
right: readonly FloorplanPreflightIssue[],
): boolean {
if (left.length !== right.length) return false
return left.every((issue, index) => {
const candidate = right[index]
return (
candidate !== undefined &&
issue.id === candidate.id &&
issue.kind === candidate.kind &&
issue.severity === candidate.severity &&
issue.message === candidate.message
)
})
}
type FloorplanPreflightState = {
issues: FloorplanPreflightIssue[]
layoutIssues: FloorplanPreflightIssue[]
auditIssues: FloorplanPreflightIssue[]
clearanceChecksEnabled: boolean
moduleChecksEnabled: boolean
setIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setAuditIssues: (issues: readonly FloorplanPreflightIssue[]) => void
setClearanceChecksEnabled: (enabled: boolean) => void
setModuleChecksEnabled: (enabled: boolean) => void
reset: () => void
}
export const useFloorplanPreflight = create<FloorplanPreflightState>((set) => ({
issues: [],
layoutIssues: [],
auditIssues: [],
clearanceChecksEnabled: false,
moduleChecksEnabled: false,
setIssues: (issues) =>
set((state) =>
preflightIssuesEqual(state.layoutIssues, issues)
? state
: { layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] },
),
setAuditIssues: (issues) =>
set((state) =>
preflightIssuesEqual(state.auditIssues, issues)
? state
: { auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] },
),
setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }),
setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }),
reset: () =>
set((state) =>
state.layoutIssues.length === 0
? state
: { layoutIssues: [], issues: [...state.auditIssues] },
),
}))
export default useFloorplanPreflight
@@ -2,15 +2,15 @@ import { describe, expect, test } from 'bun:test'
import { buildingDefinition } from './definition'
describe('buildingDefinition', () => {
test('tracks drawing-sheet child support in the schema version', () => {
test('accepts level and elevator children', () => {
expect(buildingDefinition.kind).toBe('building')
expect(buildingDefinition.schemaVersion).toBe(2)
expect(buildingDefinition.schemaVersion).toBe(3)
expect(
buildingDefinition.schema.safeParse({
id: 'building_default',
type: 'building',
...buildingDefinition.defaults(),
children: ['level_main', 'drawing-sheet_a101'],
children: ['level_main', 'elevator_main'],
}).success,
).toBe(true)
})
+1 -1
View File
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
*/
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
kind: 'building',
schemaVersion: 2,
schemaVersion: 3,
schema: BuildingNode,
category: 'site',
@@ -2,8 +2,10 @@ import {
type AnyNodeId,
type ColumnNode,
type FloorplanAffordance,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { isAngleSnapActive } from '@pascal-app/editor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
@@ -59,9 +61,10 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
let lastPatch: Partial<ColumnNode> = {}
const commitPatch = (patch: Partial<ColumnNode>) => {
const previewPatch = (patch: Partial<ColumnNode>) => {
lastPatch = patch
useScene.getState().updateNode(columnId, patch)
useLiveNodeOverrides.getState().set(columnId, patch)
useScene.getState().markDirty(columnId)
}
return {
@@ -71,37 +74,37 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
const projDelta = currentProj - initialProj
switch (dim) {
case 'width':
commitPatch({
previewPatch({
width: Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta),
})
return
case 'depth':
commitPatch({
previewPatch({
depth: Math.max(MIN_COLUMN_DEPTH, initialDepth + 2 * projDelta),
})
return
case 'uniform': {
const next = Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta)
commitPatch({ width: next, depth: next })
previewPatch({ width: next, depth: next })
return
}
case 'radius':
commitPatch({
previewPatch({
radius: Math.max(MIN_COLUMN_RADIUS, initialRadius + projDelta),
})
return
case 'brace-width':
commitPatch({
previewPatch({
braceWidth: Math.max(MIN_BRACE_DIMENSION, initialBraceWidth + 2 * projDelta),
})
return
case 'brace-depth':
commitPatch({
previewPatch({
braceDepth: Math.max(MIN_BRACE_DIMENSION, initialBraceDepth + 2 * projDelta),
})
return
case 'brace-bottom-spread':
commitPatch({
previewPatch({
braceBottomSpread: Math.max(
MIN_BRACE_BOTTOM_SPREAD,
initialBraceBottomSpread + 2 * projDelta,
@@ -109,7 +112,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
})
return
case 'brace-top-spread':
commitPatch({
previewPatch({
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
})
return
@@ -120,6 +123,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
},
commit() {
if (Object.keys(lastPatch).length > 0) {
useLiveNodeOverrides.getState().clear(columnId)
useScene.getState().updateNode(columnId, lastPatch)
}
},
@@ -147,21 +151,23 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
return {
affectedIds: [columnId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
const newRotation = initialRotation - delta
lastRotation = newRotation
useScene.getState().updateNode(columnId, { rotation: newRotation })
useLiveNodeOverrides.getState().set(columnId, { rotation: newRotation })
useScene.getState().markDirty(columnId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(columnId)
useScene.getState().updateNode(columnId, { rotation: lastRotation })
},
}
@@ -18,6 +18,7 @@ export const constructionDimensionDefinition: NodeDefinition<typeof Construction
extensions: {
'pascal:editor/floorplan': {
tool: () => import('./floorplan-tool'),
availableModes: ['expert'],
resolveForDrawing: resolveConstructionDimensionForDrawing,
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
},
@@ -7,6 +7,7 @@ import {
normalizeConstructionDimensionChainMode,
normalizeConstructionDimensionMode,
resolveConstructionDimensionDraftDirection,
shouldConsumeConstructionDimensionPointerEvent,
} from './floorplan-tool'
describe('continuous construction-dimension drafting', () => {
@@ -21,6 +22,41 @@ describe('continuous construction-dimension drafting', () => {
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
})
test('keeps an endpoint-to-face dimension aligned with the same straight wall', () => {
const wall = WallNode.parse({
id: 'wall_diagonal',
start: [0, 0],
end: [4, 4],
thickness: 0.2,
})
const direction = resolveConstructionDimensionDraftDirection(
[
[4, 0, 4],
[0.9292893219, 0, 1.0707106781],
],
[
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:end' },
fallback: [4, 0, 4],
},
{
kind: 'feature',
reference: {
nodeId: wall.id,
featureId: 'wall:face:left',
parameters: { t: 0.25 },
},
fallback: [0.9292893219, 0, 1.0707106781],
},
],
{ [wall.id]: wall },
)
expect(direction?.[0]).toBeCloseTo(-Math.SQRT1_2)
expect(direction?.[1]).toBeCloseTo(-Math.SQRT1_2)
})
test('previews one adjacent dimension for every witness interval', () => {
const geometry = buildConstructionDimensionPreviewGeometries(
[
@@ -61,7 +97,7 @@ describe('continuous construction-dimension drafting', () => {
]
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
).toMatchObject({ text: 'R 2m' })
).toMatchObject({ text: 'R 1m' })
expect(
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
).toMatchObject({ text: 'Ø 2m' })
@@ -123,14 +159,14 @@ describe('continuous construction-dimension drafting', () => {
test('only requests a label baseline for modes that use one', () => {
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
expect(constructionDimensionUsesBaseline('radius')).toBe(false)
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
})
test('derives associative radius, chord, and center drafts from one curved wall', () => {
test('keeps radius manual while deriving chord and center drafts from one curved wall', () => {
const wall = WallNode.parse({
id: 'wall_curve',
start: [0, 0],
@@ -138,16 +174,7 @@ describe('continuous construction-dimension drafting', () => {
curveOffset: 1,
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
anchors: [
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
],
points: [
[2, 0, 1.5],
[2, 0, -1],
],
})
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
{ reference: { featureId: 'wall:start' } },
{ reference: { featureId: 'wall:end' } },
@@ -177,4 +204,14 @@ describe('continuous construction-dimension drafting', () => {
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
})
test('leaves middle-button drag moves available for floor-plan panning', () => {
expect(
shouldConsumeConstructionDimensionPointerEvent({
type: 'pointermove',
button: -1,
buttons: 4,
}),
).toBe(false)
})
})
@@ -35,7 +35,10 @@ import {
useInteractionScope,
} from '@pascal-app/editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { resolveCircularConstructionDimensionLayout } from './geometry'
import {
alignConstructionDimensionDirectionToSharedWall,
resolveCircularConstructionDimensionLayout,
} from './geometry'
const SEMANTIC_SNAP_DISTANCE = 0.2
const SEMANTIC_BYPASS_DISTANCE = 0.012
@@ -123,12 +126,18 @@ function registryTargetNodeId(target: EventTarget | null): string | null {
export function resolveConstructionDimensionDraftDirection(
points: readonly MeasurementPoint[],
anchors?: readonly MeasurementAnchor[],
nodes?: Readonly<Record<AnyNodeId, AnyNode>>,
): [number, number] | null {
if (points.length < 2) return null
const dx = points[1]![0] - points[0]![0]
const dz = points[1]![2] - points[0]![2]
const magnitude = Math.hypot(dx, dz)
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
if (magnitude <= MIN_DIMENSION_LENGTH) return null
const measuredDirection: [number, number] = [dx / magnitude, dz / magnitude]
return anchors && nodes
? alignConstructionDimensionDirectionToSharedWall(measuredDirection, anchors, (id) => nodes[id])
: measuredDirection
}
export function buildConstructionDimensionPreviewGeometries(
@@ -137,6 +146,7 @@ export function buildConstructionDimensionPreviewGeometries(
unit: 'metric' | 'imperial',
mode: ConstructionDimensionMode = 'linear',
metricNotation: 'meters' | 'millimeters' = 'meters',
directionOverride?: readonly [number, number] | null,
): FloorplanGeometry[] {
if (mode === 'arc-length' || mode === 'angular') {
const layout = resolveCircularConstructionDimensionLayout(mode, points)
@@ -278,7 +288,7 @@ export function buildConstructionDimensionPreviewGeometries(
]
}
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
const direction = resolveConstructionDimensionDraftDirection(points)
const direction = directionOverride ?? resolveConstructionDimensionDraftDirection(points)
if (!direction) return []
const normal: [number, number] = [-direction[1], direction[0]]
const project = (point: MeasurementPoint): [number, number] => {
@@ -292,7 +302,11 @@ export function buildConstructionDimensionPreviewGeometries(
const dx = end[0] - start[0]
const dz = end[2] - start[2]
const value = Math.abs(dx * direction[0] + dz * direction[1])
const rawText = formatLinearMeasurement(value, unit, metricNotation)
const rawText = formatLinearMeasurement(
mode === 'radius' ? value / 2 : value,
unit,
metricNotation,
)
const text =
mode === 'radius'
? `R ${rawText}`
@@ -337,7 +351,16 @@ export function normalizeConstructionDimensionMode(value: unknown): Construction
}
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
return ['linear', 'chord', 'arc-length', 'angular'].includes(mode)
}
export function shouldConsumeConstructionDimensionPointerEvent(event: {
type: string
button: number
buttons: number
}): boolean {
if (event.type === 'pointerdown') return event.button === 0
return (event.buttons & 0b110) === 0
}
function wallFeatureAnchor(
@@ -368,7 +391,6 @@ export function buildCurvedWallConstructionDimensionDraft(
wallFeatureAnchor(wall, featureId, fallback)
switch (mode) {
case 'radius':
case 'center-mark':
return {
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
@@ -461,7 +483,11 @@ export function FloorplanConstructionDimensionToolLayer({
)
}
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
const direction = resolveConstructionDimensionDraftDirection(current.points)
const direction = resolveConstructionDimensionDraftDirection(
current.points,
current.anchors,
sceneApi.nodes(),
)
const originPoint = baselinePoint ?? current.points.at(-1)
if (!(direction && originPoint)) return false
const node = ConstructionDimensionNode.parse({
@@ -510,10 +536,10 @@ export function FloorplanConstructionDimensionToolLayer({
if (current.stage === 'baseline') commitDraft(current, associated.point)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button === 0) consume(event)
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
}
const onPointerMove = (event: PointerEvent) => {
consume(event)
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
setHover(resolveEvent(event))
}
const onPointerLeave = () => {
@@ -633,19 +659,31 @@ export function FloorplanConstructionDimensionToolLayer({
usesBaseline,
])
const preview = useMemo(
() =>
draft.stage === 'baseline' && hover
? buildConstructionDimensionPreviewGeometries(
draft.points,
hover.point,
unit,
dimensionMode,
metricNotation,
)
: [],
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
)
const preview = useMemo(() => {
if (draft.stage !== 'baseline' || !hover) return []
const direction = resolveConstructionDimensionDraftDirection(
draft.points,
draft.anchors,
sceneApi.nodes(),
)
return buildConstructionDimensionPreviewGeometries(
draft.points,
hover.point,
unit,
dimensionMode,
metricNotation,
direction,
)
}, [
dimensionMode,
draft.anchors,
draft.points,
draft.stage,
hover,
metricNotation,
sceneApi,
unit,
])
const witnessDraftPoints =
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
@@ -4,6 +4,7 @@ import {
ConstructionDimensionNode,
type FloorplanGeometry,
type GeometryContext,
LevelNode,
nodeRegistry,
registerNode,
WallNode,
@@ -134,47 +135,92 @@ describe('buildConstructionDimensionFloorplan', () => {
})
})
test('resolves wall anchors against the selected assembly datum', () => {
test('straightens an existing endpoint-to-face baseline onto its referenced wall', () => {
const wall = WallNode.parse({
id: 'wall_assembly',
id: 'wall_existing_diagonal',
start: [0, 0],
end: [4, 0],
assemblyLayers: [
end: [4, 4],
thickness: 0.2,
})
const node = ConstructionDimensionNode.parse({
anchors: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.1,
datumEligible: ['structural-face'],
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:end' },
fallback: [4, 0, 4],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.03,
datumEligible: ['finish-face'],
kind: 'feature',
reference: {
nodeId: wall.id,
featureId: 'wall:face:left',
parameters: { t: 0.25 },
},
fallback: [0.9292893219, 0, 1.0707106781],
},
],
baseline: {
origin: [5, 5],
direction: [-0.7235724834, -0.6902484349],
},
datumPolicy: 'structural-face',
})
const anchor = {
kind: 'feature' as const,
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0] as [number, number, number],
}
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
buildConstructionDimensionFloorplan(
ConstructionDimensionNode.parse({
anchors: [anchor, [3, 0, 0]],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy,
}),
context({ [wall.id]: wall }),
)
const segment = dimensionSegments(
buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall })),
)[0]
const dx = (segment?.dimensionEnd?.[0] ?? 0) - (segment?.dimensionStart?.[0] ?? 0)
const dy = (segment?.dimensionEnd?.[1] ?? 0) - (segment?.dimensionStart?.[1] ?? 0)
const length = Math.hypot(dx, dy)
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
expect(dx / length).toBeCloseTo(-Math.SQRT1_2)
expect(dy / length).toBeCloseTo(-Math.SQRT1_2)
})
test('extends a wall-face dimension to the connected wall edge', () => {
const measuredWall = WallNode.parse({
id: 'wall_measured',
parentId: 'level_main',
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
const connectedWall = WallNode.parse({
id: 'wall_connected',
parentId: 'level_main',
start: [4, 0],
end: [4, 3],
thickness: 0.2,
})
const level = LevelNode.parse({
id: 'level_main',
children: [measuredWall.id, connectedWall.id],
})
const node = ConstructionDimensionNode.parse({
anchors: [
{
kind: 'feature',
reference: { nodeId: measuredWall.id, featureId: 'wall:start' },
fallback: [0, 0, 0],
},
{
kind: 'feature',
reference: { nodeId: measuredWall.id, featureId: 'wall:end' },
fallback: [4, 0, 0],
},
],
baseline: { origin: [0, 1], direction: [1, 0] },
datumPolicy: 'wall-face',
})
const geometry = buildConstructionDimensionFloorplan(
node,
context({
[level.id]: level,
[measuredWall.id]: measuredWall,
[connectedWall.id]: connectedWall,
}),
)
expect(dimensionSegments(geometry)[0]?.end).toEqual([4.1, 0.1])
})
test('uses millimetre notation in document output', () => {
@@ -349,59 +395,23 @@ describe('buildConstructionDimensionFloorplan', () => {
).toHaveLength(0)
})
test('renders radius notation with a leader and center mark', () => {
test('renders radius like diameter while showing half the picked span', () => {
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
[0, 0, 0],
[2, 0, 0],
],
baseline: { origin: [3, 1], direction: [1, 0] },
})
const geometry = buildConstructionDimensionFloorplan(node, context())
const entries = geometry ? flatten(geometry) : []
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
text: 'R 2m',
cx: 3,
cy: 1,
})
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
})
test('updates an associative curved-wall radius when the host curve changes', () => {
const wall = WallNode.parse({
id: 'wall_curve',
expect(dimensionSegments(geometry)[0]).toMatchObject({
text: 'R 1m',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
end: [2, 0],
})
const node = ConstructionDimensionNode.parse({
mode: 'radius',
anchors: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
fallback: [2, 0, 1.5],
},
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
fallback: [2, 0, -1],
},
],
baseline: { origin: [2, -1.5], direction: [0, -1] },
})
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
const originalLabel =
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
const reshapedLabel =
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
})
test('renders diameter and repeated-feature notation', () => {
@@ -1,4 +1,5 @@
import type {
AnyNode,
AnyNodeId,
ConstructionDimensionNode,
FloorplanGeometry,
@@ -11,10 +12,9 @@ import type {
} from '@pascal-app/core'
import {
constructionDimensionRequiredAnchorCount,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallArcData,
getWallCurveFrameAt,
resolveWallAssemblyDatumReferences,
getWallThickness,
} from '@pascal-app/core'
import {
readFloorplanContext,
@@ -29,6 +29,7 @@ import {
} from '../shared/construction-length'
import { buildDimensionStringGeometry } from '../shared/dimension-string'
import {
alignConstructionDimensionDirectionToSharedWall,
resolveCircularConstructionDimensionLayout,
resolveConstructionDimensionLayout,
} from './geometry'
@@ -73,11 +74,23 @@ export function buildConstructionDimensionFloorplan(
switch (node.mode) {
case 'linear':
case 'chord':
case 'chord': {
const alignedNode = {
...displayNode,
baseline: {
...displayNode.baseline,
direction: alignConstructionDimensionDirectionToSharedWall(
displayNode.baseline.direction,
displayNode.anchors,
(id) => ctx.resolve(id),
),
},
}
return withFloorplanGeometryMetadata(
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
buildLinearOrChord(alignedNode, points, stroke, dangling, unit, profile, editable),
{ annotationRole: 'manual-dimension' },
)
}
case 'radius':
return withFloorplanGeometryMetadata(
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
@@ -127,13 +140,19 @@ function resolveDimensionAnchor(
const frame = getWallCurveFrameAt(referenced, t)
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
const endpointExtension = wallEndpointDatumExtension(
referenced,
anchor.reference.featureId,
node.datumPolicy,
ctx,
)
return {
...resolved,
point: [
frame.point.x + frame.normal.x * offset,
frame.point.x + frame.normal.x * offset + frame.tangent.x * endpointExtension,
resolved.point[1],
frame.point.y + frame.normal.y * offset,
frame.point.y + frame.normal.y * offset + frame.tangent.y * endpointExtension,
],
}
}
@@ -184,17 +203,61 @@ function wallDatumOffset(
side: 1 | -1,
): number {
if (policy === 'centerline') return 0
if (policy === 'wall-face') {
const faces = getWallAssemblyFaceOffsets(wall)
return side > 0 ? faces.exterior : faces.interior
return (getWallThickness(wall) / 2) * side
}
function wallEndpointDatumExtension(
wall: WallNode,
featureId: string,
policy: ConstructionDimensionNode['datumPolicy'],
ctx: GeometryContext,
): number {
if (policy === 'centerline' || (featureId !== 'wall:start' && featureId !== 'wall:end')) {
return 0
}
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
const candidates = resolveWallAssemblyDatumReferences(wall)
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
.map((reference) => reference.offset)
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
const parent = wall.parentId ? ctx.resolve<AnyNode>(wall.parentId as AnyNodeId) : undefined
const childIds =
parent && 'children' in parent && Array.isArray(parent.children)
? (parent.children as AnyNodeId[])
: []
const endpoint = featureId === 'wall:start' ? wall.start : wall.end
const frame = getWallCurveFrameAt(wall, featureId === 'wall:start' ? 0 : 1)
const projections = [0]
for (const childId of childIds) {
const candidate = ctx.resolve<WallNode>(childId)
if (
candidate?.type !== 'wall' ||
candidate.id === wall.id ||
getWallArcData(candidate) ||
(!pointsCoincide(endpoint, candidate.start) && !pointsCoincide(endpoint, candidate.end))
) {
continue
}
const dx = candidate.end[0] - candidate.start[0]
const dz = candidate.end[1] - candidate.start[1]
const length = Math.hypot(dx, dz)
if (length <= EPSILON) continue
const normal: FloorplanPoint = [-dz / length, dx / length]
for (const side of [-1, 1] as const) {
const candidateOffset = wallDatumOffset(candidate, policy, side)
projections.push(
normal[0] * candidateOffset * frame.tangent.x +
normal[1] * candidateOffset * frame.tangent.y,
)
}
}
return featureId === 'wall:start' ? Math.min(...projections) : Math.max(...projections)
}
function pointsCoincide(
left: readonly [number, number],
right: readonly [number, number],
): boolean {
return Math.hypot(left[0] - right[0], left[1] - right[1]) <= 0.03
}
function buildLinearOrChord(
@@ -252,23 +315,29 @@ function buildRadius(
editable: boolean,
): FloorplanGeometry | null {
const layout = resolveCircularConstructionDimensionLayout('radius', points)
if (!layout) return null
const labelPoint: FloorplanPoint = node.baseline.origin
if (!layout?.end) return null
const direction = normalized(layout.start, layout.end)
if (!direction) return null
const normal: FloorplanPoint = [-direction[1], direction[0]]
const children: FloorplanGeometry[] = [
styledPolyline([layout.center, layout.start, labelPoint], stroke),
...openArrow(layout.start, layout.center, stroke),
labelGeometry(
labelPoint,
dimensionGeometry(
node,
layout.start,
layout.end,
layout.start,
layout.end,
normal,
notation(
node,
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
dangling,
),
angle(layout.start, labelPoint),
stroke,
),
hitLine(layout.start, layout.end),
]
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
if (editable) children.push(...anchorHandles(points))
return dimensionGroup(children)
}
@@ -531,10 +600,6 @@ function styledLine(
}
}
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
}
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
return {
fill: 'none',
@@ -651,10 +716,6 @@ function distance(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.hypot(second[0] - first[0], second[1] - first[1])
}
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
return Math.atan2(second[1] - first[1], second[0] - first[0])
}
function formatDegrees(value: number): string {
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
}
@@ -1,8 +1,12 @@
import type {
ConstructionDimensionMode,
ConstructionDimensionNode,
FloorplanPoint,
MeasurementPoint,
import {
type AnyNode,
type AnyNodeId,
type ConstructionDimensionMode,
type ConstructionDimensionNode,
type FloorplanPoint,
getWallArcData,
type MeasurementAnchor,
type MeasurementPoint,
} from '@pascal-app/core'
export type ConstructionDimensionSegmentLayout = {
@@ -24,6 +28,55 @@ export type ConstructionDimensionLayout = {
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
export function alignConstructionDimensionDirectionToSharedWall(
direction: readonly [number, number],
anchors: readonly MeasurementAnchor[],
resolve: (id: AnyNodeId) => AnyNode | undefined,
): [number, number] {
const firstAnchor = anchors[0]
const secondAnchor = anchors[1]
if (
!firstAnchor ||
!secondAnchor ||
Array.isArray(firstAnchor) ||
Array.isArray(secondAnchor) ||
firstAnchor.reference.nodeId !== secondAnchor.reference.nodeId
) {
return [direction[0], direction[1]]
}
const wall = resolve(firstAnchor.reference.nodeId as AnyNodeId)
if (
wall?.type !== 'wall' ||
getWallArcData(wall) ||
!supportsStraightWallDirection(firstAnchor.reference.featureId) ||
!supportsStraightWallDirection(secondAnchor.reference.featureId)
) {
return [direction[0], direction[1]]
}
const wallDx = wall.end[0] - wall.start[0]
const wallDz = wall.end[1] - wall.start[1]
const wallLength = Math.hypot(wallDx, wallDz)
if (wallLength <= 1e-9) return [direction[0], direction[1]]
const wallDirection: [number, number] = [wallDx / wallLength, wallDz / wallLength]
return direction[0] * wallDirection[0] + direction[1] * wallDirection[1] < 0
? [-wallDirection[0], -wallDirection[1]]
: wallDirection
}
function supportsStraightWallDirection(featureId: string): boolean {
return [
'wall:start',
'wall:end',
'wall:centerline',
'wall:midpoint',
'wall:face:left',
'wall:face:right',
'wall:top-centerline',
].includes(featureId)
}
export type CircularConstructionDimensionLayout = {
center: FloorplanPoint
start: FloorplanPoint
@@ -44,7 +97,7 @@ export function resolveCircularConstructionDimensionLayout(
const first = project(anchors[0]!)
const second = project(anchors[1]!)
if (mode === 'diameter') {
if (mode === 'diameter' || mode === 'radius') {
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
const radius = distance(first, second) / 2
if (radius <= 1e-9) return null
@@ -0,0 +1,12 @@
import type { DoorNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { buildWallHostedOpeningContextualDimensions } from '../wall/contextual-dimensions'
export function buildDoorContextualDimensions(
node: DoorNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
return buildWallHostedOpeningContextualDimensions(node, ctx, {
showClearancesWhileMoving: false,
useExteriorNormal: false,
})
}
+2
View File
@@ -16,6 +16,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { buildDoorContextualDimensions } from './contextual-dimensions'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
@@ -174,6 +175,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
contextualDimensions: buildDoorContextualDimensions,
schedule: buildDoorFloorplanSchedule,
} satisfies FloorplanNodeExtension<DoorNodeType>,
},
@@ -3,6 +3,7 @@ import {
type DoorNode,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -23,11 +24,8 @@ type DoorWidthPayload = { side: 'start' | 'end' }
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
* edge stays fixed.
*
* Uses the scene-write preview pattern (writes directly to `useScene`
* each tick): the registry layer's `effectiveNode` only merges live
* overrides for walls, so an override-based preview wouldn't show on
* doors. The dispatcher snapshots / pauses history at start, so per-tick
* scene writes still collapse to one undoable entry on commit.
* Preview state stays in the live override store so the scene graph is
* written only once, when the drag commits.
*/
export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
@@ -86,18 +84,11 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
const newDoorX = anchorX + growDir * (newWidth / 2)
lastWidth = newWidth
lastDoorX = newDoorX
// Scene-write preview so the 2D plan + 3D viewer both pick up
// the change immediately. The dispatcher paused history at
// session start, so per-tick writes don't pollute undo.
useScene.getState().updateNodes([
{
id: doorId,
data: {
width: newWidth,
position: [newDoorX, initialDoorY, initialDoorZ],
},
},
])
useLiveNodeOverrides.getState().set(doorId, {
width: newWidth,
position: [newDoorX, initialDoorY, initialDoorZ],
})
useScene.getState().markDirty(doorId)
},
canCommit() {
// Width is always clamped to >= MIN_DOOR_WIDTH inside apply, so
@@ -110,6 +101,7 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
// fields that differ from the pre-drag snapshot — if the user
// drags back to the original size by accident, the diff is empty
// and the door would otherwise revert to its starting state).
useLiveNodeOverrides.getState().clear(doorId)
useScene.getState().updateNodes([
{
id: doorId,
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test'
import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core'
import { buildDoorFloorplan } from './floorplan'
const wall = WallNode.parse({
id: 'wall_door-plan',
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
function buildDoor(values: Partial<DoorNode> = {}): FloorplanGeometry[] {
const door = DoorNode.parse({
id: 'door_plan',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 1,
...values,
})
const geometry = buildDoorFloorplan(door, {
children: [],
parent: wall,
resolve: () => undefined,
siblings: [],
} as GeometryContext)
expect(geometry?.kind).toBe('group')
return geometry?.kind === 'group' ? geometry.children : []
}
describe('buildDoorFloorplan documentation symbols', () => {
test('shows hinge, strike, panic hardware, and an arched overhead line', () => {
const geometry = buildDoor({
doorType: 'hinged',
openingShape: 'arch',
archHeight: 0.45,
panicBar: true,
})
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(2)
expect(
geometry.some(
(item) =>
item.kind === 'line' && item.strokeLinecap === 'square' && item.strokeWidth === 2.2,
),
).toBe(true)
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
true,
)
})
test('documents a rounded frameless opening without swing hardware', () => {
const geometry = buildDoor({
openingKind: 'opening',
openingShape: 'rounded',
cornerRadius: 0.2,
panicBar: true,
})
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(0)
expect(geometry.some((item) => item.kind === 'line' && item.strokeLinecap === 'square')).toBe(
false,
)
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
true,
)
})
})
+71 -6
View File
@@ -5,7 +5,11 @@ import type {
GeometryContext,
WallNode,
} from '@pascal-app/core'
import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor'
import {
readFloorplanContext,
readFloorplanGeometryMetadata,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import {
buildOpeningMarkAnnotation,
type OpeningFloorplanLevelData,
@@ -41,10 +45,9 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
* mounted on). Returns null when the parent isn't a wall (orphaned
* doors during placement etc.).
*
* Skipped vs the full legacy for now: hinge / strike cubes (small
* indicator squares at the rotation pivots), rounded-opening shape
* variants, panic bar markers. Those are rare visual variations the
* follow-up port can revisit.
* Swing leaves include hinge / strike indicators and panic hardware
* when present. Rounded and arched heads are shown as dashed overhead
* lines because their geometry sits above the horizontal plan cut.
*/
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
@@ -123,6 +126,27 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
},
]
if (node.openingShape !== 'rectangle') {
const overheadRise =
Math.min(width, node.openingShape === 'arch' ? node.archHeight : node.cornerRadius) * 0.2
const overheadSide = swingDirection === 'inward' ? 1 : -1
const startX = cx - dirX * halfWidth
const startZ = cz - dirZ * halfWidth
const endX = cx + dirX * halfWidth
const endZ = cz + dirZ * halfWidth
children.push({
kind: 'path',
d: `M ${startX} ${startZ} Q ${cx + perpX * overheadRise * overheadSide} ${cz + perpZ * overheadRise * overheadSide} ${endX} ${endZ}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.8,
strokeDasharray: '4 3',
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
// solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
// closed-leaf vector (hinge → strike, whose length is the swing
@@ -192,6 +216,47 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
const markerSize = Math.min(0.05, Math.max(0.025, radius * 0.05))
const markerHalf = markerSize / 2
const hardwareMarkers: Array<[number, number]> = [
[hX, hZ],
[closedTipX, closedTipZ],
]
for (const [markerX, markerZ] of hardwareMarkers) {
children.push({
kind: 'rect',
x: markerX - markerHalf,
y: markerZ - markerHalf,
width: markerSize,
height: markerSize,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
vectorEffect: 'non-scaling-stroke',
})
}
if (node.panicBar) {
const leafX = (tipX - hX) / radius
const leafZ = (tipZ - hZ) / radius
const barCenterX = hX + leafX * radius * 0.7
const barCenterZ = hZ + leafZ * radius * 0.7
const barHalfLength = Math.min(0.12, Math.max(0.06, depth * 0.75))
const barX = -leafZ * barHalfLength
const barZ = leafX * barHalfLength
children.push({
kind: 'line',
x1: barCenterX - barX,
y1: barCenterZ - barZ,
x2: barCenterX + barX,
y2: barCenterZ + barZ,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.6 : 2.2,
strokeLinecap: 'square',
vectorEffect: 'non-scaling-stroke',
})
}
}
const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
@@ -721,7 +786,7 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
// Placement-measurement dimensions — distances to adjacent openings
// (or wall ends) on each side. Only visible while actively moving
// (the user clicked Move or grabbed the orange dot).
if (view?.moving) {
if (view?.moving && readFloorplanContext(ctx).automaticDimensions) {
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
children.push(dim)
}
@@ -1,52 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
import { drawingSheetDefinition } from './definition'
describe('drawingSheetDefinition', () => {
test('registers persistent drawing sheets as non-geometric document nodes', () => {
expect(drawingSheetDefinition.kind).toBe('drawing-sheet')
expect(drawingSheetDefinition.bake).toBe('strip')
expect(drawingSheetDefinition.schemaVersion).toBe(4)
expect(drawingSheetDefinition.dirtyTracking).toBe(false)
expect(drawingSheetDefinition.capabilities).toMatchObject({
deletable: true,
duplicable: true,
presettable: false,
})
})
test('produces schema-valid defaults', () => {
expect(
drawingSheetDefinition.schema.safeParse({
id: 'drawing-sheet_default',
type: 'drawing-sheet',
...drawingSheetDefinition.defaults(),
}).success,
).toBe(true)
})
test('contributes drawing-sheet matching through the editor extension', () => {
const sheet = drawingSheetDefinition.schema.parse({
id: 'drawing-sheet_a101',
placedViews: [
{
id: 'drawing-view_floor',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '1',
title: 'Main floor',
scale: '1:50',
},
],
})
const resolveDrawingSheet =
getFloorplanNodeExtension(drawingSheetDefinition)?.resolveDrawingSheet
expect(
resolveDrawingSheet?.({ node: sheet, levelId: 'level_main', drawingType: 'floor-plan' }),
).toBe(sheet)
expect(
resolveDrawingSheet?.({ node: sheet, levelId: 'level_upper', drawingType: 'floor-plan' }),
).toBeNull()
})
})
@@ -1,51 +0,0 @@
import { DrawingSheetNode as DrawingSheetNodeSchema, type NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { DrawingSheetNode } from './schema'
export const drawingSheetDefinition: NodeDefinition<typeof DrawingSheetNode> = {
kind: 'drawing-sheet',
bake: 'strip',
schemaVersion: 4,
schema: DrawingSheetNode,
category: 'analysis',
extensions: {
'pascal:editor/floorplan': {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
} satisfies FloorplanNodeExtension<DrawingSheetNodeSchema>,
},
defaults: () => {
const stub = DrawingSheetNodeSchema.parse({
id: 'drawing-sheet_default' as never,
type: 'drawing-sheet',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
deletable: true,
duplicable: true,
presettable: false,
},
dirtyTracking: false,
presentation: {
label: 'Drawing Sheet',
description: 'A persistent construction-document sheet with placed views and title-block data.',
icon: { kind: 'iconify', name: 'lucide:file-text' },
hidden: true,
},
mcp: {
description:
'A persistent construction-document sheet containing paper setup, placed drawing views, notes, schedules, and title-block metadata.',
},
}
@@ -1 +0,0 @@
export { drawingSheetDefinition } from './definition'
@@ -1,18 +0,0 @@
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
} from '@pascal-app/core'
@@ -2,8 +2,10 @@ import {
type AnyNodeId,
type ElevatorNode,
type FloorplanAffordance,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { isAngleSnapActive } from '@pascal-app/editor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
const MIN_ELEVATOR_DIM = 0.6
@@ -15,9 +17,7 @@ type ElevatorResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
* `linear-resize` handles declared in `definition.ts` — `anchor: 'center'`
* means dragging outward on either +X or -X edge grows `width` by 2×
* the elevator-local cursor offset while `position` stays put. Same for
* +Z / -Z and `depth`. Writes directly to scene each tick (door pattern);
* the registry dispatcher snapshots / pauses history at start so the
* per-tick writes collapse into one undoable entry on commit.
* +Z / -Z and `depth`.
*/
export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
start({ node, payload, initialPlanPoint }) {
@@ -49,14 +49,16 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
const delta = (currentLocal - initialLocal) * side
const newValue = Math.max(MIN_ELEVATOR_DIM, initialValue + 2 * delta)
lastValue = newValue
useScene
useLiveNodeOverrides
.getState()
.updateNode(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
.set(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
useScene.getState().markDirty(elevatorId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(elevatorId)
useScene
.getState()
.updateNode(elevatorId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
@@ -69,9 +71,7 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
* Elevator rotation drag (floor-plan). Sister to the 3D `arc-resize`
* handle declared in `definition.ts`. Same `- delta` sign convention as
* the 3D path so dragging the cursor in the same direction in both views
* produces the same rotation. Writes directly to scene during the drag;
* the registry dispatcher captures a snapshot first and re-applies the
* single tracked update on pointer-up.
* produces the same rotation.
*/
export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
start({ node, initialPlanPoint }) {
@@ -84,21 +84,23 @@ export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
return {
affectedIds: [elevatorId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
const newRotation = initialRotation - delta
lastRotation = newRotation
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
useLiveNodeOverrides.getState().set(elevatorId, { rotation: newRotation })
useScene.getState().markDirty(elevatorId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(elevatorId)
useScene.getState().updateNode(elevatorId, { rotation: lastRotation })
},
}
@@ -266,6 +266,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
let lastPatches = new Map<AnyNodeId, Partial<FenceNode>>()
let lastStart = originalStart
let lastEnd = originalEnd
return {
affectedIds,
@@ -314,39 +317,63 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end,
}))
useScene.getState().updateNodes([
{ id: node.id, data: { start: nextStart, end: nextEnd } },
...linkedUpdates.map((u) => ({
id: u.id,
data: { start: u.start, end: u.end },
})),
lastStart = nextStart
lastEnd = nextEnd
const nextPatches = new Map<AnyNodeId, Partial<FenceNode>>([
[node.id, { start: nextStart, end: nextEnd }],
...linkedUpdates.map(
(update) =>
[update.id, { start: update.start, end: update.end }] as [
AnyNodeId,
Partial<FenceNode>,
],
),
])
// Re-elect the slab lift host as the endpoint drags (uncapped max
// election — 2D has no camera ray). This legacy write path commits
// via the dispatcher's snapshot diff, so patching per tick both
// previews the lift and lands it in the committed diff. Fences run
// no per-frame election: `supportSlabId` IS the lift.
const patchedNodes = useScene.getState().nodes
const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => {
// election — 2D has no camera ray). Fences run no per-frame
// election: `supportSlabId` IS the lift.
const patchedNodes = { ...sceneNodes }
for (const [id, patch] of nextPatches) {
patchedNodes[id] = { ...patchedNodes[id], ...patch } as AnyNode
}
for (const id of nextPatches.keys()) {
const fence = patchedNodes[id]
if (fence?.type !== 'fence') return []
if (fence?.type !== 'fence') continue
const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes)
return patch.supportSlabId === (fence as FenceNode).supportSlabId
? []
: [{ id, data: patch }]
})
if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches)
if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) {
nextPatches.set(id, { ...nextPatches.get(id), ...patch })
}
}
const overrides = useLiveNodeOverrides.getState()
const scene = useScene.getState()
for (const linked of linkedOriginals) {
if (!nextPatches.has(linked.id)) {
overrides.clear(linked.id)
scene.markDirty(linked.id)
}
}
for (const [id, patch] of nextPatches) {
overrides.set(id, patch)
scene.markDirty(id)
}
lastPatches = nextPatches
},
canCommit() {
// Pointer-up always runs canCommit — drop the alignment guide here
// so it doesn't linger after a commit / reject.
useAlignmentGuides.getState().clear()
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
return (
!!finalFence &&
finalFence.type === 'fence' &&
isSegmentLongEnough(finalFence.start, finalFence.end)
return isSegmentLongEnough(lastStart, lastEnd)
},
commit() {
useScene.getState().updateNodes(
Array.from(lastPatches, ([id, data]) => ({
id,
data,
})),
)
const overrides = useLiveNodeOverrides.getState()
for (const id of affectedIds) overrides.clear(id)
},
}
},
-1
View File
@@ -155,7 +155,6 @@ function toMiterWall(segment: SegmentLike): WallNode {
visible: true,
metadata: {},
children: [],
assemblyLayers: [],
start: segment.start,
end: segment.end,
thickness: segment.thickness,
-32
View File
@@ -10,7 +10,6 @@ import { cupolaDefinition } from './cupola'
import { doorDefinition } from './door'
import { dormerDefinition } from './dormer'
import { downspoutDefinition } from './downspout'
import { drawingSheetDefinition } from './drawing-sheet'
import { ductFittingDefinition } from './duct-fitting'
import { ductSegmentDefinition } from './duct-segment'
import { ductTerminalDefinition } from './duct-terminal'
@@ -94,7 +93,6 @@ export const builtinPlugin: Plugin = {
scanDefinition as unknown as AnyNodeDefinition,
measurementDefinition as unknown as AnyNodeDefinition,
constructionDimensionDefinition as unknown as AnyNodeDefinition,
drawingSheetDefinition as unknown as AnyNodeDefinition,
structuralGridDefinition as unknown as AnyNodeDefinition,
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
boxVentDefinition as unknown as AnyNodeDefinition,
@@ -141,7 +139,6 @@ export { cupolaDefinition } from './cupola'
export { doorDefinition } from './door'
export { dormerDefinition } from './dormer'
export { downspoutDefinition } from './downspout'
export { drawingSheetDefinition } from './drawing-sheet'
export { ductFittingDefinition } from './duct-fitting'
export { ductSegmentDefinition } from './duct-segment'
export { ductTerminalDefinition } from './duct-terminal'
@@ -163,35 +160,6 @@ export { ridgeVentDefinition } from './ridge-vent'
export { roofDefinition } from './roof'
export { roofSegmentDefinition } from './roof-segment'
export { scanDefinition } from './scan'
export {
type BuildClearanceAdvisoriesOptions,
buildClearanceAdvisories,
type ClearanceAdvisory,
type ClearanceAdvisoryCategory,
type ClearanceAdvisorySeverity,
type ClearanceEvidence,
type ClearanceProfile,
type ClearanceRule,
type ClearanceRuleSource,
DEFAULT_CLEARANCE_PROFILES,
} from './shared/clearance-advisories'
export {
type BuildConstructionModuleAdvisoriesOptions,
buildConstructionModuleAdvisories,
type ConstructionModuleAdvisory,
type ConstructionModuleAdvisorySeverity,
type ConstructionModuleMeasurementKind,
type ConstructionModuleProfile,
type ConstructionModuleSystem,
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
} from './shared/construction-module-advisories'
export {
type BuildDimensionCompletenessAuditOptions,
buildDimensionCompletenessAudit,
type DimensionCompletenessIssue,
type DimensionCompletenessIssueKind,
type DimensionCompletenessIssueSeverity,
} from './shared/dimension-completeness-audit'
export { shelfDefinition } from './shelf'
export { siteDefinition } from './site'
export { skylightDefinition } from './skylight'
+7 -1
View File
@@ -5,7 +5,8 @@ import {
type ItemNode as ItemNodeType,
type NodeDefinition,
} from '@pascal-app/core'
import { buildItemFloorplan } from './floorplan'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { buildItemContextualDimensions, buildItemFloorplan } from './floorplan'
import { itemFloorplanMoveTarget } from './floorplan-move'
import { itemPaint } from './paint'
import { itemParametrics } from './parametrics'
@@ -172,6 +173,11 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
schema: ItemNode,
category: 'furnish',
surfaceRole: 'furnishing',
extensions: {
'pascal:editor/floorplan': {
contextualDimensions: buildItemContextualDimensions,
} satisfies FloorplanNodeExtension<ItemNodeType>,
},
// Defaults shape is cast: the schema requires a fully-typed `asset`
// field, but in practice items are always created from the catalog
+47 -42
View File
@@ -11,6 +11,7 @@ import {
movingFootprintAnchors,
type RoofSegmentNode,
roofFacePointToSegment,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
@@ -203,6 +204,7 @@ function buildWallItemSession(
original: resolveItemPlanPoint(node, useScene.getState().nodes),
metadata: node.metadata,
})
let lastPatch: Partial<ItemNode> | null = null
return {
affectedIds: [node.id as AnyNodeId],
@@ -234,25 +236,24 @@ function buildWallItemSession(
const halfW = width / 2
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: [clampedX, startLocalY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
// Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
},
},
])
lastPatch = {
position: [clampedX, startLocalY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
roofSegmentId: undefined,
roofFace: undefined,
}
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
useScene.getState().markDirty(node.id as AnyNodeId)
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
return !!live && live.type === 'item' && !!live.parentId
return !!lastPatch?.parentId
},
commit() {
if (!lastPatch) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
},
}
}
@@ -278,6 +279,7 @@ function buildFloorItemSession(
const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node)
// Alignment candidates gathered once — scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
let lastPatch: Partial<ItemNode> | null = null
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint }) {
@@ -300,22 +302,23 @@ function buildFloorItemSession(
const sourceY = node.position[1]
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: nextPosition,
// Keep parent as the level we resolved at session-start. If
// somehow it's null (e.g. orphaned item), fall back to the
// existing parent so we don't write `null` and detach.
parentId: startLevelId ?? node.parentId,
},
},
])
lastPatch = {
position: nextPosition,
// Keep parent as the level we resolved at session-start. If
// somehow it's null (e.g. orphaned item), fall back to the
// existing parent so we don't write `null` and detach.
parentId: startLevelId ?? node.parentId,
}
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
useScene.getState().markDirty(node.id as AnyNodeId)
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
return !!live && live.type === 'item'
return lastPatch !== null
},
commit() {
if (!lastPatch) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
},
}
}
@@ -337,6 +340,7 @@ function buildSurfaceItemSession(
resolveItemPlanPoint(node, useScene.getState().nodes),
node,
)
let lastPatch: Partial<ItemNode> | null = null
return {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint }) {
@@ -348,19 +352,20 @@ function buildSurfaceItemSession(
const sourceY = node.position[1]
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: {
position: nextPosition,
parentId: surface ? surface.id : node.parentId,
},
},
])
lastPatch = {
position: nextPosition,
parentId: surface ? surface.id : node.parentId,
}
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
useScene.getState().markDirty(node.id as AnyNodeId)
},
canCommit() {
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
return !!live && live.type === 'item'
return lastPatch !== null
},
commit() {
if (!lastPatch) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
},
}
}
+53
View File
@@ -11,6 +11,7 @@ import {
roofFacePointToSegment,
useLiveTransforms,
} from '@pascal-app/core'
import { formatLinearMeasurement, readFloorplanMetricNotationOverride } from '@pascal-app/editor'
/**
* Stage C floor-plan builder for item.
@@ -153,6 +154,58 @@ function resolveItemTransform(
return result
}
export function buildItemContextualDimensions(
node: ItemNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const transform = resolveItemTransform(node, ctx)
if (!transform) return null
const [width, , depth] = getScaledDimensions(node)
if (width <= 1e-6 || depth <= 1e-6) return null
const centerLocalZ = node.asset.attachTo === 'wall-side' ? depth / 2 : 0
const [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
const cx = transform.x + centerOffsetX
const cy = transform.y + centerOffsetY
const halfWidth = width / 2
const halfDepth = depth / 2
const point = (x: number, y: number): FloorplanPoint => {
const [rx, ry] = rotateVec(x, y, transform.rotation)
return [cx + rx, cy + ry]
}
const widthNormal = rotateVec(0, -1, transform.rotation)
const depthNormal = rotateVec(1, 0, transform.rotation)
const unit = ctx.viewState?.unit ?? 'metric'
const metricNotation = readFloorplanMetricNotationOverride(ctx) ?? 'meters'
const stroke = ctx.viewState?.palette?.selectedStroke ?? '#2563eb'
return {
kind: 'group',
children: [
{
kind: 'dimension',
start: point(-halfWidth, -halfDepth),
end: point(halfWidth, -halfDepth),
offsetNormal: widthNormal,
offsetDistance: 0.28,
extensionOvershoot: 0.08,
text: formatLinearMeasurement(width, unit, metricNotation),
stroke,
},
{
kind: 'dimension',
start: point(halfWidth, -halfDepth),
end: point(halfWidth, halfDepth),
offsetNormal: depthNormal,
offsetDistance: 0.28,
extensionOvershoot: 0.08,
text: formatLinearMeasurement(depth, unit, metricNotation),
stroke,
},
],
}
}
export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): FloorplanGeometry | null {
const transform = resolveItemTransform(node, ctx)
if (!transform) return null
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
import { measurementDefinition } from './definition'
describe('measurementDefinition', () => {
@@ -21,6 +22,9 @@ describe('measurementDefinition', () => {
)
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
expect(measurementDefinition.parametrics).toBeUndefined()
expect(
getFloorplanNodeExtension(measurementDefinition)?.referencedSelectionAnnotationRole,
).toBe('measurement')
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
'Left click',
'Enter',
@@ -1,4 +1,5 @@
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { buildMeasurementFloorplan } from './floorplan'
import { measurementMoveVertexAffordance } from './floorplan-affordance'
import { MeasurementNode } from './schema'
@@ -40,6 +41,11 @@ export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
},
floorplan: buildMeasurementFloorplan,
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
extensions: {
'pascal:editor/floorplan': {
referencedSelectionAnnotationRole: 'measurement',
} satisfies FloorplanNodeExtension,
},
floorplanAffordances: {
'move-measurement-vertex': measurementMoveVertexAffordance,
},
@@ -5,9 +5,10 @@ import {
type RoofNode,
type RoofSegmentNode,
snapScalar,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
@@ -91,14 +92,16 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
lastValue = newValue
useScene
useLiveNodeOverrides
.getState()
.updateNode(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
.set(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
useScene.getState().markDirty(segmentId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(segmentId)
useScene
.getState()
.updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
@@ -125,20 +128,22 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
return {
affectedIds: [segmentId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
lastRotation = initialRotation - delta
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
useLiveNodeOverrides.getState().set(segmentId, { rotation: lastRotation })
useScene.getState().markDirty(segmentId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(segmentId)
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
},
}
@@ -186,12 +191,14 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
let localX = dx * cosRoof + dz * sinRoof
let localZ = -dx * sinRoof + dz * cosRoof
lastLocal = [localX, initialY, localZ]
useScene.getState().updateNode(segmentId, { position: lastLocal })
useLiveNodeOverrides.getState().set(segmentId, { position: lastLocal })
useScene.getState().markDirty(segmentId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(segmentId)
useScene.getState().updateNode(segmentId, { position: lastLocal })
},
}
@@ -1,209 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
CabinetNode,
DoorNode,
ItemNode,
StairNode,
StairSegmentNode,
ZoneNode,
} from '@pascal-app/core'
import {
buildClearanceAdvisories,
type ClearanceProfile,
DEFAULT_CLEARANCE_PROFILES,
} from './clearance-advisories'
const adaProfile: ClearanceProfile = {
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'us-ada-2010-advisory')!,
enabled: true,
}
const officeProfile: ClearanceProfile = {
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'office-residential-advisory')!,
enabled: true,
}
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
describe('clearance advisories', () => {
test('keeps default clearance profiles optional and quiet', () => {
const narrowHall = ZoneNode.parse({
id: 'zone_hall',
name: 'Hallway',
polygon: [
[0, 0],
[0.8, 0],
[0.8, 4],
[0, 4],
],
})
expect(buildClearanceAdvisories(nodes(narrowHall))).toEqual([])
})
test('checks circulation, entry, and door clear widths with ADA provenance', () => {
const hall = ZoneNode.parse({
id: 'zone_hall',
name: 'North Corridor',
polygon: [
[0, 0],
[0.8, 0],
[0.8, 5],
[0, 5],
],
})
const entry = ZoneNode.parse({
id: 'zone_entry',
name: 'Entry vestibule',
polygon: [
[0, 0],
[0.86, 0],
[0.86, 2],
[0, 2],
],
})
const door = DoorNode.parse({
id: 'door_narrow',
width: 0.78,
})
const advisories = buildClearanceAdvisories(nodes(hall, entry, door), {
profiles: [adaProfile],
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
'ada-door-clear-opening',
'ada-entry-clear-width',
'ada-accessible-route-clear-width',
])
expect(advisories.every((advisory) => advisory.source.edition === '2010')).toBe(true)
expect(advisories.every((advisory) => advisory.severity === 'warning')).toBe(true)
})
test('reports missing fixture, cabinet, and appliance clearance evidence', () => {
const toilet = ItemNode.parse({
id: 'item_toilet',
asset: {
id: 'asset_toilet',
category: 'plumbing',
name: 'Accessible Toilet',
thumbnail: '',
src: 'asset://toilet.glb',
tags: ['fixture'],
},
})
const sinkCabinet = CabinetNode.parse({
id: 'cabinet_sink',
stack: [{ id: 'sink', type: 'sink' }],
})
const applianceCabinet = CabinetNode.parse({
id: 'cabinet_dishwasher',
stack: [{ id: 'dishwasher', type: 'dishwasher' }],
})
const advisories = buildClearanceAdvisories(nodes(toilet, sinkCabinet, applianceCabinet), {
profiles: [adaProfile, officeProfile],
})
expect(advisories.map((advisory) => advisory.id)).toEqual([
'clearance:office-residential-advisory:cabinet_dishwasher:office-appliance-front-clearance',
'clearance:office-residential-advisory:cabinet_dishwasher:office-cabinet-front-clearance',
'clearance:office-residential-advisory:cabinet_sink:office-cabinet-front-clearance',
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-depth',
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-width',
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-depth',
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-width',
])
expect(advisories.every((advisory) => advisory.measured === null)).toBe(true)
expect(advisories.every((advisory) => advisory.severity === 'info')).toBe(true)
})
test('accepts explicit clearance evidence for surrounding cabinet and fixture checks', () => {
const toilet = ItemNode.parse({
id: 'item_toilet',
asset: {
id: 'asset_toilet',
category: 'plumbing',
name: 'Accessible Toilet',
thumbnail: '',
src: 'asset://toilet.glb',
tags: ['fixture'],
},
})
const cabinet = CabinetNode.parse({
id: 'cabinet_base',
})
const advisories = buildClearanceAdvisories(nodes(toilet, cabinet), {
profiles: [adaProfile, officeProfile],
evidence: {
item_toilet: {
'ada-fixture-clear-floor-width': 0.9,
'ada-fixture-clear-floor-depth': 1.0,
},
cabinet_base: {
'office-cabinet-front-clearance': 1.0,
},
},
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual(['ada-fixture-clear-floor-depth'])
expect(advisories[0]?.measured).toBe(1)
expect(advisories[0]?.severity).toBe('warning')
})
test('checks closet depth and stair geometry from modeled dimensions', () => {
const closet = ZoneNode.parse({
id: 'zone_closet',
name: 'Bedroom Closet',
polygon: [
[0, 0],
[0.55, 0],
[0.55, 2],
[0, 2],
],
})
const stair = StairNode.parse({
id: 'stair_tall_riser',
width: 0.82,
totalRise: 2.8,
stepCount: 12,
})
const segment = StairSegmentNode.parse({
id: 'sseg_shallow_treads',
width: 1,
length: 2.2,
height: 2,
stepCount: 10,
})
const advisories = buildClearanceAdvisories(nodes(closet, stair, segment), {
profiles: [officeProfile],
})
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
'office-stair-tread-depth',
'office-stair-riser-height',
'office-stair-tread-depth',
'office-stair-width',
'office-closet-depth',
])
expect(advisories.every((advisory) => advisory.source.title.includes('Pascal'))).toBe(true)
})
test('can include disabled profiles for profile preview UIs', () => {
const door = DoorNode.parse({
id: 'door_preview',
width: 0.78,
})
const advisories = buildClearanceAdvisories(nodes(door), {
includeDisabled: true,
})
expect(advisories.map((advisory) => advisory.profileId)).toEqual(['us-ada-2010-advisory'])
})
})
@@ -1,513 +0,0 @@
import {
type AnyNode,
type CabinetModuleNode,
type CabinetNode,
type DoorNode,
type ItemNode,
resolveStairTotalRise,
type StairNode,
type StairSegmentNode,
type ZoneNode,
} from '@pascal-app/core'
import { formatConstructionLength } from './construction-length'
export type ClearanceAdvisoryCategory =
| 'circulation'
| 'entry'
| 'door-approach'
| 'fixture'
| 'cabinet'
| 'appliance'
| 'closet'
| 'stair'
export type ClearanceAdvisorySeverity = 'info' | 'warning'
export type ClearanceRuleSource = {
title: string
edition: string
section: string
url?: string
note?: string
}
export type ClearanceRule = {
id: string
category: ClearanceAdvisoryCategory
label: string
measurement:
| 'clear-width'
| 'clear-depth'
| 'clear-floor-width'
| 'clear-floor-depth'
| 'front-clearance'
| 'stair-width'
| 'tread-depth'
| 'riser-height'
minValue: number
source: ClearanceRuleSource
}
export type ClearanceProfile = {
id: string
label: string
jurisdiction?: string
enabled: boolean
rules: readonly ClearanceRule[]
}
export type ClearanceEvidence = Readonly<
Record<string, Partial<Record<ClearanceRule['id'], number>>>
>
export type BuildClearanceAdvisoriesOptions = {
profiles?: readonly ClearanceProfile[]
includeDisabled?: boolean
evidence?: ClearanceEvidence
}
export type ClearanceAdvisory = {
id: string
nodeId: string
nodeType: string
profileId: string
profileLabel: string
category: ClearanceAdvisoryCategory
ruleId: string
label: string
measured: number | null
required: number
severity: ClearanceAdvisorySeverity
source: ClearanceRuleSource
message: string
}
type ClearanceTarget = {
nodeId: string
nodeType: string
category: ClearanceAdvisoryCategory
measurements: Partial<Record<ClearanceRule['measurement'], number>>
}
const ADA_2010: Pick<ClearanceRuleSource, 'title' | 'edition' | 'url'> = {
title: '2010 ADA Standards for Accessible Design',
edition: '2010',
url: 'https://www.access-board.gov/ada/',
}
const OFFICE_STANDARD: Pick<ClearanceRuleSource, 'title' | 'edition'> = {
title: 'Pascal construction-document advisory profile',
edition: '2026-07-21',
}
export const DEFAULT_CLEARANCE_PROFILES: readonly ClearanceProfile[] = [
{
id: 'us-ada-2010-advisory',
label: 'U.S. ADA 2010 advisory checks',
jurisdiction: 'US',
enabled: false,
rules: [
{
id: 'ada-accessible-route-clear-width',
category: 'circulation',
label: 'accessible route clear width',
measurement: 'clear-width',
minValue: 36 * 0.0254,
source: {
...ADA_2010,
section: '403.5.1',
note: 'Accessible routes generally require 36 inches minimum clear width.',
},
},
{
id: 'ada-entry-clear-width',
category: 'entry',
label: 'entry clear width',
measurement: 'clear-width',
minValue: 36 * 0.0254,
source: {
...ADA_2010,
section: '403.5.1',
note: 'Entries serving an accessible route are checked against the route clear width.',
},
},
{
id: 'ada-door-clear-opening',
category: 'door-approach',
label: 'door clear opening',
measurement: 'clear-width',
minValue: 32 * 0.0254,
source: {
...ADA_2010,
section: '404.2.3',
note: 'Door openings on accessible routes require 32 inches minimum clear width.',
},
},
{
id: 'ada-fixture-clear-floor-width',
category: 'fixture',
label: 'fixture clear floor space width',
measurement: 'clear-floor-width',
minValue: 30 * 0.0254,
source: {
...ADA_2010,
section: '305.3',
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
},
},
{
id: 'ada-fixture-clear-floor-depth',
category: 'fixture',
label: 'fixture clear floor space depth',
measurement: 'clear-floor-depth',
minValue: 48 * 0.0254,
source: {
...ADA_2010,
section: '305.3',
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
},
},
],
},
{
id: 'office-residential-advisory',
label: 'Office residential advisory checks',
enabled: false,
rules: [
{
id: 'office-cabinet-front-clearance',
category: 'cabinet',
label: 'cabinet front working clearance',
measurement: 'front-clearance',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Kitchen working clearances',
note: 'Office drafting convention for cabinet and drawer operation clearance.',
},
},
{
id: 'office-appliance-front-clearance',
category: 'appliance',
label: 'appliance front working clearance',
measurement: 'front-clearance',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Kitchen appliance clearances',
note: 'Office drafting convention for appliance door and working clearance.',
},
},
{
id: 'office-closet-depth',
category: 'closet',
label: 'closet clear depth',
measurement: 'clear-depth',
minValue: 0.6,
source: {
...OFFICE_STANDARD,
section: 'Storage clearances',
note: 'Office drafting convention for reach-in closet depth.',
},
},
{
id: 'office-stair-width',
category: 'stair',
label: 'stair clear width',
measurement: 'stair-width',
minValue: 0.9,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Office drafting convention; verify against local stair code before permit use.',
},
},
{
id: 'office-stair-tread-depth',
category: 'stair',
label: 'stair tread depth',
measurement: 'tread-depth',
minValue: 0.25,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Office drafting convention; verify against local stair code before permit use.',
},
},
{
id: 'office-stair-riser-height',
category: 'stair',
label: 'stair riser height',
measurement: 'riser-height',
minValue: -0.2,
source: {
...OFFICE_STANDARD,
section: 'Residential stair geometry',
note: 'Negative minValue means measured riser height must be less than or equal to the absolute value.',
},
},
],
},
] as const
export function buildClearanceAdvisories(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildClearanceAdvisoriesOptions = {},
): ClearanceAdvisory[] {
const profiles = (options.profiles ?? DEFAULT_CLEARANCE_PROFILES).filter(
(profile) => options.includeDisabled === true || profile.enabled,
)
if (profiles.length === 0) return []
const targets = Object.values(nodes).flatMap((node) => clearanceTargets(node, nodes))
const advisories: ClearanceAdvisory[] = []
for (const target of targets) {
for (const profile of profiles) {
for (const rule of profile.rules) {
if (rule.category !== target.category) continue
const measured =
target.measurements[rule.measurement] ?? options.evidence?.[target.nodeId]?.[rule.id]
if (measured === undefined) {
advisories.push(clearanceAdvisory({ target, profile, rule, measured: null }))
continue
}
if (violatesClearanceRule(measured, rule)) {
advisories.push(clearanceAdvisory({ target, profile, rule, measured }))
}
}
}
}
return advisories.sort((left, right) => left.id.localeCompare(right.id))
}
function clearanceTargets(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): ClearanceTarget[] {
if (node.type === 'zone') return zoneTargets(node)
if (node.type === 'door') return doorTargets(node)
if (node.type === 'item') return itemTargets(node)
if (node.type === 'cabinet' || node.type === 'cabinet-module') return cabinetTargets(node)
if (node.type === 'stair') return stairTargets(node, nodes)
if (node.type === 'stair-segment') return stairSegmentTargets(node)
return []
}
function zoneTargets(zone: ZoneNode): ClearanceTarget[] {
const role = normalizedText([zone.name, zone.occupancy, String(zone.metadata ?? '')])
const dimensions = zoneClearDimensions(zone)
const targets: ClearanceTarget[] = []
if (containsAny(role, ['hall', 'hallway', 'corridor', 'passage', 'circulation'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'circulation',
measurements: { 'clear-width': dimensions.minSpan },
})
}
if (containsAny(role, ['entry', 'entrance', 'vestibule', 'foyer'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'entry',
measurements: { 'clear-width': dimensions.minSpan },
})
}
if (containsAny(role, ['closet', 'wardrobe'])) {
targets.push({
nodeId: zone.id,
nodeType: zone.type,
category: 'closet',
measurements: { 'clear-depth': dimensions.minSpan },
})
}
return targets
}
function doorTargets(door: DoorNode): ClearanceTarget[] {
return [
{
nodeId: door.id,
nodeType: door.type,
category: 'door-approach',
measurements: { 'clear-width': door.width },
},
]
}
function itemTargets(item: ItemNode): ClearanceTarget[] {
const text = normalizedText([
item.asset.name,
item.asset.category,
...(item.asset.tags ?? []),
...(item.asset.functionTags ?? []),
])
const targets: ClearanceTarget[] = []
if (containsAny(text, ['toilet', 'lavatory', 'sink', 'fixture', 'tub', 'shower', 'wc'])) {
targets.push({
nodeId: item.id,
nodeType: item.type,
category: 'fixture',
measurements: {},
})
}
if (containsAny(text, ['appliance', 'fridge', 'refrigerator', 'oven', 'range', 'dishwasher'])) {
targets.push({
nodeId: item.id,
nodeType: item.type,
category: 'appliance',
measurements: {},
})
}
return targets
}
function cabinetTargets(cabinet: CabinetNode | CabinetModuleNode): ClearanceTarget[] {
const targets: ClearanceTarget[] = [
{
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'cabinet',
measurements: {},
},
]
if ((cabinet.stack ?? []).some((compartment) => isApplianceCompartment(compartment.type))) {
targets.push({
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'appliance',
measurements: {},
})
}
if ((cabinet.stack ?? []).some((compartment) => compartment.type === 'sink')) {
targets.push({
nodeId: cabinet.id,
nodeType: cabinet.type,
category: 'fixture',
measurements: {},
})
}
return targets
}
function stairTargets(
stair: StairNode,
nodes: Readonly<Record<string, AnyNode>>,
): ClearanceTarget[] {
const measurements: ClearanceTarget['measurements'] = { 'stair-width': stair.width }
const totalRise = resolveStairTotalRise(stair, nodes as Record<string, AnyNode>)
if (stair.stepCount > 0 && totalRise > 0) {
measurements['riser-height'] = totalRise / stair.stepCount
}
return [
{
nodeId: stair.id,
nodeType: stair.type,
category: 'stair',
measurements,
},
]
}
function stairSegmentTargets(segment: StairSegmentNode): ClearanceTarget[] {
const measurements: ClearanceTarget['measurements'] = { 'stair-width': segment.width }
if (segment.segmentType === 'stair' && segment.stepCount > 0) {
measurements['tread-depth'] = segment.length / segment.stepCount
if (segment.height > 0) measurements['riser-height'] = segment.height / segment.stepCount
}
return [
{
nodeId: segment.id,
nodeType: segment.type,
category: 'stair',
measurements,
},
]
}
function clearanceAdvisory(args: {
target: ClearanceTarget
profile: ClearanceProfile
rule: ClearanceRule
measured: number | null
}): ClearanceAdvisory {
const { target, profile, rule, measured } = args
const measuredLabel =
measured === null ? 'not verified' : formatConstructionLength(measured, 'metric')
const requiredLabel = formatConstructionLength(Math.abs(rule.minValue), 'metric')
const comparator = rule.minValue < 0 ? 'at most' : 'at least'
return {
id: ['clearance', profile.id, target.nodeId, rule.id].join(':'),
nodeId: target.nodeId,
nodeType: target.nodeType,
profileId: profile.id,
profileLabel: profile.label,
category: rule.category,
ruleId: rule.id,
label: rule.label,
measured,
required: Math.abs(rule.minValue),
severity: measured === null ? 'info' : 'warning',
source: rule.source,
message:
measured === null
? `${titleCase(target.nodeType)} ${target.nodeId} requires ${rule.label} verification (${comparator} ${requiredLabel}) per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`
: `${titleCase(target.nodeType)} ${target.nodeId} ${rule.label} ${measuredLabel} is below ${requiredLabel} per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`,
}
}
function violatesClearanceRule(measured: number, rule: ClearanceRule): boolean {
if (!Number.isFinite(measured)) return true
if (rule.minValue < 0) return measured > Math.abs(rule.minValue)
return measured < rule.minValue
}
function zoneClearDimensions(zone: ZoneNode): { minSpan: number } {
const xs = zone.polygon.map((point) => point[0])
const zs = zone.polygon.map((point) => point[1])
if (xs.length === 0 || zs.length === 0) return { minSpan: 0 }
return {
minSpan: Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs)),
}
}
function normalizedText(parts: readonly string[]): string {
return parts.join(' ').toLowerCase()
}
function containsAny(text: string, needles: readonly string[]): boolean {
return needles.some((needle) => text.includes(needle))
}
function isApplianceCompartment(type: string): boolean {
return [
'oven',
'microwave',
'dishwasher',
'cooktop-gas',
'cooktop-induction',
'fridge-single',
'fridge-double',
'fridge-top-freezer',
'fridge-bottom-freezer',
].includes(type)
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -1,181 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, WallNode, WindowNode } from '@pascal-app/core'
import {
buildConstructionModuleAdvisories,
type ConstructionModuleProfile,
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
} from './construction-module-advisories'
const FOOT = 0.3048
const metricProfile: ConstructionModuleProfile = {
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'metric-common')!,
enabled: true,
}
const imperialProfile: ConstructionModuleProfile = {
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'imperial-common')!,
enabled: true,
}
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
describe('construction module advisories', () => {
test('keeps default construction module profiles optional and quiet', () => {
const wall = WallNode.parse({
id: 'wall_off_module',
start: [0, 0],
end: [3.97, 0],
})
expect(buildConstructionModuleAdvisories(nodes(wall))).toEqual([])
})
test('reports metric wall lengths that miss the configured construction module', () => {
const compliantWall = WallNode.parse({
id: 'wall_metric_ok',
start: [0, 0],
end: [4, 0],
})
const offModuleWall = WallNode.parse({
id: 'wall_metric_off',
start: [0, 0],
end: [3.97, 0],
})
const advisories = buildConstructionModuleAdvisories(nodes(compliantWall, offModuleWall), {
profiles: [metricProfile],
})
expect(advisories).toHaveLength(1)
expect(advisories[0]).toMatchObject({
id: 'construction-module:metric-common:wall_metric_off:wall-length',
nodeId: 'wall_metric_off',
profileId: 'metric-common',
kind: 'wall-length',
module: 0.1,
measured: 3.97,
nearestMultiple: 4,
severity: 'info',
})
expect(advisories[0]?.deviation).toBeCloseTo(0.03)
expect(advisories[0]?.message).toContain('100 mm construction module')
})
test('checks overall level extents at exterior finish faces', () => {
const walls = [
WallNode.parse({
id: 'wall_bottom',
parentId: 'level_main',
start: [0, 0],
end: [4.03, 0],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_right',
parentId: 'level_main',
start: [4.03, 0],
end: [4.03, 3],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_top',
parentId: 'level_main',
start: [4.03, 3],
end: [0, 3],
thickness: 0.2,
}),
WallNode.parse({
id: 'wall_left',
parentId: 'level_main',
start: [0, 3],
end: [0, 0],
thickness: 0.2,
}),
]
const advisories = buildConstructionModuleAdvisories(nodes(...walls), {
profiles: [metricProfile],
})
expect(advisories).toContainEqual(
expect.objectContaining({
id: 'construction-module:metric-common:level_main:level-overall-width',
nodeId: 'level_main',
nodeType: 'level',
kind: 'level-overall-width',
}),
)
expect(
advisories.find((advisory) => advisory.kind === 'level-overall-width')?.measured,
).toBeCloseTo(4.23)
expect(advisories).not.toContainEqual(expect.objectContaining({ kind: 'level-overall-depth' }))
})
test('reports imperial opening widths that miss common inch modules', () => {
const compliantDoor = DoorNode.parse({
id: 'door_imperial_ok',
width: 3 * FOOT,
})
const offModuleDoor = DoorNode.parse({
id: 'door_imperial_off',
width: 0.95,
})
const advisories = buildConstructionModuleAdvisories(nodes(compliantDoor, offModuleDoor), {
profiles: [imperialProfile],
})
expect(advisories).toHaveLength(1)
expect(advisories[0]).toMatchObject({
id: 'construction-module:imperial-common:door_imperial_off:opening-width',
nodeId: 'door_imperial_off',
profileId: 'imperial-common',
kind: 'opening-width',
})
expect(advisories[0]?.module).toBeCloseTo(12 * 0.0254)
expect(advisories[0]?.message).toContain('1\'-0" construction module')
})
test('checks verified rough, masonry, and finish opening widths without inventing them', () => {
const door = DoorNode.parse({
id: 'door_verified_widths',
width: 1.2,
roughOpeningWidth: 1.23,
masonryOpeningWidth: 1.4,
})
const window = WindowNode.parse({
id: 'window_verified_widths',
width: 1.2,
finishOpeningWidth: 1.27,
})
const advisories = buildConstructionModuleAdvisories(nodes(door, window), {
profiles: [metricProfile],
})
expect(advisories.map((advisory) => advisory.id)).toEqual([
'construction-module:metric-common:door_verified_widths:rough-opening-width',
'construction-module:metric-common:window_verified_widths:finish-opening-width',
])
})
test('can explicitly include disabled profiles for preflight previews', () => {
const wall = WallNode.parse({
id: 'wall_preview',
start: [0, 0],
end: [3.97, 0],
})
const advisories = buildConstructionModuleAdvisories(nodes(wall), {
includeDisabled: true,
})
expect(advisories.map((advisory) => advisory.profileId).sort()).toEqual([
'imperial-common',
'metric-common',
])
})
})
@@ -1,326 +0,0 @@
import {
type AnyNode,
type DoorNode,
getWallAssemblyFaceOffsets,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { formatConstructionLength } from './construction-length'
const INCH = 0.0254
export type ConstructionModuleSystem = 'imperial' | 'metric'
export type ConstructionModuleAdvisorySeverity = 'info' | 'warning'
export type ConstructionModuleProfile = {
id: string
label: string
system: ConstructionModuleSystem
modules: readonly number[]
tolerance: number
enabled: boolean
}
export type ConstructionModuleMeasurementKind =
| 'wall-length'
| 'level-overall-width'
| 'level-overall-depth'
| 'opening-width'
| 'rough-opening-width'
| 'masonry-opening-width'
| 'finish-opening-width'
export type ConstructionModuleAdvisory = {
id: string
nodeId: string
nodeType: string
profileId: string
profileLabel: string
system: ConstructionModuleSystem
kind: ConstructionModuleMeasurementKind
label: string
module: number
measured: number
deviation: number
nearestMultiple: number
severity: ConstructionModuleAdvisorySeverity
message: string
}
export type BuildConstructionModuleAdvisoriesOptions = {
profiles?: readonly ConstructionModuleProfile[]
includeDisabled?: boolean
}
type ConstructionModuleMeasurement = {
nodeId: string
nodeType: string
kind: ConstructionModuleMeasurementKind
label: string
measured: number
}
type ModuleFit = {
module: number
nearestMultiple: number
deviation: number
}
export const DEFAULT_CONSTRUCTION_MODULE_PROFILES: readonly ConstructionModuleProfile[] = [
{
id: 'imperial-common',
label: 'Imperial common modules',
system: 'imperial',
modules: [12 * INCH, 16 * INCH, 24 * INCH],
tolerance: 0.25 * INCH,
enabled: false,
},
{
id: 'metric-common',
label: 'Metric common modules',
system: 'metric',
modules: [0.1, 0.2, 0.4, 0.6],
tolerance: 0.005,
enabled: false,
},
] as const
export function buildConstructionModuleAdvisories(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildConstructionModuleAdvisoriesOptions = {},
): ConstructionModuleAdvisory[] {
const profiles = (options.profiles ?? DEFAULT_CONSTRUCTION_MODULE_PROFILES).filter(
(profile) => options.includeDisabled === true || profile.enabled,
)
if (profiles.length === 0) return []
const measurements = [
...Object.values(nodes).flatMap((node) => constructionModuleMeasurements(node)),
...levelOverallMeasurements(nodes),
]
const advisories: ConstructionModuleAdvisory[] = []
for (const measurement of measurements) {
for (const profile of profiles) {
const fit = bestModuleFit(measurement.measured, profile.modules)
if (!fit || fit.deviation <= profile.tolerance) continue
advisories.push({
id: ['construction-module', profile.id, measurement.nodeId, measurement.kind].join(':'),
nodeId: measurement.nodeId,
nodeType: measurement.nodeType,
profileId: profile.id,
profileLabel: profile.label,
system: profile.system,
kind: measurement.kind,
label: measurement.label,
module: fit.module,
measured: measurement.measured,
deviation: fit.deviation,
nearestMultiple: fit.nearestMultiple,
severity: 'info',
message: moduleAdvisoryMessage(measurement, profile, fit),
})
}
}
return advisories.sort((left, right) => left.id.localeCompare(right.id))
}
function levelOverallMeasurements(
nodes: Readonly<Record<string, AnyNode>>,
): ConstructionModuleMeasurement[] {
const wallsByLevel = new Map<string, WallNode[]>()
for (const node of Object.values(nodes)) {
if (node.type !== 'wall' || !node.parentId) continue
if (node.curveOffset !== undefined && Math.abs(node.curveOffset) > 1e-6) continue
const levelWalls = wallsByLevel.get(node.parentId) ?? []
levelWalls.push(node)
wallsByLevel.set(node.parentId, levelWalls)
}
const measurements: ConstructionModuleMeasurement[] = []
for (const [levelId, walls] of wallsByLevel) {
const primaryWall = walls.reduce((longest, wall) =>
wallLength(wall) > wallLength(longest) ? wall : longest,
)
const primaryLength = wallLength(primaryWall)
if (
walls.length < 2 ||
!isUsefulLength(primaryLength) ||
!walls.some((wall) => !wallsAreParallel(primaryWall, wall))
) {
continue
}
const footprintPoints = walls.flatMap(wallFootprintPoints)
const direction: [number, number] = [
(primaryWall.end[0] - primaryWall.start[0]) / primaryLength,
(primaryWall.end[1] - primaryWall.start[1]) / primaryLength,
]
const normal: [number, number] = [-direction[1], direction[0]]
const along = footprintPoints.map(([x, y]) => x * direction[0] + y * direction[1])
const across = footprintPoints.map(([x, y]) => x * normal[0] + y * normal[1])
const width = Math.max(...along) - Math.min(...along)
const depth = Math.max(...across) - Math.min(...across)
if (isUsefulLength(width)) {
measurements.push({
nodeId: levelId,
nodeType: 'level',
kind: 'level-overall-width',
label: 'overall plan width',
measured: width,
})
}
if (isUsefulLength(depth)) {
measurements.push({
nodeId: levelId,
nodeType: 'level',
kind: 'level-overall-depth',
label: 'overall plan depth',
measured: depth,
})
}
}
return measurements
}
function wallLength(wall: WallNode): number {
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}
function wallsAreParallel(left: WallNode, right: WallNode): boolean {
const leftLength = wallLength(left)
const rightLength = wallLength(right)
if (!(isUsefulLength(leftLength) && isUsefulLength(rightLength))) return true
const leftDirection = [
(left.end[0] - left.start[0]) / leftLength,
(left.end[1] - left.start[1]) / leftLength,
]
const rightDirection = [
(right.end[0] - right.start[0]) / rightLength,
(right.end[1] - right.start[1]) / rightLength,
]
return (
Math.abs(leftDirection[0]! * rightDirection[1]! - leftDirection[1]! * rightDirection[0]!) < 1e-4
)
}
function wallFootprintPoints(wall: WallNode): [number, number][] {
const dx = wall.end[0] - wall.start[0]
const dy = wall.end[1] - wall.start[1]
const length = wallLength(wall)
if (!isUsefulLength(length)) return []
const normal: [number, number] = [-dy / length, dx / length]
const offsets = getWallAssemblyFaceOffsets(wall)
return [offsets.interior, offsets.exterior].flatMap((offset) => [
[wall.start[0] + normal[0] * offset, wall.start[1] + normal[1] * offset],
[wall.end[0] + normal[0] * offset, wall.end[1] + normal[1] * offset],
])
}
function constructionModuleMeasurements(node: AnyNode): ConstructionModuleMeasurement[] {
if (node.type === 'wall') return wallMeasurements(node)
if (node.type === 'door' || node.type === 'window') return openingMeasurements(node)
return []
}
function wallMeasurements(wall: WallNode): ConstructionModuleMeasurement[] {
if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return []
const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
if (!isUsefulLength(length)) return []
return [
{
nodeId: wall.id,
nodeType: wall.type,
kind: 'wall-length',
label: 'wall length',
measured: length,
},
]
}
function openingMeasurements(opening: DoorNode | WindowNode): ConstructionModuleMeasurement[] {
return [
widthMeasurement(opening, 'opening-width', 'nominal width', opening.width),
widthMeasurement(
opening,
'rough-opening-width',
'rough opening width',
opening.roughOpeningWidth,
),
widthMeasurement(
opening,
'masonry-opening-width',
'masonry opening width',
opening.masonryOpeningWidth,
),
widthMeasurement(
opening,
'finish-opening-width',
'finish opening width',
opening.finishOpeningWidth,
),
].filter((measurement): measurement is ConstructionModuleMeasurement => measurement !== null)
}
function widthMeasurement(
opening: DoorNode | WindowNode,
kind: ConstructionModuleMeasurementKind,
label: string,
measured: number | undefined,
): ConstructionModuleMeasurement | null {
if (!isUsefulLength(measured)) return null
return {
nodeId: opening.id,
nodeType: opening.type,
kind,
label,
measured,
}
}
function bestModuleFit(measured: number, modules: readonly number[]): ModuleFit | null {
let best: ModuleFit | null = null
for (const module of modules) {
if (!isUsefulLength(module)) continue
const multiple = Math.max(1, Math.round(measured / module))
const nearestMultiple = multiple * module
const deviation = Math.abs(measured - nearestMultiple)
if (!best || deviation < best.deviation) {
best = { module, nearestMultiple, deviation }
}
}
return best
}
function moduleAdvisoryMessage(
measurement: ConstructionModuleMeasurement,
profile: ConstructionModuleProfile,
fit: ModuleFit,
): string {
const unit = profile.system === 'imperial' ? 'imperial' : 'metric'
const measured = formatConstructionLength(measurement.measured, unit)
const module = formatModuleLength(fit.module, profile.system)
const deviation = formatConstructionLength(fit.deviation, unit)
return `${titleCase(measurement.nodeType)} ${measurement.nodeId} ${measurement.label} ${measured} is ${deviation} off the ${module} construction module.`
}
function formatModuleLength(module: number, system: ConstructionModuleSystem): string {
if (system === 'metric') return `${Math.round(module * 1000)} mm`
return formatConstructionLength(module, 'imperial')
}
function isUsefulLength(value: number | undefined): value is number {
return value !== undefined && Number.isFinite(value) && value > 1e-6
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -0,0 +1,311 @@
import { describe, expect, test } from 'bun:test'
import {
DoorNode,
type GeometryContext,
ItemNode,
WallNode,
WindowNode,
ZoneNode,
} from '@pascal-app/core'
import { createFloorplanContextExtensions } from '@pascal-app/editor'
import { buildDoorContextualDimensions } from '../door/contextual-dimensions'
import { buildItemContextualDimensions } from '../item/floorplan'
import { buildWallContextualDimensions } from '../wall/contextual-dimensions'
import { buildWindowContextualDimensions } from '../window/contextual-dimensions'
import { buildZoneContextualDimensions } from '../zone/contextual-dimensions'
function context(
parent: GeometryContext['parent'] = null,
siblings: GeometryContext['siblings'] = [],
moving = false,
): GeometryContext {
return {
resolve: () => undefined,
children: [],
siblings,
parent,
extensions: createFloorplanContextExtensions({
metricNotation: 'meters',
purpose: 'edit',
wallDimensionReference: 'centerline',
}),
viewState: moving
? {
selected: true,
unit: 'metric',
highlighted: false,
hovered: false,
moving: true,
}
: undefined,
}
}
describe('contextual floor-plan dimensions', () => {
test('uses modeled centerline length for a straight wall', () => {
const wall = WallNode.parse({
id: 'wall_primary',
start: [0, 0],
end: [4, 0],
})
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
kind: 'dimension',
start: [0, 0],
end: [4, 0],
text: '4m',
})
})
test('places a selected corner-wall dimension on its exterior side', () => {
const wall = WallNode.parse({
id: 'wall_corner',
start: [0, 0],
end: [4, 0],
frontSide: 'interior',
backSide: 'exterior',
})
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
kind: 'dimension',
offsetNormal: [0, -1],
})
})
test('infers the outside of an unclassified perimeter wall from its connected plan', () => {
const wall = WallNode.parse({
id: 'wall_right',
start: [4, 0],
end: [4, 6],
})
const siblings = [
WallNode.parse({ id: 'wall_top', start: [0, 0], end: [4, 0] }),
WallNode.parse({ id: 'wall_bottom', start: [4, 6], end: [0, 6] }),
WallNode.parse({ id: 'wall_left', start: [0, 6], end: [0, 0] }),
]
expect(buildWallContextualDimensions(wall, context(null, siblings))).toMatchObject({
kind: 'dimension',
offsetNormal: [1, 0],
})
})
test('shows one internal-wall dimension between the connected stud faces', () => {
const wall = WallNode.parse({
id: 'wall_internal',
start: [0, 0],
end: [0, 4],
thickness: 0.1,
frontSide: 'interior',
backSide: 'interior',
})
const startWall = WallNode.parse({
id: 'wall_start',
start: [-2, 0],
end: [2, 0],
thickness: 0.2,
})
const endWall = WallNode.parse({
id: 'wall_end',
start: [-2, 4],
end: [2, 4],
thickness: 0.2,
})
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toEqual(
expect.objectContaining({
kind: 'dimension',
start: [0, 0.1],
end: [0, 3.9],
offsetNormal: [-1, 0],
text: '3.8m',
}),
)
})
test('measures a selected perimeter wall between connected stud centerlines', () => {
const wall = WallNode.parse({
id: 'wall_perimeter',
start: [0, 0],
end: [0, 4],
frontSide: 'interior',
backSide: 'exterior',
})
const startWall = WallNode.parse({
id: 'wall_start',
start: [-2, 0],
end: [2, 0],
thickness: 0.2,
})
const endWall = WallNode.parse({
id: 'wall_end',
start: [-2, 4],
end: [2, 4],
thickness: 0.2,
})
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toMatchObject({
kind: 'dimension',
start: [0, 0],
end: [0, 4],
offsetNormal: [1, 0],
text: '4m',
})
})
test('uses arc length for a curved wall', () => {
const wall = WallNode.parse({
id: 'wall_curved',
start: [0, 0],
end: [4, 0],
curveOffset: 1,
})
const geometry = buildWallContextualDimensions(wall, context())
expect(geometry).toMatchObject({ kind: 'dimension-label' })
expect(geometry && 'text' in geometry ? Number.parseFloat(geometry.text) : 0).toBeGreaterThan(4)
})
test('shows only an opening width along its host wall', () => {
const wall = WallNode.parse({
id: 'wall_host',
start: [0, 0],
end: [6, 0],
})
const door = DoorNode.parse({
id: 'door_primary',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 0.9,
})
expect(buildDoorContextualDimensions(door, context(wall))).toMatchObject({
kind: 'dimension',
start: [1.55, 0],
end: [2.45, 0],
text: '0.9m',
})
})
test('shows a selected window width on the exterior side', () => {
const wall = WallNode.parse({
id: 'wall_host',
start: [0, 0],
end: [6, 0],
frontSide: 'interior',
backSide: 'exterior',
})
const startWall = WallNode.parse({
id: 'wall_start',
start: [0, -2],
end: [0, 2],
thickness: 0.2,
})
const endWall = WallNode.parse({
id: 'wall_end',
start: [6, -2],
end: [6, 2],
thickness: 0.2,
})
const window = WindowNode.parse({
id: 'window_primary',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 1,
})
expect(
buildWindowContextualDimensions(window, context(wall, [startWall, endWall])),
).toMatchObject({
kind: 'dimension',
offsetNormal: [0, -1],
start: [1.5, 0],
end: [2.5, 0],
text: '1m',
})
})
test('updates both window clearances from its live wall-local position', () => {
const wall = WallNode.parse({
id: 'wall_host',
start: [0, 0],
end: [6, 0],
frontSide: 'interior',
backSide: 'exterior',
})
const startWall = WallNode.parse({
id: 'wall_start',
start: [0, -2],
end: [0, 2],
thickness: 0.2,
})
const endWall = WallNode.parse({
id: 'wall_end',
start: [6, -2],
end: [6, 2],
thickness: 0.2,
})
const draggedWindow = WindowNode.parse({
id: 'window_primary',
parentId: wall.id,
wallId: wall.id,
position: [3, 1.05, 0],
width: 1,
})
const geometry = buildWindowContextualDimensions(
draggedWindow,
context(wall, [startWall, endWall], true),
)
expect(
geometry?.kind === 'dimension-string' ? geometry.segments.map((segment) => segment.text) : [],
).toEqual(['2.4m', '1m', '2.4m'])
})
test('shows room area at the polygon centroid', () => {
const room = ZoneNode.parse({
id: 'zone_room',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
spaceRole: 'room',
})
expect(buildZoneContextualDimensions(room, context())).toMatchObject({
kind: 'dimension-label',
cx: 2,
cy: 1.5,
text: '12.0m²',
})
})
test('shows item width and depth without placement chains', () => {
const item = ItemNode.parse({
id: 'item_primary',
position: [2, 0, 3],
scale: [2, 1, 1],
asset: {
id: 'table',
category: 'furniture',
name: 'Table',
thumbnail: '',
src: 'asset://table',
dimensions: [1.2, 0.8, 0.6],
},
})
const geometry = buildItemContextualDimensions(item, context())
expect(geometry?.kind).toBe('group')
expect(
geometry?.kind === 'group'
? geometry.children.map((child) => ('text' in child ? child.text : null))
: [],
).toEqual(['2.4m', '0.6m'])
})
})
@@ -1,340 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
CabinetNode,
ConstructionDimensionNode,
DoorNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { buildDimensionCompletenessAudit } from './dimension-completeness-audit'
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
}
function featureAnchor(nodeId: string, fallback: [number, number, number] = [0, 0, 0]) {
return {
kind: 'feature' as const,
reference: { nodeId, featureId: 'center' },
fallback,
}
}
describe('dimension completeness audit', () => {
test('reports missing overall exterior wall dimensions and partition references', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const partitionWall = WallNode.parse({
id: 'wall_partition',
start: [1, 0],
end: [1, 3],
frontSide: 'interior',
backSide: 'interior',
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'missing-partition-reference',
'undocumented-critical-node',
'undocumented-critical-node',
])
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'missing-overall-dimension',
nodeId: 'wall_exterior',
severity: 'warning',
}),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'missing-partition-reference',
nodeId: 'wall_partition',
severity: 'info',
}),
)
})
test('uses associative construction-dimension anchors as dimension coverage', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const partitionWall = WallNode.parse({
id: 'wall_partition',
start: [1, 0],
end: [1, 3],
frontSide: 'interior',
backSide: 'interior',
})
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_wall_refs',
anchors: [
featureAnchor(exteriorWall.id, [0, 0, 0]),
featureAnchor(partitionWall.id, [1, 0, 0]),
],
})
expect(buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall, dimension))).toEqual(
[],
)
})
test('can count the automatic wall and opening dimension plan as coverage', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
roughOpeningWidth: 0.96,
})
expect(
buildDimensionCompletenessAudit(nodes(exteriorWall, door), {
includeAutomaticDimensions: true,
}),
).toEqual([])
})
test('reports undimensioned exterior openings and missing verified rough openings', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry', 'window_front'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
width: 0.9,
})
const window = WindowNode.parse({
id: 'window_front',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
roughOpeningWidth: 1.22,
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, window))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'missing-verified-rough-opening',
'undimensioned-exterior-opening',
'undimensioned-exterior-opening',
'undocumented-critical-node',
])
expect(issues.filter((auditIssue) => auditIssue.nodeId === 'window_front')).toHaveLength(1)
})
test('suppresses exterior opening and rough-opening issues when evidence exists', () => {
const exteriorWall = WallNode.parse({
id: 'wall_exterior',
children: ['door_entry'],
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const door = DoorNode.parse({
id: 'door_entry',
parentId: exteriorWall.id,
wallId: exteriorWall.id,
width: 0.9,
roughOpeningWidth: 0.96,
})
const openingDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_door',
anchors: [featureAnchor(door.id, [2, 0, 0]), featureAnchor(door.id, [3, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, openingDimension))
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
'missing-overall-dimension',
'undocumented-critical-node',
])
})
test('can require rough-opening height verification as a stricter profile', () => {
const door = DoorNode.parse({
id: 'door_entry',
roughOpeningWidth: 0.96,
})
expect(
buildDimensionCompletenessAudit(nodes(door), { requireRoughOpeningHeights: true }),
).toMatchObject([
{
kind: 'missing-verified-rough-opening',
nodeId: 'door_entry',
},
])
})
test('does not require rough openings for masonry openings or frameless openings', () => {
const masonryWindow = WindowNode.parse({
id: 'window_masonry',
constructionType: 'masonry',
})
const framelessOpening = DoorNode.parse({
id: 'door_opening',
openingKind: 'opening',
})
expect(buildDimensionCompletenessAudit(nodes(masonryWindow, framelessOpening))).toEqual([])
})
test('detects duplicate and contradictory dimension string overrides', () => {
const wall = WallNode.parse({
id: 'wall_exterior',
start: [0, 0],
end: [5, 0],
frontSide: 'exterior',
})
const firstDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_first',
textOverride: '5.00m',
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [5, 0, 0])],
})
const duplicateDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_duplicate',
textOverride: '5.00 m',
anchors: [featureAnchor('wall_other', [0, 0, 0]), featureAnchor('wall_other', [5, 0, 0])],
})
const conflictingDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_conflict',
textOverride: '4.80m',
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [4.8, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(
nodes(wall, firstDimension, duplicateDimension, conflictingDimension),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'duplicate-dimension-string',
nodeId: 'construction-dimension_first',
}),
)
expect(issues).toContainEqual(
expect.objectContaining({
kind: 'contradictory-dimension-string',
nodeId: wall.id,
}),
)
})
test('detects continuous dimension segment totals that disagree with the overall string', () => {
const dimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_chain',
chainMode: 'continuous',
textOverride: '3.00m',
anchors: [
featureAnchor('wall_a', [0, 0, 0]),
featureAnchor('wall_b', [1, 0, 0]),
featureAnchor('wall_c', [2, 0, 0]),
],
})
const issues = buildDimensionCompletenessAudit(nodes(dimension))
expect(issues).toEqual([
expect.objectContaining({
kind: 'dimension-segment-total-mismatch',
nodeId: 'construction-dimension_chain',
}),
])
})
test('reports construction-critical nodes without dimensions or schedules', () => {
const undocumentedCabinet = CabinetNode.parse({
id: 'cabinet_undocumented',
})
const stair = StairNode.parse({
id: 'stair_documented',
})
const stairDimension = ConstructionDimensionNode.parse({
id: 'construction-dimension_stair',
anchors: [featureAnchor(stair.id, [0, 0, 0]), featureAnchor(stair.id, [1, 0, 0])],
})
const issues = buildDimensionCompletenessAudit(
nodes(undocumentedCabinet, stair, stairDimension),
)
expect(issues).toEqual([
expect.objectContaining({
kind: 'undocumented-critical-node',
nodeId: undocumentedCabinet.id,
}),
])
})
test('includes unresolved annotation collisions from preflight evidence', () => {
const issues = buildDimensionCompletenessAudit(nodes(), {
preflightIssues: [
{
id: 'dimension-label_wall_a',
kind: 'unresolved-collision',
severity: 'warning',
message:
'Wall A dimension label still overlaps another annotation after automatic layout.',
},
{
id: 'dimension-label_wall_b',
kind: 'short-unreadable-segment',
severity: 'warning',
message: 'Wall B uses an outside label.',
},
],
})
expect(issues).toEqual([
expect.objectContaining({
id: 'dimension-completeness:unresolved-annotation-collision:dimension-label_wall_a',
kind: 'unresolved-annotation-collision',
nodeId: 'dimension-label_wall_a',
nodeType: 'annotation',
}),
])
})
test('includes clipped sheet content from sheet preflight evidence', () => {
const issues = buildDimensionCompletenessAudit(nodes(), {
preflightIssues: [
{
message:
'Scaled plan exceeds the sheet viewport. Review clipped view or annotation content.',
},
],
})
expect(issues).toEqual([
expect.objectContaining({
id: 'dimension-completeness:clipped-sheet-content:sheet',
kind: 'clipped-sheet-content',
nodeId: 'sheet',
nodeType: 'sheet',
severity: 'warning',
}),
])
})
})
@@ -1,495 +0,0 @@
import {
type AnyNode,
type ConstructionDimensionNode,
type DoorNode,
measurementAnchorReferenceNodeIds,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
export type DimensionCompletenessIssueKind =
| 'missing-overall-dimension'
| 'undimensioned-exterior-opening'
| 'missing-partition-reference'
| 'missing-verified-rough-opening'
| 'duplicate-dimension-string'
| 'contradictory-dimension-string'
| 'dimension-segment-total-mismatch'
| 'undocumented-critical-node'
| 'unresolved-annotation-collision'
| 'clipped-sheet-content'
export type DimensionCompletenessIssueSeverity = 'info' | 'warning'
export type DimensionCompletenessIssue = {
id: string
kind: DimensionCompletenessIssueKind
nodeId: string
nodeType: string
severity: DimensionCompletenessIssueSeverity
message: string
}
export type BuildDimensionCompletenessAuditOptions = {
includeAutomaticDimensions?: boolean
requireRoughOpeningHeights?: boolean
dimensionValueTolerance?: number
preflightIssues?: readonly DimensionCompletenessPreflightIssue[]
}
export type DimensionCompletenessPreflightIssue = {
id?: string
kind?: string
severity?: DimensionCompletenessIssueSeverity
message: string
}
type DimensionCoverage = ReadonlySet<string>
type DocumentationCoverage = {
dimensioned: ReadonlySet<string>
scheduled: ReadonlySet<string>
}
type OpeningNode = DoorNode | WindowNode
type DimensionRecord = {
dimension: ConstructionDimensionNode
referencedNodeIds: readonly string[]
normalizedText: string | null
parsedTextValue: number | null
segmentTotal: number | null
}
export function buildDimensionCompletenessAudit(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions = {},
): DimensionCompletenessIssue[] {
const coverage = dimensionCoverage(nodes, options)
const documentation = documentationCoverage(nodes, coverage)
const issues: DimensionCompletenessIssue[] = []
issues.push(...dimensionStringIssues(nodes, options))
issues.push(...preflightCompletenessIssues(options.preflightIssues ?? []))
for (const node of Object.values(nodes)) {
if (node.type === 'wall') {
issues.push(...wallDimensionIssues(node, coverage))
} else if (node.type === 'door' || node.type === 'window') {
issues.push(...openingDimensionIssues(node, nodes, coverage, options))
}
if (isConstructionCriticalNode(node, nodes) && !hasDocumentationCoverage(node, documentation)) {
issues.push(
issue(
'undocumented-critical-node',
node,
'warning',
`${titleCase(node.type)} ${node.id} has no construction dimension or schedule entry.`,
),
)
}
}
return issues.sort((left, right) => left.id.localeCompare(right.id))
}
function dimensionCoverage(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCoverage {
const covered = new Set<string>()
for (const node of Object.values(nodes)) {
if (node.type !== 'construction-dimension') continue
for (const nodeId of measurementAnchorReferenceNodeIds(
(node as ConstructionDimensionNode).anchors,
)) {
covered.add(nodeId)
}
}
if (options.includeAutomaticDimensions === true) {
for (const node of Object.values(nodes)) {
if (
node.type === 'wall' &&
node.visible !== false &&
Math.abs(node.curveOffset ?? 0) <= 1e-6 &&
(isExteriorWall(node) || isPartitionWall(node))
) {
covered.add(node.id)
}
}
for (const node of Object.values(nodes)) {
if (node.type !== 'door' && node.type !== 'window') continue
const host = openingHostWall(node, nodes)
if (host && covered.has(host.id)) covered.add(node.id)
}
}
return covered
}
function documentationCoverage(
nodes: Readonly<Record<string, AnyNode>>,
dimensioned: DimensionCoverage,
): DocumentationCoverage {
const scheduled = new Set<string>()
for (const node of Object.values(nodes)) {
if (hasGeneratedScheduleEntry(node)) scheduled.add(node.id)
}
return { dimensioned, scheduled }
}
function dimensionStringIssues(
nodes: Readonly<Record<string, AnyNode>>,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCompletenessIssue[] {
const records = Object.values(nodes)
.filter((node): node is ConstructionDimensionNode => node.type === 'construction-dimension')
.map((dimension) => dimensionRecord(dimension))
const issues: DimensionCompletenessIssue[] = []
issues.push(...duplicateDimensionStringIssues(records))
issues.push(...contradictoryDimensionStringIssues(records))
issues.push(...segmentTotalMismatchIssues(records, options.dimensionValueTolerance ?? 0.005))
return issues
}
function dimensionRecord(dimension: ConstructionDimensionNode): DimensionRecord {
const normalizedText = normalizedDimensionText(dimension.textOverride)
return {
dimension,
referencedNodeIds: measurementAnchorReferenceNodeIds(dimension.anchors),
normalizedText,
parsedTextValue: normalizedText ? parseDimensionTextValue(normalizedText) : null,
segmentTotal: continuousSegmentTotal(dimension),
}
}
function duplicateDimensionStringIssues(
records: readonly DimensionRecord[],
): DimensionCompletenessIssue[] {
const byText = new Map<string, DimensionRecord[]>()
for (const record of records) {
if (!record.normalizedText) continue
const existing = byText.get(record.normalizedText)
if (existing) existing.push(record)
else byText.set(record.normalizedText, [record])
}
const issues: DimensionCompletenessIssue[] = []
for (const [text, duplicates] of byText) {
if (duplicates.length < 2) continue
const dimension = duplicates[0]?.dimension
if (!dimension) continue
issues.push(
issue(
'duplicate-dimension-string',
dimension,
'info',
`Dimension string "${text}" is used by ${duplicates.length} construction dimensions.`,
),
)
}
return issues
}
function contradictoryDimensionStringIssues(
records: readonly DimensionRecord[],
): DimensionCompletenessIssue[] {
const byNode = new Map<string, Map<string, DimensionRecord[]>>()
for (const record of records) {
if (!record.normalizedText) continue
for (const nodeId of record.referencedNodeIds) {
const byText = byNode.get(nodeId) ?? new Map<string, DimensionRecord[]>()
const matchingText = byText.get(record.normalizedText)
if (matchingText) matchingText.push(record)
else byText.set(record.normalizedText, [record])
byNode.set(nodeId, byText)
}
}
const issues: DimensionCompletenessIssue[] = []
for (const [nodeId, byText] of byNode) {
if (byText.size < 2) continue
const firstRecord = [...byText.values()][0]?.[0]
if (!firstRecord) continue
issues.push({
id: ['dimension-completeness', 'contradictory-dimension-string', nodeId].join(':'),
kind: 'contradictory-dimension-string',
nodeId,
nodeType: 'unknown',
severity: 'warning',
message: `Referenced node ${nodeId} has contradictory construction dimension strings: ${[
...byText.keys(),
].join(', ')}.`,
})
}
return issues
}
function segmentTotalMismatchIssues(
records: readonly DimensionRecord[],
tolerance: number,
): DimensionCompletenessIssue[] {
return records.flatMap((record) => {
if (record.dimension.chainMode !== 'continuous') return []
if (record.parsedTextValue === null || record.segmentTotal === null) return []
if (Math.abs(record.parsedTextValue - record.segmentTotal) <= tolerance) return []
return [
issue(
'dimension-segment-total-mismatch',
record.dimension,
'warning',
`Continuous dimension ${record.dimension.id} text ${record.normalizedText} does not match its segment total ${record.segmentTotal.toFixed(3)}m.`,
),
]
})
}
function preflightCompletenessIssues(
preflightIssues: readonly DimensionCompletenessPreflightIssue[],
): DimensionCompletenessIssue[] {
const issues: DimensionCompletenessIssue[] = []
for (const preflightIssue of preflightIssues) {
const normalizedKind = preflightIssue.kind?.trim().toLowerCase()
const normalizedMessage = preflightIssue.message.trim().toLowerCase()
if (normalizedKind === 'unresolved-collision') {
issues.push(
preflightIssueCompletenessIssue(
'unresolved-annotation-collision',
preflightIssue,
'annotation',
),
)
continue
}
if (
normalizedKind === 'clipped-content' ||
normalizedKind === 'clipped-sheet-content' ||
normalizedMessage.includes('clipped') ||
normalizedMessage.includes('exceeds the sheet viewport')
) {
issues.push(preflightIssueCompletenessIssue('clipped-sheet-content', preflightIssue, 'sheet'))
}
}
return issues
}
function preflightIssueCompletenessIssue(
kind: Extract<
DimensionCompletenessIssueKind,
'unresolved-annotation-collision' | 'clipped-sheet-content'
>,
preflightIssue: DimensionCompletenessPreflightIssue,
fallbackNodeId: string,
): DimensionCompletenessIssue {
const nodeId = preflightIssue.id?.trim() || fallbackNodeId
return {
id: ['dimension-completeness', kind, nodeId].join(':'),
kind,
nodeId,
nodeType: fallbackNodeId,
severity: preflightIssue.severity ?? 'warning',
message: preflightIssue.message,
}
}
function wallDimensionIssues(
wall: WallNode,
coverage: DimensionCoverage,
): DimensionCompletenessIssue[] {
if (coverage.has(wall.id)) return []
if (isExteriorWall(wall)) {
return [
issue(
'missing-overall-dimension',
wall,
'warning',
`Exterior wall ${wall.id} has no associative overall construction dimension.`,
),
]
}
if (isPartitionWall(wall)) {
return [
issue(
'missing-partition-reference',
wall,
'info',
`Partition wall ${wall.id} has no associative partition reference dimension.`,
),
]
}
return []
}
function openingDimensionIssues(
opening: OpeningNode,
nodes: Readonly<Record<string, AnyNode>>,
coverage: DimensionCoverage,
options: BuildDimensionCompletenessAuditOptions,
): DimensionCompletenessIssue[] {
const issues: DimensionCompletenessIssue[] = []
const hostWall = openingHostWall(opening, nodes)
if (hostWall && isExteriorWall(hostWall) && !coverage.has(opening.id)) {
issues.push(
issue(
'undimensioned-exterior-opening',
opening,
'warning',
`${titleCase(opening.type)} ${opening.id} is on exterior wall ${hostWall.id} but has no associative opening dimension.`,
),
)
}
if (missingVerifiedRoughOpening(opening, options)) {
issues.push(
issue(
'missing-verified-rough-opening',
opening,
'info',
`${titleCase(opening.type)} ${opening.id} has no verified rough-opening ${options.requireRoughOpeningHeights === true ? 'width and height' : 'width'} recorded.`,
),
)
}
return issues
}
function openingHostWall(
opening: OpeningNode,
nodes: Readonly<Record<string, AnyNode>>,
): WallNode | null {
const hostId = opening.wallId ?? opening.parentId ?? null
if (!hostId) return null
const host = nodes[hostId]
return host?.type === 'wall' ? host : null
}
function missingVerifiedRoughOpening(
opening: OpeningNode,
options: BuildDimensionCompletenessAuditOptions,
): boolean {
if (opening.openingKind === 'opening') return false
if (opening.constructionType === 'masonry') return false
if (opening.roughOpeningWidth === undefined) return true
return options.requireRoughOpeningHeights === true && opening.roughOpeningHeight === undefined
}
function isExteriorWall(wall: WallNode): boolean {
return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
}
function isPartitionWall(wall: WallNode): boolean {
return wall.frontSide === 'interior' || wall.backSide === 'interior'
}
function hasDocumentationCoverage(node: AnyNode, coverage: DocumentationCoverage): boolean {
return coverage.dimensioned.has(node.id) || coverage.scheduled.has(node.id)
}
function hasGeneratedScheduleEntry(node: AnyNode): boolean {
if (node.type === 'door' || node.type === 'window') return node.openingKind !== 'opening'
return node.type === 'zone' && node.spaceRole === 'room'
}
function isConstructionCriticalNode(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): boolean {
if (node.type === 'wall') return isExteriorWall(node) || isPartitionWall(node)
if (node.type === 'door' || node.type === 'window') {
const hostWall = openingHostWall(node, nodes)
return hostWall ? isExteriorWall(hostWall) : false
}
if (node.type === 'zone') return node.spaceRole === 'room'
return (
node.type === 'cabinet' ||
node.type === 'cabinet-module' ||
node.type === 'stair' ||
node.type === 'stair-segment'
)
}
function normalizedDimensionText(text: string | null): string | null {
const normalized = text
?.trim()
.replace(/\s+/g, ' ')
.replace(/(\d)\s+(MM|M|")/gi, '$1$2')
.toUpperCase()
return normalized || null
}
function parseDimensionTextValue(text: string): number | null {
const metricMatch = text.match(/^([0-9]+(?:\.[0-9]+)?)\s*(MM|M)?$/)
if (metricMatch) {
const value = Number.parseFloat(metricMatch[1] ?? '')
if (!Number.isFinite(value)) return null
return metricMatch[2] === 'MM' ? value / 1000 : value
}
const imperialMatch = text.match(/^(?:(\d+(?:\.\d+)?)')?(?:-)?(?:(\d+(?:\.\d+)?)")?$/)
if (imperialMatch) {
const feet = Number.parseFloat(imperialMatch[1] ?? '0')
const inches = Number.parseFloat(imperialMatch[2] ?? '0')
const totalInches = feet * 12 + inches
return totalInches > 0 ? totalInches * 0.0254 : null
}
return null
}
function continuousSegmentTotal(dimension: ConstructionDimensionNode): number | null {
if (dimension.chainMode !== 'continuous' || dimension.anchors.length < 3) return null
const directionLength = Math.hypot(
dimension.baseline.direction[0],
dimension.baseline.direction[1],
)
if (directionLength <= 1e-9) return null
const dirX = dimension.baseline.direction[0] / directionLength
const dirZ = dimension.baseline.direction[1] / directionLength
let total = 0
for (let index = 1; index < dimension.anchors.length; index += 1) {
const previousAnchor = dimension.anchors[index - 1]
const currentAnchor = dimension.anchors[index]
if (!previousAnchor || !currentAnchor) return null
const previous = anchorFallbackPoint(previousAnchor)
const current = anchorFallbackPoint(currentAnchor)
total += Math.abs((current[0] - previous[0]) * dirX + (current[2] - previous[2]) * dirZ)
}
return total
}
function anchorFallbackPoint(
anchor: ConstructionDimensionNode['anchors'][number],
): [number, number, number] {
return Array.isArray(anchor) ? anchor : anchor.fallback
}
function issue(
kind: DimensionCompletenessIssueKind,
node: Pick<AnyNode, 'id' | 'type'>,
severity: DimensionCompletenessIssueSeverity,
message: string,
): DimensionCompletenessIssue {
return {
id: ['dimension-completeness', kind, node.id].join(':'),
kind,
nodeId: node.id,
nodeType: node.type,
severity,
message,
}
}
function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
type AnyNodeId,
DoorNode,
useLiveNodeOverrides,
useScene,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { columnResizeAffordance } from '../column/floorplan-affordances'
import { doorWidthAffordance } from '../door/floorplan-affordances'
import { spawnRotateAffordance } from '../spawn/floorplan-affordances'
import { windowWidthAffordance } from '../window/floorplan-affordances'
globalThis.requestAnimationFrame ??= (callback) => {
callback(0)
return 0
}
globalThis.cancelAnimationFrame ??= () => {}
const modifiers = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
afterEach(() => {
useLiveNodeOverrides.getState().clearAll()
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
})
describe('opening width floor-plan affordances', () => {
for (const kind of ['door', 'window'] as const) {
test(`${kind} previews through a live override and writes the scene only on commit`, () => {
const wall = WallNode.parse({
id: `wall_${kind}`,
start: [0, 0],
end: [6, 0],
})
const opening =
kind === 'door'
? DoorNode.parse({
id: 'door_width-live',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 1,
})
: WindowNode.parse({
id: 'window_width-live',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 1,
})
const nodes = { [wall.id]: wall, [opening.id]: opening }
useScene.setState({ nodes } as never)
const affordance = kind === 'door' ? doorWidthAffordance : windowWidthAffordance
const session = affordance.start({
node: opening as never,
payload: { side: 'end' },
nodes: useScene.getState().nodes,
initialPlanPoint: [2.5, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [3, 0], modifiers })
expect(useScene.getState().nodes[opening.id]).toBe(opening)
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toMatchObject({
width: 1.5,
position: [2.25, 1.05, 0],
})
session.commit?.()
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toBeUndefined()
expect(useScene.getState().nodes[opening.id]).toMatchObject({
width: 1.5,
position: [2.25, 1.05, 0],
})
})
}
})
describe('floor-plan affordance preview policy', () => {
test('a parametric resize keeps scene data stable until commit', () => {
const column = {
id: 'column_live-resize',
type: 'column',
position: [0, 0, 0],
width: 1,
depth: 1,
radius: 0.5,
}
useScene.setState({ nodes: { [column.id]: column } } as never)
const session = columnResizeAffordance.start({
node: column as never,
payload: { dim: 'width', planAxis: [1, 0] },
nodes: useScene.getState().nodes,
initialPlanPoint: [0.5, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [0.75, 0], modifiers })
expect(useScene.getState().nodes[column.id]).toBe(column)
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toMatchObject({
width: 1.5,
})
session.commit?.()
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toBeUndefined()
expect(useScene.getState().nodes[column.id]).toMatchObject({ width: 1.5 })
})
test('a rotation keeps scene data stable until commit', () => {
const spawn = {
id: 'spawn_live-rotate',
type: 'spawn',
position: [0, 0, 0],
rotation: 0,
}
useScene.setState({ nodes: { [spawn.id]: spawn } } as never)
const session = spawnRotateAffordance.start({
node: spawn as never,
payload: undefined,
nodes: useScene.getState().nodes,
initialPlanPoint: [1, 0],
gridSnapStep: 0.1,
})
session.apply({ planPoint: [0, 1], modifiers })
expect(useScene.getState().nodes[spawn.id]).toBe(spawn)
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)?.rotation).toBeCloseTo(
-Math.PI / 2,
)
session.commit?.()
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)).toBeUndefined()
expect((useScene.getState().nodes[spawn.id] as { rotation: number }).rotation).toBeCloseTo(
-Math.PI / 2,
)
})
})
@@ -2,8 +2,10 @@ import {
type AnyNodeId,
type FloorplanAffordance,
type ShelfNode,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { isAngleSnapActive } from '@pascal-app/editor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
@@ -45,13 +47,15 @@ export const shelfResizeAffordance: FloorplanAffordance<ShelfNode> = {
} else {
lastPatch = { depth: Math.max(MIN_SHELF_DEPTH, initialDepth + 2 * projDelta) }
}
useScene.getState().updateNode(shelfId, lastPatch)
useLiveNodeOverrides.getState().set(shelfId, lastPatch)
useScene.getState().markDirty(shelfId)
},
canCommit() {
return true
},
commit() {
if (Object.keys(lastPatch).length > 0) {
useLiveNodeOverrides.getState().clear(shelfId)
useScene.getState().updateNode(shelfId, lastPatch)
}
},
@@ -83,21 +87,23 @@ export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
return {
affectedIds: [shelfId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
const newRotationY = initialRotationY - delta
lastRotation = [r[0], newRotationY, r[2]]
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
useLiveNodeOverrides.getState().set(shelfId, { rotation: lastRotation })
useScene.getState().markDirty(shelfId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(shelfId)
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
},
}
+20 -38
View File
@@ -6,14 +6,16 @@ import {
type FloorplanMoveTargetSession,
movingFootprintAnchors,
type ShelfNode,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
applyFloorplanAlignment,
getFloorStackPreviewPosition,
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX,
useEditor,
type WallPlanPoint,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
@@ -23,22 +25,8 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
* because shelf is a `position`-field kind (it carries its location in
* `node.position`, not in polygon vertices):
*
* - Each pointermove writes the absolute world-plan position straight
* to `useScene` (history is paused by the overlay). This is the single
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
* `ParametricNodeRenderer` group transform both follow it reactively,
* so 2D and 3D can never diverge.
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
* history, and re-applies the final position as one undoable step.
* `canCommit` only validates.
*
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
* `slab` / `ceiling` use. That works for polygon kinds because their commit
* rebuilds geometry (the vertices change), which forces the 3D group to
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
* scene directly removes that second source of truth entirely.
* - Each pointermove previews through `useLiveNodeOverrides`.
* - On commit, the final position is written once as one undoable step.
*/
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
const shelfId = node.id as AnyNodeId
@@ -49,6 +37,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
metadata: node.metadata,
})
let lastPosition: [number, number, number] = originalPosition
let lastVisualPosition: [number, number, number] = originalPosition
let lastSnapKey: string | null = null
// Alignment candidates — corner/edge/segment anchors of every OTHER node
@@ -58,16 +47,13 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
const session: FloorplanMoveTargetSession = {
affectedIds: [shelfId],
apply({ planPoint, modifiers }) {
const snap = (value: number) => {
if (modifiers.shiftKey) return value
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
apply({ planPoint }) {
const gridSnapActive = isGridSnapActive()
const step = gridSnapActive ? getSegmentGridStep() : 0
const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value)
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
// Figma-style alignment layered on the grid snap — the shelf footprint
// edges snap to neighbours / wall faces and a guide is published. Alt
// bypasses alignment; Shift bypasses all snap.
// edges snap to neighbours / wall faces and a guide is published.
const { point: snapped } = applyFloorplanAlignment(
gridSnapped,
movingFootprintAnchors(
@@ -77,7 +63,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
originalRotationY,
),
candidates,
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
{ applySnap: isMagneticSnapActive() },
)
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
lastPosition = next
@@ -86,7 +72,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
// and the placement coordinators. Item / slab / wall flows fire
// the same cue, so the shelf following along is the expected UX.
const snapKey = `${snapped[0]},${snapped[1]}`
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
if (gridSnapActive && snapKey !== lastSnapKey) {
triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey
}
@@ -96,23 +82,19 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
rotation: node.rotation,
levelId: node.parentId ?? null,
})
// Single source of truth — write the absolute position straight to
// the scene (history is paused by the overlay). Both the 2D SVG and
// the 3D group transform read `node.position` reactively, so they
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
// into one undoable step on commit.
useScene.getState().updateNodes([
{
id: shelfId,
data: { position: visualPosition },
},
])
lastVisualPosition = visualPosition
useLiveNodeOverrides.getState().set(shelfId, { position: visualPosition })
useScene.getState().markDirty(shelfId)
},
canCommit() {
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
if (live?.type !== 'shelf') return false
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
},
commit() {
useLiveNodeOverrides.getState().clear(shelfId)
useScene.getState().updateNodes([{ id: shelfId, data: { position: lastVisualPosition } }])
},
}
return session
}
@@ -2,8 +2,10 @@ import {
type AnyNodeId,
type FloorplanAffordance,
type SpawnNode,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { isAngleSnapActive } from '@pascal-app/editor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
@@ -17,20 +19,22 @@ export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
return {
affectedIds: [spawnId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
lastRotation = initialRotation - delta
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
useLiveNodeOverrides.getState().set(spawnId, { rotation: lastRotation })
useScene.getState().markDirty(spawnId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(spawnId)
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
},
}
+8 -5
View File
@@ -4,9 +4,10 @@ import {
type FloorplanMoveTargetSession,
type SpawnNode,
snapScalar,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor'
export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node }) => {
const spawnId = node.id as AnyNodeId
@@ -16,14 +17,15 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
const session: FloorplanMoveTargetSession = {
affectedIds: [spawnId],
apply({ planPoint, modifiers }) {
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
apply({ planPoint }) {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])]
if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return
lastPosition = next
useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }])
useLiveNodeOverrides.getState().set(spawnId, { position: next })
useScene.getState().markDirty(spawnId)
},
canCommit() {
if (!lastPosition) return false
@@ -31,6 +33,7 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
},
commit() {
if (!lastPosition) return
useLiveNodeOverrides.getState().clear(spawnId)
useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }])
},
}
@@ -4,8 +4,10 @@ import {
type FloorplanAffordanceSession,
type StairNode,
type StairSegmentNode,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { isAngleSnapActive } from '@pascal-app/editor'
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
// Minimums + max sweep mirror the 3D handles in
@@ -73,12 +75,14 @@ export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
const delta = sign * (currentProj - initialProj)
const newWidth = Math.max(MIN_SEGMENT_WIDTH, initialWidth + delta)
lastWidth = newWidth
useScene.getState().updateNode(segmentNodeId, { width: newWidth })
useLiveNodeOverrides.getState().set(segmentNodeId, { width: newWidth })
useScene.getState().markDirty(segmentNodeId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(segmentNodeId)
useScene.getState().updateNode(segmentNodeId, { width: lastWidth })
},
}
@@ -111,12 +115,14 @@ export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
const delta = currentProj - initialProj
const newLength = Math.max(MIN_SEGMENT_LENGTH, initialLength + delta)
lastLength = newLength
useScene.getState().updateNode(segmentNodeId, { length: newLength })
useLiveNodeOverrides.getState().set(segmentNodeId, { length: newLength })
useScene.getState().markDirty(segmentNodeId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(segmentNodeId)
useScene.getState().updateNode(segmentNodeId, { length: lastLength })
},
}
@@ -149,12 +155,14 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
lastWidth = newWidth
useScene.getState().updateNode(stairId, { width: newWidth })
useLiveNodeOverrides.getState().set(stairId, { width: newWidth })
useScene.getState().markDirty(stairId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(stairId)
useScene.getState().updateNode(stairId, { width: lastWidth })
},
}
@@ -200,12 +208,17 @@ export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> =
const newWidth = initialOuterRadius - newInner
lastInner = newInner
lastWidth = newWidth
useScene.getState().updateNode(stairId, { innerRadius: newInner, width: newWidth })
useLiveNodeOverrides.getState().set(stairId, {
innerRadius: newInner,
width: newWidth,
})
useScene.getState().markDirty(stairId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(stairId)
useScene.getState().updateNode(stairId, { innerRadius: lastInner, width: lastWidth })
},
}
@@ -233,21 +246,23 @@ export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
return {
affectedIds: [stairId],
apply({ planPoint, modifiers }) {
apply({ planPoint }) {
const delta = rotateAffordanceDelta({
center: [cx, cz],
initialAngle,
planPoint,
free: modifiers.shiftKey,
free: !isAngleSnapActive(),
})
const newRotation = initialRotation - delta
lastRotation = newRotation
useScene.getState().updateNode(stairId, { rotation: newRotation })
useLiveNodeOverrides.getState().set(stairId, { rotation: newRotation })
useScene.getState().markDirty(stairId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(stairId)
useScene.getState().updateNode(stairId, { rotation: lastRotation })
},
}
@@ -298,12 +313,17 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
const newRotation = initialRotation + rotationShift
lastSweep = newSweep
lastRotation = newRotation
useScene.getState().updateNode(stairId, { sweepAngle: newSweep, rotation: newRotation })
useLiveNodeOverrides.getState().set(stairId, {
sweepAngle: newSweep,
rotation: newRotation,
})
useScene.getState().markDirty(stairId)
},
canCommit() {
return true
},
commit() {
useLiveNodeOverrides.getState().clear(stairId)
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
},
}
+19 -15
View File
@@ -6,9 +6,15 @@ import {
movingAlignmentAnchors,
type StairNode,
snapScalar,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
import {
applyFloorplanAlignment,
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
/**
@@ -17,13 +23,11 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
* Existing stairs preserve the cursor grab offset, matching the 3D move
* tools; fresh catalog placement follows the cursor absolutely.
*
* Figma alignment is layered on the stair footprint edges; Alt bypasses.
* Figma alignment is layered on the stair footprint edges.
* Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
*
* The position is written straight to scene each tick (the stair has a real
* `position` field, unlike polygon kinds) and re-applied atomically via
* `commit()` so the overlay's deterministic revert → resume → commit path
* records a single undo step (same pattern door / window use).
* The position previews through the live override store and is written to
* scene once via `commit()`.
*/
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
const startY = node.position[1]
@@ -37,14 +41,12 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
apply({ planPoint, modifiers }) {
// Snap the origin to the editor's current grid step (driven by
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
apply({ planPoint }) {
const step = isGridSnapActive() ? getSegmentGridStep() : 0
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the actual stair footprint (Alt bypasses alignment; Shift all snap),
// matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
// Figma alignment on the actual stair footprint, matching the 3D move
// tool. Publishes guides via `useAlignmentGuides`.
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
const { point: aligned } = applyFloorplanAlignment(
[gx, gz],
@@ -52,14 +54,15 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates,
{ bypass: modifiers.altKey || modifiers.shiftKey },
{ applySnap: isMagneticSnapActive() },
)
const sx = aligned[0]
const sz = aligned[1]
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
lastValid = { position: [sx, startY, sz] }
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastValid)
useScene.getState().markDirty(node.id as AnyNodeId)
},
canCommit() {
// No overlap / placement rules for stairs in 2D — any pointer-up
@@ -72,6 +75,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
// commit-path (revert → resume → session.commit()). Same pattern
// door / window use.
if (!lastValid) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
},
}
@@ -12,6 +12,7 @@ export const structuralGridDefinition: NodeDefinition<typeof StructuralGridNode>
extensions: {
'pascal:editor/floorplan': {
tool: () => import('./floorplan-tool'),
availableModes: ['expert'],
preferredView: '2d',
} satisfies FloorplanNodeExtension<StructuralGridNode>,
},
@@ -14,34 +14,9 @@ function wall(overrides: Partial<WallNodeType>): WallNodeType {
parentId: 'level_main',
start: [0, 0],
end: [1, 0],
thickness: 0.2,
frontSide: 'interior',
backSide: 'interior',
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.2,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
{
id: 'interior-finish',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-finish',
role: 'exterior-finish',
side: 'exterior',
thickness: 0.04,
materialRef: 'library:cladding',
datumEligible: ['finish-face'],
},
],
...overrides,
})
}
@@ -88,42 +63,9 @@ function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false)
id: 'wall_partition',
start: [4, partitionSpansPlan ? -6 : -4],
end: [4, 0],
thickness: 0.12,
frontSide: 'interior',
backSide: 'interior',
assemblyLayers: [
{
id: 'partition-stud-core',
role: 'structure',
side: 'core',
thickness: 0.12,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
{
id: 'partition-finish-left',
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'partition-finish-right',
role: 'interior-finish',
side: 'exterior',
thickness: 0.02,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'partition-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.1,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const walls = [top, ...(topContinuation ? [topContinuation] : []), right, bottom, left, partition]
const nodes = Object.fromEntries(walls.map((candidate) => [candidate.id, candidate])) as Record<
@@ -172,12 +114,12 @@ describe('automatic wall dimension reference policy', () => {
return entries[0]?.end[0]
}
expect(intersection('finish-face')).toBeCloseTo(3.92)
expect(intersection('finish-face')).toBeCloseTo(3.94)
expect(intersection('centerline')).toBeCloseTo(4)
expect(intersection('structural-face')).toBeCloseTo(3.94)
})
test('keeps finished faces, centerline, and face of stud as distinct display modes', () => {
test('keeps centerline distinct while all face modes use the wall face', () => {
const { nodes, top, walls } = topFacadeFixture(true)
const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes })
const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
@@ -191,9 +133,9 @@ describe('automatic wall dimension reference policy', () => {
}
expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([
'3.92m',
'0.26m',
'6.02m',
'4.04m',
'0.12m',
'6.04m',
])
expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m'])
expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([
@@ -306,16 +248,7 @@ describe('automatic wall dimension reference policy', () => {
id: 'wall_horizontal_partition',
start: [0, -3],
end: [10, -3],
assemblyLayers: [
{
id: 'horizontal-stud-core',
role: 'structure',
side: 'core',
thickness: 0.12,
materialRef: 'library:stud',
datumEligible: ['structural-face'],
},
],
thickness: 0.12,
})
const horizontalWalls = [top, right, bottom, left, partition]
const horizontalNodes = Object.fromEntries(

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