Compare commits
25
Commits
f6d28f6794
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dea46a451 | ||
|
|
c848a33f06 | ||
|
|
aec4aa2b5a | ||
|
|
e334a2371d | ||
|
|
4e11b333bb | ||
|
|
c6a70579cc | ||
|
|
fb7aa22946 | ||
|
|
0addef89a4 | ||
|
|
ab76686b8b | ||
|
|
daa1f3e99b | ||
|
|
7309fc8f1d | ||
|
|
f0985df887 | ||
|
|
99812cd4cb | ||
|
|
b95d737eb6 | ||
|
|
3b70deb837 | ||
|
|
28ab27f5ec | ||
|
|
d0afde192e | ||
|
|
7cfb88dcad | ||
|
|
8b8ef23a59 | ||
|
|
8715d481ba | ||
|
|
c42b3f01c9 | ||
|
|
4b27966f9a | ||
|
|
08d1bbf017 | ||
|
|
3126b0b430 | ||
|
|
4be0e55836 |
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.git
|
||||
.next
|
||||
.turbo
|
||||
dist
|
||||
*.log
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
*.md
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/editor.iml" filepath="$PROJECT_DIR$/.idea/editor.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# ---- Сборка ----
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем только нужное для установки
|
||||
COPY package.json bun.lock ./
|
||||
COPY turbo.json ./
|
||||
COPY biome.jsonc ./
|
||||
|
||||
# Копируем исходники
|
||||
COPY packages ./packages
|
||||
COPY tooling ./tooling
|
||||
COPY apps ./apps
|
||||
|
||||
# Устанавливаем зависимости
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Исправляем путь в globals.css (заменяем @/ на ../)
|
||||
RUN sed -i 's|@/styles/elevation.css|../styles/elevation.css|g' apps/editor/app/globals.css
|
||||
|
||||
# Создаём символическую ссылку, чтобы папка styles была доступна из apps/editor
|
||||
RUN ln -s /app/styles /app/apps/editor/styles
|
||||
|
||||
# Собираем
|
||||
RUN bun run build
|
||||
|
||||
# ---- Продакшен ----
|
||||
FROM oven/bun:1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем собранные артефакты
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/tooling ./tooling
|
||||
COPY --from=builder /app/apps ./apps
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/bun.lock ./
|
||||
|
||||
WORKDIR /app/apps/editor
|
||||
|
||||
EXPOSE 3002
|
||||
CMD ["bun", "run", "start"]
|
||||
@@ -1,3 +1,14 @@
|
||||
# Быстрая установка из готового образа:
|
||||
|
||||
###
|
||||
Скачать образ: https://cdn.s3.mshart.ru/cloud/docker/images/pascal-editor.tar
|
||||
###
|
||||
залить на сервер и выполнить:
|
||||
```bash
|
||||
docker load -i pascal-editor.tar
|
||||
docker run -d --name pascal-editor -p 3002:3000 editor-editor:latest
|
||||
```
|
||||
|
||||
# Pascal Editor
|
||||
|
||||
A 3D building editor built with React Three Fiber and WebGPU.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
editor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: pascal-editor
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3002:3002"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
@@ -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": {
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsgo --build --watch",
|
||||
"test": "bun test",
|
||||
"test": "bun test src",
|
||||
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -222,7 +220,6 @@ type CameraControlEvents = {
|
||||
'camera-controls:orbit-ccw': undefined
|
||||
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
||||
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
||||
'camera-controls:pose': CameraPose
|
||||
'camera-controls:apply-pose': CameraPose
|
||||
'camera-controls:cancel-pose': undefined
|
||||
'camera-controls:interaction-start': undefined
|
||||
@@ -277,6 +274,12 @@ type RoomPresetEvents = {
|
||||
}
|
||||
|
||||
type SelectionEvents = {
|
||||
/**
|
||||
* A node click accepted by an editor canvas selection path after proxy and
|
||||
* phase routing. Hosts can react to the user's 2D/3D selection intent
|
||||
* without treating programmatic selection changes as canvas clicks.
|
||||
*/
|
||||
'selection:canvas-node-click': AnyNode
|
||||
/**
|
||||
* "Reveal this node" intent — the editor's node action menu emits it with the
|
||||
* selected node; whoever owns the node's catalog/panel (host browser, a
|
||||
@@ -322,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> &
|
||||
|
||||
@@ -12,7 +12,6 @@ export type {
|
||||
ConstructionDimensionEvent,
|
||||
DoorEvent,
|
||||
DormerEvent,
|
||||
DrawingSheetEvent,
|
||||
ElevatorEvent,
|
||||
EventSuffix,
|
||||
FenceEvent,
|
||||
@@ -230,12 +229,15 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
|
||||
export {
|
||||
type ApplySceneSnapshotOptions,
|
||||
acquireSceneReadOnlyLease,
|
||||
applySceneOperationPatch,
|
||||
applyScenePatch,
|
||||
applySceneSnapshot,
|
||||
clearSceneHistory,
|
||||
default as useScene,
|
||||
type SceneMaterialPatch,
|
||||
type SceneNodePatch,
|
||||
type SceneNodeStructuralPatch,
|
||||
type SceneOperationPatch,
|
||||
type ScenePatch,
|
||||
} from './store/use-scene'
|
||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||
|
||||
@@ -66,9 +66,12 @@ export const MATERIAL_CATEGORIES = [
|
||||
'stone',
|
||||
'brick',
|
||||
'tile',
|
||||
'wallpaper',
|
||||
'concrete',
|
||||
'metal',
|
||||
'plastic',
|
||||
'fabric',
|
||||
'carpet',
|
||||
'leather',
|
||||
'roofing',
|
||||
'ground',
|
||||
|
||||
@@ -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], {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,14 @@ describe('LevelNode', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts level child IDs minted by plugins', () => {
|
||||
const children = LevelNode.parse({
|
||||
children: ['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'],
|
||||
}).children as string[]
|
||||
|
||||
expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'])
|
||||
})
|
||||
|
||||
test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
|
||||
expect('height' in LevelNode.parse({})).toBe(false)
|
||||
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { CeilingNode } from './ceiling'
|
||||
import { ColumnNode } from './column'
|
||||
import { ConstructionDimensionNode } from './construction-dimension'
|
||||
import { DuctFittingNode } from './duct-fitting'
|
||||
import { DuctSegmentNode } from './duct-segment'
|
||||
import { DuctTerminalNode } from './duct-terminal'
|
||||
import { FenceNode } from './fence'
|
||||
import { GuideNode } from './guide'
|
||||
import { HvacEquipmentNode } from './hvac-equipment'
|
||||
import { ItemNode } from './item'
|
||||
import { LinesetNode } from './lineset'
|
||||
import { LiquidLineNode } from './liquid-line'
|
||||
import { MeasurementNode } from './measurement'
|
||||
import { PipeFittingNode } from './pipe-fitting'
|
||||
import { PipeSegmentNode } from './pipe-segment'
|
||||
import { PipeTrapNode } from './pipe-trap'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { ShelfNode } from './shelf'
|
||||
import { SlabNode } from './slab'
|
||||
import { SpawnNode } from './spawn'
|
||||
import { StairNode } from './stair'
|
||||
import { StructuralGridNode } from './structural-grid'
|
||||
import { WallNode } from './wall'
|
||||
import { ZoneNode } from './zone'
|
||||
import type { CeilingNode } from './ceiling'
|
||||
import type { ColumnNode } from './column'
|
||||
import type { ConstructionDimensionNode } from './construction-dimension'
|
||||
import type { DuctFittingNode } from './duct-fitting'
|
||||
import type { DuctSegmentNode } from './duct-segment'
|
||||
import type { DuctTerminalNode } from './duct-terminal'
|
||||
import type { FenceNode } from './fence'
|
||||
import type { GuideNode } from './guide'
|
||||
import type { HvacEquipmentNode } from './hvac-equipment'
|
||||
import type { ItemNode } from './item'
|
||||
import type { LinesetNode } from './lineset'
|
||||
import type { LiquidLineNode } from './liquid-line'
|
||||
import type { MeasurementNode } from './measurement'
|
||||
import type { PipeFittingNode } from './pipe-fitting'
|
||||
import type { PipeSegmentNode } from './pipe-segment'
|
||||
import type { PipeTrapNode } from './pipe-trap'
|
||||
import type { RoofNode } from './roof'
|
||||
import type { ScanNode } from './scan'
|
||||
import type { ShelfNode } from './shelf'
|
||||
import type { SlabNode } from './slab'
|
||||
import type { SpawnNode } from './spawn'
|
||||
import type { StairNode } from './stair'
|
||||
import type { StructuralGridNode } from './structural-grid'
|
||||
import type { WallNode } from './wall'
|
||||
import type { ZoneNode } from './zone'
|
||||
|
||||
type CoreLevelChildId =
|
||||
| WallNode['id']
|
||||
| FenceNode['id']
|
||||
| ColumnNode['id']
|
||||
| ConstructionDimensionNode['id']
|
||||
| StructuralGridNode['id']
|
||||
| ItemNode['id']
|
||||
| ZoneNode['id']
|
||||
| SlabNode['id']
|
||||
| CeilingNode['id']
|
||||
| RoofNode['id']
|
||||
| StairNode['id']
|
||||
| ScanNode['id']
|
||||
| GuideNode['id']
|
||||
| MeasurementNode['id']
|
||||
| SpawnNode['id']
|
||||
| ShelfNode['id']
|
||||
| DuctSegmentNode['id']
|
||||
| DuctFittingNode['id']
|
||||
| DuctTerminalNode['id']
|
||||
| HvacEquipmentNode['id']
|
||||
| LinesetNode['id']
|
||||
| LiquidLineNode['id']
|
||||
| PipeSegmentNode['id']
|
||||
| PipeFittingNode['id']
|
||||
| PipeTrapNode['id']
|
||||
|
||||
const LevelChildId = z.string().transform((id) => id as CoreLevelChildId)
|
||||
|
||||
export const LevelNode = BaseNode.extend({
|
||||
id: objectId('level'),
|
||||
type: nodeType('level'),
|
||||
children: z
|
||||
.array(
|
||||
z.union([
|
||||
WallNode.shape.id,
|
||||
FenceNode.shape.id,
|
||||
ColumnNode.shape.id,
|
||||
ConstructionDimensionNode.shape.id,
|
||||
StructuralGridNode.shape.id,
|
||||
ItemNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
CeilingNode.shape.id,
|
||||
RoofNode.shape.id,
|
||||
StairNode.shape.id,
|
||||
ScanNode.shape.id,
|
||||
GuideNode.shape.id,
|
||||
MeasurementNode.shape.id,
|
||||
SpawnNode.shape.id,
|
||||
ShelfNode.shape.id,
|
||||
DuctSegmentNode.shape.id,
|
||||
DuctFittingNode.shape.id,
|
||||
DuctTerminalNode.shape.id,
|
||||
HvacEquipmentNode.shape.id,
|
||||
LinesetNode.shape.id,
|
||||
LiquidLineNode.shape.id,
|
||||
PipeSegmentNode.shape.id,
|
||||
PipeFittingNode.shape.id,
|
||||
PipeTrapNode.shape.id,
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
// The node registry owns child-kind validity. Persisted level relationships
|
||||
// must also admit IDs minted by plugins that core cannot enumerate.
|
||||
children: z.array(LevelChildId).default([]),
|
||||
// Specific props
|
||||
level: z.number().default(0),
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { nodeRegistry } from '../registry/registry'
|
||||
import type { AnyNodeDefinition } from '../registry/types'
|
||||
import { BuildingNode } from '../schema/nodes/building'
|
||||
import { LevelNode } from '../schema/nodes/level'
|
||||
import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material'
|
||||
@@ -16,6 +19,7 @@ import useLiveNodeOverrides from './use-live-node-overrides'
|
||||
import useLiveTransforms from './use-live-transforms'
|
||||
import useScene, {
|
||||
acquireSceneReadOnlyLease,
|
||||
applySceneOperationPatch,
|
||||
applyScenePatch,
|
||||
applySceneSnapshot,
|
||||
clearSceneHistory,
|
||||
@@ -141,8 +145,6 @@ describe('scene commit boundary', () => {
|
||||
test('applies host patches without local history and marks node and parent dirty', () => {
|
||||
const commits: SceneCommit[] = []
|
||||
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
|
||||
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 99 })
|
||||
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
|
||||
|
||||
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
|
||||
true,
|
||||
@@ -154,8 +156,6 @@ describe('scene commit boundary', () => {
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
|
||||
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(true)
|
||||
expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toBeUndefined()
|
||||
expect(useLiveTransforms.getState().get(LEVEL_ID)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('applies material patches atomically and dirties nodes that reference them', () => {
|
||||
@@ -320,19 +320,343 @@ describe('scene commit boundary', () => {
|
||||
expect(commits).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('defers host patches while a local interaction has history paused', () => {
|
||||
test('applies a disjoint host patch while a local interaction has history paused', () => {
|
||||
useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false })
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
|
||||
true,
|
||||
)
|
||||
expect(levelNumber()).toBe(7)
|
||||
expect(useScene.temporal.getState().isTracking).toBe(false)
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
expect(useLiveNodeOverrides.getState().get(BUILDING_ID)).toEqual({ visible: false })
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
})
|
||||
|
||||
test('defers a host patch that collides with a live node or structural parent', () => {
|
||||
pauseSceneHistory(useScene)
|
||||
try {
|
||||
useLiveNodeOverrides.getState().set(LEVEL_ID, { level: 9 })
|
||||
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
|
||||
false,
|
||||
)
|
||||
expect(levelNumber()).toBe(0)
|
||||
expect(useScene.temporal.getState().isTracking).toBe(false)
|
||||
expect(useLiveNodeOverrides.getState().get(LEVEL_ID)).toEqual({ level: 9 })
|
||||
|
||||
useLiveNodeOverrides.getState().clear(LEVEL_ID)
|
||||
useLiveTransforms.getState().set(LEVEL_ID, { position: [1, 0, 1], rotation: 0 })
|
||||
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
|
||||
false,
|
||||
)
|
||||
expect(useLiveTransforms.getState().get(LEVEL_ID)).toEqual({
|
||||
position: [1, 0, 1],
|
||||
rotation: 0,
|
||||
})
|
||||
|
||||
useLiveTransforms.getState().clear(LEVEL_ID)
|
||||
useLiveNodeOverrides.getState().set(BUILDING_ID, { visible: false })
|
||||
const child = LevelNode.parse({
|
||||
id: 'level_live_parent',
|
||||
parentId: BUILDING_ID,
|
||||
children: [],
|
||||
level: 1,
|
||||
})
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [{ node: child, position: 1 }],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(useScene.getState().nodes[child.id]).toBeUndefined()
|
||||
expect(useLiveNodeOverrides.getState().get(BUILDING_ID)).toEqual({ visible: false })
|
||||
|
||||
const existingChild = useScene.getState().nodes[LEVEL_ID] as AnyNode
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [{ node: existingChild, position: 0 }],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(useScene.getState().nodes[LEVEL_ID]).toBe(existingChild)
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
})
|
||||
|
||||
test('validates registered creates and updates without stripping forward-compatible fields', () => {
|
||||
const kind = 'test:operation-forward-compatible'
|
||||
if (!nodeRegistry.has(kind)) {
|
||||
nodeRegistry._register({
|
||||
capabilities: {},
|
||||
category: 'utility',
|
||||
defaults: () => ({}),
|
||||
kind,
|
||||
schema: z.object({
|
||||
id: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
object: z.literal('node').default('node'),
|
||||
parentId: z.string().nullable().default(null),
|
||||
pluginValue: z.number(),
|
||||
type: z.literal(kind),
|
||||
visible: z.boolean().default(true),
|
||||
}),
|
||||
schemaVersion: 1,
|
||||
} as unknown as AnyNodeDefinition)
|
||||
}
|
||||
const id = 'plugin_forward_compatible' as AnyNodeId
|
||||
const node = {
|
||||
forwardCompatible: { retained: true },
|
||||
id,
|
||||
metadata: {},
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
pluginValue: 1,
|
||||
type: kind,
|
||||
visible: true,
|
||||
} as unknown as AnyNode
|
||||
useScene.setState({
|
||||
collections: {},
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
materials: {},
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
})
|
||||
clearSceneHistory()
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [{ node, position: 0 }],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(useScene.getState().nodes[id]).toEqual(node)
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [
|
||||
{
|
||||
data: { pluginValue: 2 } as Partial<AnyNode>,
|
||||
id,
|
||||
removeFields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(useScene.getState().nodes[id]).toMatchObject({
|
||||
forwardCompatible: { retained: true },
|
||||
pluginValue: 2,
|
||||
})
|
||||
})
|
||||
|
||||
test('applies exact structural, field, and material changes in one host commit', () => {
|
||||
const replacementId = 'level_replacement' as AnyNodeId
|
||||
const replacement = LevelNode.parse({
|
||||
id: replacementId,
|
||||
parentId: BUILDING_ID,
|
||||
children: [],
|
||||
level: 1,
|
||||
})
|
||||
const materialId = 'mat_operation' as SceneMaterialId
|
||||
const material = SceneMaterial.parse({
|
||||
id: materialId,
|
||||
name: 'Operation material',
|
||||
material: { properties: { color: '#112233' } },
|
||||
})
|
||||
const deleted = useScene.getState().nodes[LEVEL_ID] as AnyNode
|
||||
const commits: SceneCommit[] = []
|
||||
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [{ id: materialId, material }],
|
||||
nodeCreates: [{ node: replacement, position: 0 }],
|
||||
nodeDeletes: [{ node: deleted, position: 0 }],
|
||||
nodeUpdates: [
|
||||
{
|
||||
id: BUILDING_ID,
|
||||
data: { visible: false } as Partial<AnyNode>,
|
||||
removeFields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
const state = useScene.getState()
|
||||
expect(state.nodes[LEVEL_ID]).toBeUndefined()
|
||||
expect(state.nodes[replacementId]).toEqual(replacement)
|
||||
expect((state.nodes[BUILDING_ID] as { children: AnyNodeId[] }).children).toEqual([
|
||||
replacementId,
|
||||
])
|
||||
expect(state.nodes[BUILDING_ID]?.visible).toBe(false)
|
||||
expect(state.materials[materialId]).toEqual(material)
|
||||
expect(state.rootNodeIds).toEqual([BUILDING_ID])
|
||||
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('does not leave deleted ancestors in the dirty set after subtree deletion', () => {
|
||||
const building = useScene.getState().nodes[BUILDING_ID] as AnyNode
|
||||
const level = useScene.getState().nodes[LEVEL_ID] as AnyNode
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [
|
||||
{ node: building, position: 0 },
|
||||
{ node: level, position: 0 },
|
||||
],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(useScene.getState().nodes).toEqual({})
|
||||
expect(useScene.getState().rootNodeIds).toEqual([])
|
||||
expect(useScene.getState().dirtyNodes.has(BUILDING_ID)).toBe(false)
|
||||
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(false)
|
||||
})
|
||||
|
||||
test('dirties surviving siblings after a remote structural deletion', () => {
|
||||
const siblingId = 'level_surviving_sibling' as AnyNodeId
|
||||
const sibling = LevelNode.parse({
|
||||
id: siblingId,
|
||||
parentId: BUILDING_ID,
|
||||
children: [],
|
||||
level: 1,
|
||||
})
|
||||
const building = useScene.getState().nodes[BUILDING_ID] as AnyNode
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
...useScene.getState().nodes,
|
||||
[BUILDING_ID]: { ...building, children: [LEVEL_ID, siblingId] },
|
||||
[siblingId]: sibling,
|
||||
},
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
})
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [{ node: useScene.getState().nodes[LEVEL_ID] as AnyNode, position: 0 }],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(useScene.getState().dirtyNodes.has(siblingId)).toBe(true)
|
||||
})
|
||||
|
||||
test('preserves an external tool history pause while applying a remote patch', () => {
|
||||
useScene.temporal.getState().pause()
|
||||
expect(useScene.temporal.getState().isTracking).toBe(false)
|
||||
|
||||
try {
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [
|
||||
{
|
||||
data: { level: 2 } as Partial<AnyNode>,
|
||||
id: LEVEL_ID,
|
||||
removeFields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(useScene.temporal.getState().isTracking).toBe(false)
|
||||
} finally {
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects an invalid structural operation before mutating any field or material', () => {
|
||||
const missingParentId = 'building_missing' as AnyNodeId
|
||||
const orphan = LevelNode.parse({
|
||||
id: 'level_orphan',
|
||||
parentId: missingParentId,
|
||||
children: [],
|
||||
level: 1,
|
||||
})
|
||||
const materialId = 'mat_rejected' as SceneMaterialId
|
||||
const material = SceneMaterial.parse({
|
||||
id: materialId,
|
||||
name: 'Rejected material',
|
||||
material: { properties: { color: '#abcdef' } },
|
||||
})
|
||||
const before = currentSnapshot()
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [{ id: materialId, material }],
|
||||
nodeCreates: [{ node: orphan, position: 0 }],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [
|
||||
{
|
||||
id: LEVEL_ID,
|
||||
data: { level: 5 } as Partial<AnyNode>,
|
||||
removeFields: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
expect(currentSnapshot()).toEqual(before)
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('keeps dirty work bounded when structurally patching a 10k-node scene', () => {
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {}
|
||||
const rootNodeIds: AnyNodeId[] = []
|
||||
for (let index = 0; index < 10_000; index += 1) {
|
||||
const id = `level_scale_${index}` as AnyNodeId
|
||||
nodes[id] = LevelNode.parse({ id, parentId: null, children: [], level: index })
|
||||
rootNodeIds.push(id)
|
||||
}
|
||||
useScene.setState({
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
materials: {},
|
||||
})
|
||||
clearSceneHistory()
|
||||
const created = LevelNode.parse({
|
||||
id: 'level_scale_created',
|
||||
parentId: null,
|
||||
children: [],
|
||||
level: 10_000,
|
||||
})
|
||||
|
||||
expect(
|
||||
applySceneOperationPatch({
|
||||
materialChanges: [],
|
||||
nodeCreates: [{ node: created, position: rootNodeIds.length }],
|
||||
nodeDeletes: [],
|
||||
nodeUpdates: [],
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(useScene.getState().rootNodeIds.at(-1)).toBe(created.id)
|
||||
expect([...useScene.getState().dirtyNodes]).toEqual([created.id])
|
||||
expect(useScene.getState().nodes.level_scale_5000).toBe(nodes.level_scale_5000)
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('applies a host snapshot as a history floor and clears live state', () => {
|
||||
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
|
||||
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode } from '../schema'
|
||||
import useScene from './use-scene'
|
||||
|
||||
describe('legacy site child migration', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('keeps a flat building subtree referenced by an embedded site child', () => {
|
||||
const embeddedBuilding = {
|
||||
object: 'node',
|
||||
id: 'building_legacy',
|
||||
type: 'building',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['level_legacy'],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}
|
||||
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
site_legacy: {
|
||||
object: 'node',
|
||||
id: 'site_legacy',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [embeddedBuilding],
|
||||
},
|
||||
building_legacy: embeddedBuilding,
|
||||
level_legacy: {
|
||||
object: 'node',
|
||||
id: 'level_legacy',
|
||||
type: 'level',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['wall_legacy'],
|
||||
level: 0,
|
||||
},
|
||||
wall_legacy: {
|
||||
object: 'node',
|
||||
id: 'wall_legacy',
|
||||
type: 'wall',
|
||||
parentId: 'level_legacy',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
},
|
||||
} as unknown as Record<string, AnyNode>,
|
||||
['site_legacy'] as never,
|
||||
)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
expect(Object.keys(nodes).sort()).toEqual([
|
||||
'building_legacy',
|
||||
'level_legacy',
|
||||
'site_legacy',
|
||||
'wall_legacy',
|
||||
])
|
||||
expect((nodes.site_legacy as { children: string[] }).children).toEqual(['building_legacy'])
|
||||
})
|
||||
})
|
||||
@@ -28,10 +28,10 @@ import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema
|
||||
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
|
||||
import {
|
||||
generateSceneMaterialId,
|
||||
type SceneMaterial,
|
||||
SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
} from '../schema/scene-material'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types'
|
||||
import { deriveLegacyLevelHeight } from '../services/level-height'
|
||||
import { getCeilingClampBound } from '../services/storey'
|
||||
import { computeWallSlabSupport } from '../systems/slab/slab-support'
|
||||
@@ -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
|
||||
@@ -1594,76 +1634,345 @@ export type ScenePatch = {
|
||||
nodeUpdates: SceneNodePatch[]
|
||||
}
|
||||
|
||||
export function applyScenePatch(changes: ScenePatch): boolean {
|
||||
const beforeState = useScene.getState()
|
||||
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => {
|
||||
const node = beforeState.nodes[id]
|
||||
if (!node) return true
|
||||
if ('id' in data && data.id !== node.id) return true
|
||||
if ('type' in data && data.type !== node.type) return true
|
||||
if ('object' in data && data.object !== node.object) return true
|
||||
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) {
|
||||
return true
|
||||
export type SceneNodeStructuralPatch = {
|
||||
node: AnyNode
|
||||
position: number
|
||||
}
|
||||
|
||||
export type SceneOperationPatch = ScenePatch & {
|
||||
nodeCreates: SceneNodeStructuralPatch[]
|
||||
nodeDeletes: SceneNodeStructuralPatch[]
|
||||
}
|
||||
|
||||
function sceneOperationPatchLiveConflictIds(
|
||||
beforeState: SceneState,
|
||||
changes: SceneOperationPatch,
|
||||
): Set<AnyNodeId> {
|
||||
const ids = new Set<AnyNodeId>()
|
||||
const addNodeAndParent = (node: AnyNode | undefined) => {
|
||||
if (!node) return
|
||||
ids.add(node.id)
|
||||
if (node.parentId) ids.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
for (const { id, data } of changes.nodeUpdates) {
|
||||
ids.add(id)
|
||||
if (Object.hasOwn(data, 'parentId')) {
|
||||
const currentParentId = beforeState.nodes[id]?.parentId
|
||||
if (currentParentId) ids.add(currentParentId as AnyNodeId)
|
||||
if (typeof data.parentId === 'string') ids.add(data.parentId as AnyNodeId)
|
||||
}
|
||||
return removeFields.some((field) => Object.hasOwn(data, field))
|
||||
})
|
||||
const hasInvalidMaterialTarget = changes.materialChanges.some(
|
||||
({ id, material }) => material !== null && material.id !== id,
|
||||
}
|
||||
for (const { node } of changes.nodeCreates) addNodeAndParent(node)
|
||||
for (const { node } of changes.nodeDeletes) addNodeAndParent(node)
|
||||
return ids
|
||||
}
|
||||
|
||||
function sceneOperationPatchHasLiveConflict(
|
||||
beforeState: SceneState,
|
||||
changes: SceneOperationPatch,
|
||||
): boolean {
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
const transforms = useLiveTransforms.getState()
|
||||
for (const id of sceneOperationPatchLiveConflictIds(beforeState, changes)) {
|
||||
if (overrides.get(id) || transforms.get(id)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function areScenePatchValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true
|
||||
if (typeof left !== typeof right || left === null || right === null) return false
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return (
|
||||
Array.isArray(left) &&
|
||||
Array.isArray(right) &&
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => areScenePatchValuesEqual(value, right[index]))
|
||||
)
|
||||
}
|
||||
if (typeof left !== 'object' || typeof right !== 'object') return false
|
||||
const leftRecord = left as Record<string, unknown>
|
||||
const rightRecord = right as Record<string, unknown>
|
||||
const leftKeys = Object.keys(leftRecord)
|
||||
if (leftKeys.length !== Object.keys(rightRecord).length) return false
|
||||
return leftKeys.every(
|
||||
(key) =>
|
||||
Object.hasOwn(rightRecord, key) &&
|
||||
areScenePatchValuesEqual(leftRecord[key], rightRecord[key]),
|
||||
)
|
||||
}
|
||||
|
||||
function parseSceneOperationPatchNode(value: unknown): AnyNode | null {
|
||||
const builtin = AnyNodeSchema.safeParse(value)
|
||||
if (builtin.success) return builtin.data
|
||||
if (!(value && typeof value === 'object' && !Array.isArray(value))) return null
|
||||
const type = (value as { type?: unknown }).type
|
||||
if (typeof type !== 'string') return null
|
||||
const registered = nodeRegistry.get(type)?.schema.safeParse(value)
|
||||
return registered?.success ? (registered.data as AnyNode) : null
|
||||
}
|
||||
|
||||
function structuralSiblingIds(
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
rootNodeIds: AnyNodeId[],
|
||||
parentId: AnyNodeId | null,
|
||||
): AnyNodeId[] | null {
|
||||
if (!parentId) return rootNodeIds
|
||||
const parent = nodes[parentId]
|
||||
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
|
||||
return parent.children.every((id) => typeof id === 'string')
|
||||
? (parent.children as AnyNodeId[])
|
||||
: null
|
||||
}
|
||||
|
||||
function insertSceneStructuralPlacements(
|
||||
base: AnyNodeId[],
|
||||
placements: readonly SceneNodeStructuralPatch[],
|
||||
): AnyNodeId[] | null {
|
||||
if (placements.length === 0) return base
|
||||
const result = new Array<AnyNodeId | undefined>(base.length + placements.length)
|
||||
for (const change of placements) {
|
||||
if (
|
||||
!Number.isSafeInteger(change.position) ||
|
||||
change.position < 0 ||
|
||||
change.position >= result.length ||
|
||||
result[change.position] !== undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
result[change.position] = change.node.id
|
||||
}
|
||||
let baseIndex = 0
|
||||
for (let index = 0; index < result.length; index += 1) {
|
||||
if (result[index] !== undefined) continue
|
||||
result[index] = base[baseIndex]
|
||||
baseIndex += 1
|
||||
}
|
||||
return result as AnyNodeId[]
|
||||
}
|
||||
|
||||
function sceneOperationPatchNextState(
|
||||
beforeState: SceneState,
|
||||
changes: SceneOperationPatch,
|
||||
): Pick<SceneState, 'materials' | 'nodes' | 'rootNodeIds'> | null {
|
||||
const createIds = new Set<AnyNodeId>()
|
||||
const deleteIds = new Set<AnyNodeId>()
|
||||
const updateIds = new Set<AnyNodeId>()
|
||||
const materialIds = new Set<SceneMaterialId>()
|
||||
const parsedCreates: SceneNodeStructuralPatch[] = []
|
||||
|
||||
for (const change of changes.nodeCreates) {
|
||||
const parsed = parseSceneOperationPatchNode(change.node)
|
||||
if (
|
||||
!parsed ||
|
||||
parsed.id !== change.node.id ||
|
||||
createIds.has(parsed.id) ||
|
||||
Object.hasOwn(beforeState.nodes, parsed.id) ||
|
||||
!Number.isSafeInteger(change.position) ||
|
||||
change.position < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
createIds.add(parsed.id)
|
||||
parsedCreates.push({ node: change.node, position: change.position })
|
||||
}
|
||||
for (const change of changes.nodeDeletes) {
|
||||
const id = change.node.id
|
||||
const current = beforeState.nodes[id]
|
||||
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
|
||||
const siblings = structuralSiblingIds(beforeState.nodes, beforeState.rootNodeIds, parentId)
|
||||
if (
|
||||
!current ||
|
||||
createIds.has(id) ||
|
||||
deleteIds.has(id) ||
|
||||
!Number.isSafeInteger(change.position) ||
|
||||
change.position < 0 ||
|
||||
siblings?.[change.position] !== id ||
|
||||
!areScenePatchValuesEqual(current, change.node)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
deleteIds.add(id)
|
||||
}
|
||||
for (const id of createIds) {
|
||||
if (deleteIds.has(id)) return null
|
||||
}
|
||||
for (const node of Object.values(beforeState.nodes)) {
|
||||
const parentId = (node.parentId as AnyNodeId | null | undefined) ?? null
|
||||
if (parentId && deleteIds.has(parentId) && !deleteIds.has(node.id)) return null
|
||||
}
|
||||
|
||||
const nextNodes = { ...beforeState.nodes }
|
||||
let nextRootNodeIds =
|
||||
deleteIds.size > 0
|
||||
? beforeState.rootNodeIds.filter((id) => !deleteIds.has(id))
|
||||
: beforeState.rootNodeIds
|
||||
const changedParentIds = new Set<AnyNodeId>()
|
||||
for (const change of changes.nodeDeletes) {
|
||||
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
|
||||
if (parentId && !deleteIds.has(parentId)) changedParentIds.add(parentId)
|
||||
delete nextNodes[change.node.id]
|
||||
}
|
||||
for (const parentId of changedParentIds) {
|
||||
const parent = nextNodes[parentId]
|
||||
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
|
||||
nextNodes[parentId] = {
|
||||
...parent,
|
||||
children: (parent.children as AnyNodeId[]).filter((id) => !deleteIds.has(id)),
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
for (const change of parsedCreates) nextNodes[change.node.id] = change.node
|
||||
const rootCreates: SceneNodeStructuralPatch[] = []
|
||||
const existingParentCreates = new Map<AnyNodeId, SceneNodeStructuralPatch[]>()
|
||||
for (const change of parsedCreates) {
|
||||
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
|
||||
if (!parentId) {
|
||||
rootCreates.push(change)
|
||||
continue
|
||||
}
|
||||
const parent = nextNodes[parentId]
|
||||
if (!parent) return null
|
||||
if (createIds.has(parentId)) {
|
||||
if (
|
||||
!('children' in parent) ||
|
||||
!Array.isArray(parent.children) ||
|
||||
parent.children[change.position] !== change.node.id
|
||||
) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
const placements = existingParentCreates.get(parentId) ?? []
|
||||
placements.push(change)
|
||||
existingParentCreates.set(parentId, placements)
|
||||
}
|
||||
const insertedRoots = insertSceneStructuralPlacements(nextRootNodeIds, rootCreates)
|
||||
if (!insertedRoots) return null
|
||||
nextRootNodeIds = insertedRoots
|
||||
for (const [parentId, placements] of existingParentCreates) {
|
||||
const parent = nextNodes[parentId]
|
||||
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
|
||||
const children = insertSceneStructuralPlacements(parent.children as AnyNodeId[], placements)
|
||||
if (!children) return null
|
||||
nextNodes[parentId] = { ...parent, children } as AnyNode
|
||||
}
|
||||
for (const change of parsedCreates) {
|
||||
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
|
||||
const siblings = structuralSiblingIds(nextNodes, nextRootNodeIds, parentId)
|
||||
if (siblings?.[change.position] !== change.node.id) return null
|
||||
if (!('children' in change.node && Array.isArray(change.node.children))) continue
|
||||
for (const childId of change.node.children as AnyNodeId[]) {
|
||||
if (nextNodes[childId]?.parentId !== change.node.id) return null
|
||||
}
|
||||
}
|
||||
|
||||
for (const { id, data, removeFields } of changes.nodeUpdates) {
|
||||
const node = nextNodes[id]
|
||||
if (
|
||||
!node ||
|
||||
createIds.has(id) ||
|
||||
deleteIds.has(id) ||
|
||||
updateIds.has(id) ||
|
||||
('id' in data && data.id !== node.id) ||
|
||||
('type' in data && data.type !== node.type) ||
|
||||
('object' in data && data.object !== node.object) ||
|
||||
removeFields.some(
|
||||
(field) =>
|
||||
field === 'id' || field === 'object' || field === 'type' || Object.hasOwn(data, field),
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
updateIds.add(id)
|
||||
const candidate = { ...node, ...data } as Record<string, unknown>
|
||||
for (const field of removeFields) delete candidate[field]
|
||||
const validated = parseSceneOperationPatchNode(candidate)
|
||||
if (
|
||||
!validated ||
|
||||
validated.id !== id ||
|
||||
validated.type !== node.type ||
|
||||
validated.object !== node.object
|
||||
) {
|
||||
return null
|
||||
}
|
||||
nextNodes[id] = candidate as AnyNode
|
||||
}
|
||||
|
||||
const materials =
|
||||
changes.materialChanges.length > 0 ? { ...beforeState.materials } : beforeState.materials
|
||||
for (const { id, material } of changes.materialChanges) {
|
||||
if (
|
||||
materialIds.has(id) ||
|
||||
(material !== null && (material.id !== id || !SceneMaterial.safeParse(material).success))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
materialIds.add(id)
|
||||
if (material === null) delete materials[id]
|
||||
else materials[id] = material
|
||||
}
|
||||
|
||||
return { materials, nodes: nextNodes, rootNodeIds: nextRootNodeIds }
|
||||
}
|
||||
|
||||
export function applySceneOperationPatch(changes: SceneOperationPatch): boolean {
|
||||
const beforeState = useScene.getState()
|
||||
if (
|
||||
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
|
||||
hasInvalidNodeTarget ||
|
||||
hasInvalidMaterialTarget
|
||||
changes.nodeUpdates.length === 0 &&
|
||||
changes.materialChanges.length === 0 &&
|
||||
changes.nodeCreates.length === 0 &&
|
||||
changes.nodeDeletes.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const temporalState = useScene.temporal.getState()
|
||||
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false
|
||||
if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false
|
||||
const next = sceneOperationPatchNextState(beforeState, changes)
|
||||
if (!next) return false
|
||||
|
||||
const before = sceneHistorySnapshotFromState(beforeState)
|
||||
pauseSceneHistory(useScene)
|
||||
const shouldScopeHistoryPause =
|
||||
useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0
|
||||
if (shouldScopeHistoryPause) pauseSceneHistory(useScene)
|
||||
try {
|
||||
// Host-owned fields bypass the UI lock without running local mutation cascades.
|
||||
useScene.setState((state) => {
|
||||
const nodes = changes.nodeUpdates.length > 0 ? { ...state.nodes } : state.nodes
|
||||
for (const { id, data, removeFields } of changes.nodeUpdates) {
|
||||
const node = nodes[id]
|
||||
if (!node) return {}
|
||||
const nextNode = { ...node, ...data }
|
||||
for (const field of removeFields) delete nextNode[field as keyof typeof nextNode]
|
||||
nodes[id] = nextNode as AnyNode
|
||||
}
|
||||
const materials =
|
||||
changes.materialChanges.length > 0 ? { ...state.materials } : state.materials
|
||||
for (const { id, material } of changes.materialChanges) {
|
||||
if (material === null) {
|
||||
delete materials[id]
|
||||
} else {
|
||||
materials[id] = material
|
||||
}
|
||||
}
|
||||
return { materials, nodes }
|
||||
})
|
||||
useScene.setState(next)
|
||||
} finally {
|
||||
resumeSceneHistory(useScene)
|
||||
if (shouldScopeHistoryPause) resumeSceneHistory(useScene)
|
||||
}
|
||||
|
||||
const currentState = useScene.getState()
|
||||
const current = sceneHistorySnapshotFromState(currentState)
|
||||
for (const { id } of changes.nodeUpdates) {
|
||||
const touchedNodeIds = new Set<AnyNodeId>([
|
||||
...changes.nodeUpdates.map(({ id }) => id),
|
||||
...changes.nodeCreates.map(({ node }) => node.id),
|
||||
...changes.nodeDeletes.map(({ node }) => node.id),
|
||||
])
|
||||
for (const id of touchedNodeIds) {
|
||||
useLiveNodeOverrides.getState().clear(id)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
}
|
||||
if (areSceneSnapshotsEqual(before, current)) return false
|
||||
|
||||
for (const { id } of changes.nodeUpdates) {
|
||||
currentState.markDirty(id)
|
||||
for (const id of touchedNodeIds) {
|
||||
if (current.nodes[id]) currentState.markDirty(id)
|
||||
else currentState.clearDirty(id)
|
||||
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||
if (beforeParentId) currentState.markDirty(beforeParentId)
|
||||
if (currentParentId) currentState.markDirty(currentParentId)
|
||||
}
|
||||
const structuralParentIds = new Set<AnyNodeId>()
|
||||
for (const { node } of changes.nodeCreates) {
|
||||
if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
for (const { node } of changes.nodeDeletes) {
|
||||
if (node.parentId) structuralParentIds.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
for (const parentId of structuralParentIds) {
|
||||
const parent = current.nodes[parentId]
|
||||
if (!(parent && 'children' in parent && Array.isArray(parent.children))) continue
|
||||
for (const childId of parent.children) currentState.markDirty(childId as AnyNodeId)
|
||||
}
|
||||
if (changes.materialChanges.length > 0) {
|
||||
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
|
||||
for (const node of Object.values(current.nodes)) {
|
||||
@@ -1673,6 +1982,7 @@ export function applyScenePatch(changes: ScenePatch): boolean {
|
||||
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id)
|
||||
|
||||
notifySceneCommit({
|
||||
origin: 'host',
|
||||
@@ -1682,6 +1992,14 @@ export function applyScenePatch(changes: ScenePatch): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyScenePatch(changes: ScenePatch): boolean {
|
||||
return applySceneOperationPatch({
|
||||
...changes,
|
||||
nodeCreates: [],
|
||||
nodeDeletes: [],
|
||||
})
|
||||
}
|
||||
|
||||
export type ApplySceneSnapshotOptions = {
|
||||
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,28 @@ describe('healSceneNodes', () => {
|
||||
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
|
||||
})
|
||||
|
||||
test('preserves legacy embedded site children for the scene migration', () => {
|
||||
const building = {
|
||||
id: 'building_legacy',
|
||||
type: 'building',
|
||||
parentId: null,
|
||||
children: ['level_legacy'],
|
||||
}
|
||||
const { nodes, strippedChildRefs } = healSceneNodes({
|
||||
site_legacy: {
|
||||
id: 'site_legacy',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
children: [building, null],
|
||||
},
|
||||
building_legacy: building,
|
||||
level_legacy: { id: 'level_legacy', type: 'level', parentId: null, children: [] },
|
||||
})
|
||||
|
||||
expect(strippedChildRefs).toBe(1)
|
||||
expect((nodes.site_legacy as { children: unknown[] }).children).toEqual([building])
|
||||
})
|
||||
|
||||
test('drops childless zero-length walls and removes their parent reference', () => {
|
||||
const { nodes, droppedWallIds } = healSceneNodes({
|
||||
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface HealSceneResult {
|
||||
nodes: Record<string, unknown>
|
||||
/** Ids of zero-length walls that were dropped. */
|
||||
droppedWallIds: string[]
|
||||
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
||||
/** Count of invalid non-string (e.g. null) entries removed from `children` arrays. */
|
||||
strippedChildRefs: number
|
||||
/**
|
||||
* Count of child references removed because the child's `parentId` points at
|
||||
@@ -74,26 +74,43 @@ export function healSceneNodes(input: Record<string, unknown>): HealSceneResult
|
||||
let strippedChildRefs = 0
|
||||
let strippedStaleChildRefs = 0
|
||||
|
||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]`
|
||||
// bug), references to walls we just removed, same-array duplicates, and
|
||||
// stale references whose child's `parentId` names a different parent.
|
||||
// Pass 2: clean `children` arrays — drop invalid non-string entries (the
|
||||
// `[null]` bug), references to walls we just removed, same-array duplicates,
|
||||
// and stale references whose child's `parentId` names a different parent.
|
||||
// Legacy sites embedded full child objects; keep those for migrateNodes to
|
||||
// flatten after healing instead of disconnecting the entire building.
|
||||
const nodes: Record<string, unknown> = {}
|
||||
for (const [id, node] of Object.entries(kept)) {
|
||||
const children = (node as { children?: unknown })?.children
|
||||
if (Array.isArray(children)) {
|
||||
const seen = new Set<string>()
|
||||
const cleaned = children.filter((c): c is string => {
|
||||
if (typeof c !== 'string' || dropped.has(c)) {
|
||||
const cleaned = children.filter((child) => {
|
||||
const embeddedSiteChildId =
|
||||
(node as { type?: unknown }).type === 'site' &&
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
typeof (child as { id?: unknown }).id === 'string'
|
||||
? (child as { id: string }).id
|
||||
: null
|
||||
if (embeddedSiteChildId) {
|
||||
if (seen.has(embeddedSiteChildId)) {
|
||||
strippedStaleChildRefs++
|
||||
return false
|
||||
}
|
||||
seen.add(embeddedSiteChildId)
|
||||
return true
|
||||
}
|
||||
if (typeof child !== 'string' || dropped.has(child)) {
|
||||
strippedChildRefs++
|
||||
return false
|
||||
}
|
||||
if (seen.has(c)) {
|
||||
if (seen.has(child)) {
|
||||
strippedStaleChildRefs++
|
||||
return false
|
||||
}
|
||||
seen.add(c)
|
||||
const child = kept[c] as { parentId?: unknown } | undefined
|
||||
if (child && typeof child.parentId === 'string' && child.parentId !== id) {
|
||||
seen.add(child)
|
||||
const childNode = kept[child] as { parentId?: unknown } | undefined
|
||||
if (childNode && typeof childNode.parentId === 'string' && childNode.parentId !== id) {
|
||||
strippedStaleChildRefs++
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extensi
|
||||
import {
|
||||
createFreshPlacementSubtree,
|
||||
duplicatesAsFreshSubtree,
|
||||
prepareFreshPlacementRootDuplicate,
|
||||
} from '../../lib/fresh-planar-placement'
|
||||
import { curveReshapeScope } from '../../lib/interaction/scope'
|
||||
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||
@@ -116,8 +117,8 @@ function collectQuickActionNodes(
|
||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||
* - Duplicate: deep-clones the node, marks it new, sets it as the
|
||||
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
|
||||
* - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
|
||||
* root-only copy, then hands that real draft to the placement cursor.
|
||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||
* `relations.cascadeDelete` if declared on the def.
|
||||
*
|
||||
@@ -320,34 +321,30 @@ export function FloorplanRegistryActionMenu() {
|
||||
if (!node.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||
if (draft) {
|
||||
let draftId: AnyNodeId | null = null
|
||||
try {
|
||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||
if (!draft) return
|
||||
setMovingNode(draft as never)
|
||||
setMovingNodeOrigin('2d')
|
||||
useScene.temporal.getState().resume()
|
||||
return
|
||||
} else {
|
||||
const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
|
||||
const parsed = def.schema.parse(cloned) as AnyNode
|
||||
draftId = parsed.id as AnyNodeId
|
||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||
setMovingNode(parsed as never)
|
||||
}
|
||||
setMovingNodeOrigin('2d')
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
} catch (error) {
|
||||
if (draftId && useScene.getState().nodes[draftId]) {
|
||||
useScene.getState().deleteNode(draftId)
|
||||
}
|
||||
console.error('Failed to duplicate node', error)
|
||||
} finally {
|
||||
useScene.temporal.getState().resume()
|
||||
return
|
||||
}
|
||||
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
|
||||
delete (cloned as { id?: AnyNodeId }).id
|
||||
const prevMeta =
|
||||
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||
? (cloned.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
// Mark fresh + hand to the placement cursor so the copy follows the
|
||||
// pointer and only lands on the next click — same gesture for every
|
||||
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
||||
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
||||
// no longer need the old "offset + drop already-placed" special case.
|
||||
cloned.metadata = { ...prevMeta, isNew: true }
|
||||
const parsed = def.schema.parse(cloned) as AnyNode
|
||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||
setMovingNode(parsed as never)
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createFloorplanRenderScaleReference } from './floorplan-render-context'
|
||||
|
||||
describe('floorplan render context', () => {
|
||||
test('updates the live scale without changing the registry-facing reader', () => {
|
||||
const scale = createFloorplanRenderScaleReference(0.02)
|
||||
const read = scale.read
|
||||
|
||||
scale.update(0.01)
|
||||
|
||||
expect(scale.read).toBe(read)
|
||||
expect(read()).toBe(0.01)
|
||||
})
|
||||
|
||||
test('notifies selected-handle renderers when a hidden floorplan becomes visible', () => {
|
||||
const scale = createFloorplanRenderScaleReference(30)
|
||||
let renderedCurveHandleRadius = 8 * scale.read()
|
||||
const unsubscribe = scale.subscribe(() => {
|
||||
renderedCurveHandleRadius = 8 * scale.read()
|
||||
})
|
||||
|
||||
scale.update(0.02)
|
||||
|
||||
expect(renderedCurveHandleRadius).toBe(0.16)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { FloorplanPalette } from '@pascal-app/core'
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
|
||||
/**
|
||||
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
||||
@@ -34,7 +42,43 @@ export type FloorplanRenderContextValue = {
|
||||
sceneRotationDeg: number
|
||||
}
|
||||
|
||||
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
|
||||
export type FloorplanStaticRenderContextValue = Omit<
|
||||
FloorplanRenderContextValue,
|
||||
'sceneRotationDeg' | 'unitsPerPixel'
|
||||
> & {
|
||||
getSceneRotationDeg: () => number
|
||||
getUnitsPerPixel: () => number
|
||||
subscribeUnitsPerPixel: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
const FloorplanStaticRenderContext = createContext<FloorplanStaticRenderContextValue | null>(null)
|
||||
const FloorplanSceneRotationContext = createContext(0)
|
||||
const FloorplanUnitsPerPixelContext = createContext(1)
|
||||
|
||||
export type FloorplanRenderScaleReference = {
|
||||
read: () => number
|
||||
subscribe: (listener: () => void) => () => void
|
||||
update: (unitsPerPixel: number) => void
|
||||
}
|
||||
|
||||
export function createFloorplanRenderScaleReference(
|
||||
initialUnitsPerPixel: number,
|
||||
): FloorplanRenderScaleReference {
|
||||
let unitsPerPixel = initialUnitsPerPixel
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
read: () => unitsPerPixel,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
update: (nextUnitsPerPixel) => {
|
||||
if (Object.is(unitsPerPixel, nextUnitsPerPixel)) return
|
||||
unitsPerPixel = nextUnitsPerPixel
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function FloorplanRenderProvider({
|
||||
children,
|
||||
@@ -42,12 +86,39 @@ export function FloorplanRenderProvider({
|
||||
palette,
|
||||
hatchPatternId,
|
||||
sceneRotationDeg,
|
||||
}: FloorplanRenderContextValue & { children: ReactNode }) {
|
||||
const value = useMemo<FloorplanRenderContextValue>(
|
||||
() => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
|
||||
[unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
|
||||
getSceneRotationDeg,
|
||||
}: FloorplanRenderContextValue & {
|
||||
children: ReactNode
|
||||
getSceneRotationDeg: () => number
|
||||
}) {
|
||||
const renderScaleReference = useRef<FloorplanRenderScaleReference | null>(null)
|
||||
if (!renderScaleReference.current) {
|
||||
renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel)
|
||||
}
|
||||
const getUnitsPerPixel = renderScaleReference.current.read
|
||||
const subscribeUnitsPerPixel = renderScaleReference.current.subscribe
|
||||
useLayoutEffect(() => {
|
||||
renderScaleReference.current?.update(unitsPerPixel)
|
||||
}, [unitsPerPixel])
|
||||
const staticValue = useMemo<FloorplanStaticRenderContextValue>(
|
||||
() => ({
|
||||
palette,
|
||||
hatchPatternId,
|
||||
getSceneRotationDeg,
|
||||
getUnitsPerPixel,
|
||||
subscribeUnitsPerPixel,
|
||||
}),
|
||||
[palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel, subscribeUnitsPerPixel],
|
||||
)
|
||||
return (
|
||||
<FloorplanStaticRenderContext.Provider value={staticValue}>
|
||||
<FloorplanUnitsPerPixelContext.Provider value={unitsPerPixel}>
|
||||
<FloorplanSceneRotationContext.Provider value={sceneRotationDeg}>
|
||||
{children}
|
||||
</FloorplanSceneRotationContext.Provider>
|
||||
</FloorplanUnitsPerPixelContext.Provider>
|
||||
</FloorplanStaticRenderContext.Provider>
|
||||
)
|
||||
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,5 +129,39 @@ export function FloorplanRenderProvider({
|
||||
* whole legacy panel along.
|
||||
*/
|
||||
export function useFloorplanRender(): FloorplanRenderContextValue | null {
|
||||
return useContext(FloorplanRenderContext)
|
||||
const staticValue = useContext(FloorplanStaticRenderContext)
|
||||
const sceneRotationDeg = useContext(FloorplanSceneRotationContext)
|
||||
const unitsPerPixel = useContext(FloorplanUnitsPerPixelContext)
|
||||
return useMemo(
|
||||
() =>
|
||||
staticValue
|
||||
? {
|
||||
unitsPerPixel,
|
||||
palette: staticValue.palette,
|
||||
hatchPatternId: staticValue.hatchPatternId,
|
||||
sceneRotationDeg,
|
||||
}
|
||||
: null,
|
||||
[sceneRotationDeg, staticValue, unitsPerPixel],
|
||||
)
|
||||
}
|
||||
|
||||
export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue | null {
|
||||
return useContext(FloorplanStaticRenderContext)
|
||||
}
|
||||
|
||||
const subscribeToNoFloorplanScale = () => () => {}
|
||||
const readDefaultFloorplanScale = () => 1
|
||||
|
||||
export function useFloorplanStaticUnitsPerPixel(): number {
|
||||
const staticValue = useContext(FloorplanStaticRenderContext)
|
||||
return useSyncExternalStore(
|
||||
staticValue?.subscribeUnitsPerPixel ?? subscribeToNoFloorplanScale,
|
||||
staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
|
||||
staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale,
|
||||
)
|
||||
}
|
||||
|
||||
export function useFloorplanSceneRotation(): number {
|
||||
return useContext(FloorplanSceneRotationContext)
|
||||
}
|
||||
|
||||
+9
-191
@@ -1,11 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
collectAnnotationLayoutPreflightIssues,
|
||||
floorplanAnnotationObstacleMode,
|
||||
observeSvgAnnotationLayoutChanges,
|
||||
polylineObstacleRectangles,
|
||||
resolveAnnotationLabelRectangles,
|
||||
resolveSvgAnnotationCollisions,
|
||||
} from './floorplan-annotation-layout'
|
||||
|
||||
describe('floorplanAnnotationObstacleMode', () => {
|
||||
@@ -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([
|
||||
@@ -333,135 +272,14 @@ describe('resolveAnnotationLabelRectangles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('observeSvgAnnotationLayoutChanges', () => {
|
||||
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
|
||||
const OriginalMutationObserver = globalThis.MutationObserver
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
|
||||
let notify: MutationCallback | undefined
|
||||
let animationFrames: FrameRequestCallback[] = []
|
||||
let disconnected = false
|
||||
let observedOptions: MutationObserverInit | undefined
|
||||
describe('resolveSvgAnnotationCollisions', () => {
|
||||
test('uses captured label references instead of querying for them again', () => {
|
||||
const svg = {
|
||||
querySelectorAll: () => {
|
||||
throw new Error('labels were rediscovered')
|
||||
},
|
||||
} as unknown as SVGSVGElement
|
||||
|
||||
class FakeMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
notify = callback
|
||||
}
|
||||
|
||||
observe(_target: Node, options?: MutationObserverInit): void {
|
||||
observedOptions = options
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
disconnected = true
|
||||
}
|
||||
|
||||
takeRecords(): MutationRecord[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
}) as typeof requestAnimationFrame
|
||||
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
|
||||
try {
|
||||
const flushAnimationFrame = () => {
|
||||
const callbacks = animationFrames
|
||||
animationFrames = []
|
||||
for (const callback of callbacks) callback(0)
|
||||
}
|
||||
let layoutPasses = 0
|
||||
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
|
||||
layoutPasses += 1
|
||||
})
|
||||
|
||||
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
|
||||
|
||||
expect(layoutPasses).toBe(0)
|
||||
flushAnimationFrame()
|
||||
expect(layoutPasses).toBe(0)
|
||||
flushAnimationFrame()
|
||||
expect(layoutPasses).toBe(1)
|
||||
expect(observedOptions).toMatchObject({
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: expect.any(Array),
|
||||
})
|
||||
|
||||
notify?.(
|
||||
[
|
||||
{
|
||||
attributeName: 'style',
|
||||
target: { closest: () => ({}) },
|
||||
type: 'attributes',
|
||||
} as unknown as MutationRecord,
|
||||
],
|
||||
{} as MutationObserver,
|
||||
)
|
||||
expect(layoutPasses).toBe(1)
|
||||
|
||||
stop()
|
||||
expect(disconnected).toBe(true)
|
||||
} finally {
|
||||
globalThis.MutationObserver = OriginalMutationObserver
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame
|
||||
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
|
||||
}
|
||||
})
|
||||
|
||||
test('waits for a quiet frame instead of resolving on every mutation frame', () => {
|
||||
const OriginalMutationObserver = globalThis.MutationObserver
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
|
||||
let notify: MutationCallback | undefined
|
||||
let animationFrames: FrameRequestCallback[] = []
|
||||
|
||||
class FakeMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
notify = callback
|
||||
}
|
||||
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
takeRecords(): MutationRecord[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
}) as typeof requestAnimationFrame
|
||||
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
|
||||
try {
|
||||
const flushAnimationFrame = () => {
|
||||
const callbacks = animationFrames
|
||||
animationFrames = []
|
||||
for (const callback of callbacks) callback(0)
|
||||
}
|
||||
let layoutPasses = 0
|
||||
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
|
||||
layoutPasses += 1
|
||||
})
|
||||
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
|
||||
flushAnimationFrame()
|
||||
}
|
||||
|
||||
expect(layoutPasses).toBe(0)
|
||||
flushAnimationFrame()
|
||||
expect(layoutPasses).toBe(1)
|
||||
stop()
|
||||
} finally {
|
||||
globalThis.MutationObserver = OriginalMutationObserver
|
||||
globalThis.requestAnimationFrame = originalRequestAnimationFrame
|
||||
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
|
||||
}
|
||||
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[],
|
||||
@@ -127,15 +115,19 @@ export function resolveAnnotationLabelRectangles(
|
||||
|
||||
export function resolveSvgAnnotationCollisions(
|
||||
svg: SVGSVGElement,
|
||||
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
|
||||
): AnnotationPreflightIssue[] {
|
||||
const labels = Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
|
||||
if (labels.length === 0) return []
|
||||
options: {
|
||||
labels?: readonly SVGGElement[]
|
||||
layoutOverrides?: AnnotationLayoutOverrides
|
||||
} = {},
|
||||
): void {
|
||||
const labels =
|
||||
options.labels ??
|
||||
Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
|
||||
if (labels.length === 0) return
|
||||
|
||||
for (const label of labels) {
|
||||
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
|
||||
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
|
||||
label.removeAttribute('data-floorplan-layout-unresolved')
|
||||
delete label.dataset.floorplanAnnotationLayoutDx
|
||||
delete label.dataset.floorplanAnnotationLayoutDy
|
||||
}
|
||||
@@ -183,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,
|
||||
@@ -196,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]
|
||||
@@ -204,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()
|
||||
@@ -222,173 +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 observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void {
|
||||
let scheduledFrame: number | null = null
|
||||
let mutationVersion = 0
|
||||
let observedVersion = 0
|
||||
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
|
||||
const flushWhenSettled = () => {
|
||||
if (observedVersion !== mutationVersion) {
|
||||
observedVersion = mutationVersion
|
||||
scheduledFrame = requestFrame(flushWhenSettled)
|
||||
return
|
||||
}
|
||||
scheduledFrame = null
|
||||
onChange()
|
||||
}
|
||||
const schedule = () => {
|
||||
mutationVersion += 1
|
||||
if (scheduledFrame !== null) return
|
||||
observedVersion = mutationVersion - 1
|
||||
scheduledFrame = requestFrame(flushWhenSettled)
|
||||
}
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some(isAnnotationLayoutMutation)) schedule()
|
||||
})
|
||||
observer.observe(target, {
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
'cx',
|
||||
'cy',
|
||||
'd',
|
||||
'dominant-baseline',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'height',
|
||||
'points',
|
||||
'r',
|
||||
'rx',
|
||||
'ry',
|
||||
'stroke-width',
|
||||
'text-anchor',
|
||||
'transform',
|
||||
'visibility',
|
||||
'width',
|
||||
'x',
|
||||
'x1',
|
||||
'x2',
|
||||
'y',
|
||||
'y1',
|
||||
'y2',
|
||||
],
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (scheduledFrame === null) return
|
||||
if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
|
||||
else clearTimeout(scheduledFrame)
|
||||
}
|
||||
}
|
||||
|
||||
function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
|
||||
if (mutation.type !== 'attributes') return true
|
||||
const attribute = mutation.attributeName ?? ''
|
||||
const target = mutation.target as Element
|
||||
const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
|
||||
|
||||
if (
|
||||
attribute === 'data-floorplan-annotation-id' ||
|
||||
attribute === 'data-floorplan-annotation-layout-dx' ||
|
||||
attribute === 'data-floorplan-annotation-layout-dy' ||
|
||||
attribute === 'data-floorplan-layout-unresolved'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
closest?.('[data-floorplan-annotation-label]') &&
|
||||
(attribute === 'style' || attribute === 'transform')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
|
||||
(attribute === 'x1' ||
|
||||
attribute === 'x2' ||
|
||||
attribute === 'y1' ||
|
||||
attribute === 'y2' ||
|
||||
attribute === 'visibility')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function collectAnnotationLayoutPreflightIssues(
|
||||
rectangles: readonly AnnotationLabelRectangle[],
|
||||
shifts: readonly AnnotationLabelShift[],
|
||||
obstacles: readonly AnnotationObstacleRectangle[] = [],
|
||||
): AnnotationPreflightIssue[] {
|
||||
const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
|
||||
const finalRectangles = rectangles.map((rectangle) => {
|
||||
const shift = shiftsById.get(rectangle.id) ?? {
|
||||
id: rectangle.id,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
resolved: false,
|
||||
}
|
||||
return {
|
||||
source: rectangle,
|
||||
shift,
|
||||
bounds: {
|
||||
x: rectangle.x + shift.dx,
|
||||
y: rectangle.y + shift.dy,
|
||||
width: rectangle.width,
|
||||
height: rectangle.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
const issues: AnnotationPreflightIssue[] = []
|
||||
const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
|
||||
if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
|
||||
issues.push({ id, kind, severity: 'warning', message })
|
||||
}
|
||||
|
||||
for (const entry of finalRectangles) {
|
||||
const label = preflightLabel(entry.source)
|
||||
if (entry.source.labelPlacement === 'outside-end') {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'short-unreadable-segment',
|
||||
`${label} is too short for inline text and uses an outside label or leader.`,
|
||||
)
|
||||
}
|
||||
if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'plan-geometry-conflict',
|
||||
`${label} still conflicts with fixed plan geometry after automatic layout.`,
|
||||
)
|
||||
}
|
||||
if (!entry.shift.resolved) {
|
||||
const collidesWithLabel = finalRectangles.some(
|
||||
(candidate) =>
|
||||
candidate.source.id !== entry.source.id &&
|
||||
rectanglesOverlap(entry.bounds, candidate.bounds),
|
||||
)
|
||||
if (collidesWithLabel) {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'unresolved-collision',
|
||||
`${label} still overlaps another annotation after automatic layout.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function preflightLabel(rectangle: AnnotationLabelRectangle): string {
|
||||
const text = rectangle.text?.trim()
|
||||
return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
|
||||
}
|
||||
|
||||
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
|
||||
|
||||
@@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({
|
||||
/>
|
||||
) : null}
|
||||
<g
|
||||
data-floorplan-annotation-angle-radians={Math.atan2(
|
||||
geometry.end[1] - geometry.start[1],
|
||||
geometry.end[0] - geometry.start[0],
|
||||
)}
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||
geometry.offsetDistance,
|
||||
)}
|
||||
data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
|
||||
data-floorplan-dimension-label-placement={layout.labelPlacement}
|
||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||
@@ -429,11 +434,16 @@ export function FloorplanDimensionStringRenderer({
|
||||
/>
|
||||
) : null}
|
||||
<g
|
||||
data-floorplan-annotation-angle-radians={Math.atan2(
|
||||
segment.end[1] - segment.start[1],
|
||||
segment.end[0] - segment.start[0],
|
||||
)}
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||
geometry.offsetDistance,
|
||||
)}
|
||||
data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
|
||||
data-floorplan-dimension-label-placement={layout.labelPlacement}
|
||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||
|
||||
@@ -487,9 +487,15 @@ function renderNode(
|
||||
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-angle-radians={g.angle}
|
||||
data-floorplan-annotation-default-transform={labelTransform}
|
||||
data-floorplan-annotation-label=""
|
||||
data-floorplan-annotation-priority="20"
|
||||
data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
|
||||
data-floorplan-annotation-transform-after-rotation={`translate(0 ${
|
||||
-(g.offsetPx ?? 0) * unitsPerPixel
|
||||
})`}
|
||||
data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
|
||||
key={keyHint}
|
||||
pointerEvents="none"
|
||||
transform={labelTransform}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
import {
|
||||
resolveFloorplanAnnotationLabelTransform,
|
||||
resolveFloorplanAnnotationUpdate,
|
||||
resolveFloorplanLabelAngle,
|
||||
shouldUpdateFloorplanLabelRotation,
|
||||
updateSvgFloorplanLabelOrientations,
|
||||
} from './floorplan-label-angle'
|
||||
|
||||
describe('resolveFloorplanLabelAngle', () => {
|
||||
test('keeps segment labels readable while preserving their screen direction', () => {
|
||||
@@ -12,4 +18,72 @@ describe('resolveFloorplanLabelAngle', () => {
|
||||
expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
|
||||
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
|
||||
})
|
||||
|
||||
test('ignores sub-degree rotation changes for annotation layout', () => {
|
||||
expect(shouldUpdateFloorplanLabelRotation(30, 30.9)).toBe(false)
|
||||
expect(shouldUpdateFloorplanLabelRotation(30, 31)).toBe(true)
|
||||
expect(shouldUpdateFloorplanLabelRotation(359.5, 0.25)).toBe(false)
|
||||
})
|
||||
|
||||
test('updates only label orientation while preserving its layout shift', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationLabelTransform({
|
||||
afterRotation: 'translate(0 -0.2)',
|
||||
angleRadians: 0,
|
||||
beforeRotation: 'translate(4 6)',
|
||||
layoutDx: 0.5,
|
||||
layoutDy: -0.25,
|
||||
sceneRotationDeg: 90,
|
||||
screenUpright: true,
|
||||
}),
|
||||
).toEqual({
|
||||
defaultTransform: 'translate(4 6) rotate(-90) translate(0 -0.2)',
|
||||
transform: 'translate(4 6) rotate(-90) translate(0 -0.2) translate(0.5 -0.25)',
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps a rotation-only update out of the full collision layout path', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationUpdate({
|
||||
layoutInputsChanged: false,
|
||||
nextRotationDeg: 45,
|
||||
previousRotationDeg: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
resolveCollisions: false,
|
||||
updateLabelPresentation: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('runs collision layout when floor-plan scene inputs change', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationUpdate({
|
||||
layoutInputsChanged: true,
|
||||
nextRotationDeg: 30,
|
||||
previousRotationDeg: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
resolveCollisions: true,
|
||||
updateLabelPresentation: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('updates captured label references without rediscovering the DOM', () => {
|
||||
const attributes = new Map<string, string>([['transform', 'translate(4 6) rotate(0)']])
|
||||
const label = {
|
||||
dataset: {
|
||||
floorplanAnnotationAngleRadians: '0',
|
||||
floorplanAnnotationLayoutDx: '0.5',
|
||||
floorplanAnnotationLayoutDy: '-0.25',
|
||||
floorplanAnnotationScreenUpright: 'true',
|
||||
floorplanAnnotationTransformAfterRotation: '',
|
||||
floorplanAnnotationTransformBeforeRotation: 'translate(4 6)',
|
||||
},
|
||||
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
} as unknown as SVGGElement
|
||||
|
||||
expect(updateSvgFloorplanLabelOrientations([label], 90)).toBe(1)
|
||||
expect(attributes.get('transform')).toBe('translate(4 6) rotate(-90) translate(0.5 -0.25)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,3 +12,95 @@ export function resolveFloorplanLabelAngle(
|
||||
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
||||
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
|
||||
}
|
||||
|
||||
export function shouldUpdateFloorplanLabelRotation(
|
||||
previousRotationDeg: number | null,
|
||||
nextRotationDeg: number,
|
||||
minimumDeltaDeg = 1,
|
||||
): boolean {
|
||||
if (previousRotationDeg === null) return true
|
||||
const deltaDeg = ((((nextRotationDeg - previousRotationDeg + 180) % 360) + 360) % 360) - 180
|
||||
return Math.abs(deltaDeg) >= minimumDeltaDeg
|
||||
}
|
||||
|
||||
export function resolveFloorplanAnnotationUpdate({
|
||||
layoutInputsChanged,
|
||||
previousRotationDeg,
|
||||
nextRotationDeg,
|
||||
}: {
|
||||
layoutInputsChanged: boolean
|
||||
previousRotationDeg: number | null
|
||||
nextRotationDeg: number
|
||||
}): {
|
||||
resolveCollisions: boolean
|
||||
updateLabelPresentation: boolean
|
||||
} {
|
||||
return {
|
||||
resolveCollisions: layoutInputsChanged,
|
||||
updateLabelPresentation:
|
||||
layoutInputsChanged ||
|
||||
shouldUpdateFloorplanLabelRotation(previousRotationDeg, nextRotationDeg),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFloorplanAnnotationLabelTransform({
|
||||
angleRadians,
|
||||
sceneRotationDeg,
|
||||
screenUpright,
|
||||
beforeRotation,
|
||||
afterRotation,
|
||||
layoutDx = 0,
|
||||
layoutDy = 0,
|
||||
}: {
|
||||
angleRadians: number
|
||||
sceneRotationDeg: number
|
||||
screenUpright: boolean
|
||||
beforeRotation: string
|
||||
afterRotation: string
|
||||
layoutDx?: number
|
||||
layoutDy?: number
|
||||
}): {
|
||||
defaultTransform: string
|
||||
transform: string
|
||||
} {
|
||||
const degrees = resolveFloorplanLabelAngle(angleRadians, sceneRotationDeg, screenUpright)
|
||||
const defaultTransform = [beforeRotation, `rotate(${degrees})`, afterRotation]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return {
|
||||
defaultTransform,
|
||||
transform:
|
||||
layoutDx === 0 && layoutDy === 0
|
||||
? defaultTransform
|
||||
: `${defaultTransform} translate(${layoutDx} ${layoutDy})`,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSvgFloorplanLabelOrientations(
|
||||
labels: Iterable<SVGGElement>,
|
||||
sceneRotationDeg: number,
|
||||
): number {
|
||||
let updated = 0
|
||||
|
||||
for (const label of labels) {
|
||||
const angleRadians = Number(label.dataset.floorplanAnnotationAngleRadians)
|
||||
if (!Number.isFinite(angleRadians)) continue
|
||||
|
||||
const { defaultTransform, transform } = resolveFloorplanAnnotationLabelTransform({
|
||||
angleRadians,
|
||||
sceneRotationDeg,
|
||||
screenUpright: label.dataset.floorplanAnnotationScreenUpright === 'true',
|
||||
beforeRotation: label.dataset.floorplanAnnotationTransformBeforeRotation ?? '',
|
||||
afterRotation: label.dataset.floorplanAnnotationTransformAfterRotation ?? '',
|
||||
layoutDx: Number(label.dataset.floorplanAnnotationLayoutDx ?? 0),
|
||||
layoutDy: Number(label.dataset.floorplanAnnotationLayoutDy ?? 0),
|
||||
})
|
||||
if (label.getAttribute('transform') === transform) continue
|
||||
|
||||
label.dataset.floorplanAnnotationDefaultTransform = defaultTransform
|
||||
label.setAttribute('transform', transform)
|
||||
updated += 1
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -19,12 +20,83 @@ import {
|
||||
collectFloorplanDependencyNodes,
|
||||
collectFloorplanLinkedLevelNodes,
|
||||
computeAffectedSiblingIds,
|
||||
floorplanAffordanceReshapeScope,
|
||||
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({
|
||||
kind: 'reshaping',
|
||||
nodeId: 'wall_1',
|
||||
reshape: 'curve',
|
||||
driver: 'floorplan',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
||||
return {
|
||||
id,
|
||||
@@ -195,6 +267,35 @@ describe('floorplan affordance cancellation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan opening placement interaction routing', () => {
|
||||
test('passes entries through only while an opening tool or moving opening is active', () => {
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'build',
|
||||
tool: 'window',
|
||||
movingNodeHasWallOpeningPlacement: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
movingNodeHasWallOpeningPlacement: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
movingNodeHasWallOpeningPlacement: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan vertex double-click routing', () => {
|
||||
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
||||
expect(
|
||||
@@ -220,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) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
computeHeroFraming,
|
||||
createSnapshotPipeline,
|
||||
GRID_LAYER,
|
||||
heroCameraPose,
|
||||
temporarilyHideNodeTypes,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PerspectiveCamera } from 'three'
|
||||
import type { WebGPURenderer } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
export function BakeThumbnail({
|
||||
active,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
active: boolean
|
||||
onComplete: (blob: Blob, size: { w: number; h: number }) => void
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const renderer = useThree((state) => state.gl)
|
||||
const scene = useThree((state) => state.scene)
|
||||
const doneRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!(active && !doneRef.current)) return
|
||||
doneRef.current = true
|
||||
|
||||
const run = async () => {
|
||||
const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
|
||||
let pipeline: Awaited<ReturnType<typeof createSnapshotPipeline>> = null
|
||||
|
||||
try {
|
||||
const framing = computeHeroFraming()
|
||||
if (!framing) {
|
||||
onError('scene has no framable content')
|
||||
return
|
||||
}
|
||||
|
||||
const { width, height } = renderer.domElement
|
||||
const aspect = width / height
|
||||
const camera = new PerspectiveCamera(60, aspect, 0.1, 1000)
|
||||
camera.layers.disable(EDITOR_LAYER)
|
||||
camera.layers.disable(GRID_LAYER)
|
||||
const pose = heroCameraPose({
|
||||
boxes: framing.boxes,
|
||||
aim: framing.aim,
|
||||
azimuthRad: framing.azimuthRad,
|
||||
aspect,
|
||||
})
|
||||
camera.position.set(pose.position[0], pose.position[1], pose.position[2])
|
||||
camera.lookAt(pose.target[0], pose.target[1], pose.target[2])
|
||||
camera.updateMatrixWorld()
|
||||
|
||||
pipeline = await createSnapshotPipeline({
|
||||
renderer: renderer as unknown as WebGPURenderer,
|
||||
scene,
|
||||
camera,
|
||||
})
|
||||
if (!pipeline) {
|
||||
onError('thumbnail pipeline failed to build')
|
||||
return
|
||||
}
|
||||
|
||||
pipeline.applyEnvironment({
|
||||
theme: useViewer.getState().sceneTheme,
|
||||
transparent: false,
|
||||
grade: true,
|
||||
edges: useViewer.getState().edges,
|
||||
camera,
|
||||
})
|
||||
const { blob, outW, outH } = await pipeline.capture({ captureMode: 'standard' })
|
||||
onComplete(blob, { w: outW, h: outH })
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[bake-thumbnail]',
|
||||
error instanceof Error ? (error.stack ?? error.message) : error,
|
||||
)
|
||||
onError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
pipeline?.dispose()
|
||||
restoreNodeVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
}, [active, onComplete, onError, renderer, scene])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
type CameraPoseApplicationPlan,
|
||||
normalizeCameraPose,
|
||||
planCameraPoseApplication,
|
||||
publishInitialCameraPose,
|
||||
releaseCameraPoseEventSuppression,
|
||||
stepCameraPoseInterpolation,
|
||||
withCameraPoseDistance,
|
||||
} from '../../lib/camera-pose'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { publishCameraPose } from '../../store/camera-pose-store'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
useActiveHandleDrag,
|
||||
@@ -46,14 +49,9 @@ const tempSize = new Vector3()
|
||||
const tempTarget = new Vector3()
|
||||
const transitionFreezePosition = new Vector3()
|
||||
const transitionFreezeTarget = new Vector3()
|
||||
const syncTarget = new Vector3()
|
||||
const syncSpherical = new Spherical()
|
||||
const keyboardPanSpherical = new Spherical()
|
||||
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
||||
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
||||
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
|
||||
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
|
||||
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
|
||||
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
||||
const KEYBOARD_PAN_MIN_SPEED = 2
|
||||
const KEYBOARD_PAN_MAX_SPEED = 55
|
||||
@@ -63,14 +61,6 @@ type CameraPoseSnapshot = {
|
||||
position: [number, number, number]
|
||||
target: [number, number, number]
|
||||
}
|
||||
type NavigationCameraPoseSnapshot = {
|
||||
target: [number, number, number]
|
||||
azimuth: number
|
||||
viewWidth: number
|
||||
}
|
||||
type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
|
||||
publishOnComplete: boolean
|
||||
}
|
||||
type CameraViewWidthUpdate =
|
||||
| { type: 'distance'; distance: number; viewWidth: number }
|
||||
| { type: 'zoom'; viewWidth: number; zoom: number }
|
||||
@@ -201,14 +191,6 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo
|
||||
return Math.max(0.001, distance)
|
||||
}
|
||||
|
||||
function getAngleDeltaRadians(a: number, b: number) {
|
||||
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
||||
}
|
||||
|
||||
function nearestEquivalentRadians(angle: number, reference: number) {
|
||||
return reference + getAngleDeltaRadians(angle, reference)
|
||||
}
|
||||
|
||||
function clampFinite(value: number, min: number, max: number) {
|
||||
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
||||
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
|
||||
@@ -233,21 +215,6 @@ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function isCameraAtNavigationPose(
|
||||
pose: NavigationCameraPoseSnapshot,
|
||||
target: Vector3,
|
||||
azimuth: number,
|
||||
viewWidth: number,
|
||||
) {
|
||||
return (
|
||||
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
||||
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
||||
)
|
||||
}
|
||||
|
||||
function getCameraDistanceForViewWidth(
|
||||
camera: Camera,
|
||||
viewWidth: number,
|
||||
@@ -397,7 +364,6 @@ export const CustomCameraControls = () => {
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
||||
@@ -407,19 +373,8 @@ export const CustomCameraControls = () => {
|
||||
)
|
||||
const currentLevelId = selection.levelId
|
||||
const firstLoad = useRef(true)
|
||||
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
|
||||
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
|
||||
const lastApplied2dNavigationRevision = useRef(0)
|
||||
const savedSmoothTimeRef = useRef<number | null>(null)
|
||||
const maxPolarAngle =
|
||||
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
||||
const clearPendingFloorplanNavigationPose = useCallback(() => {
|
||||
pendingFloorplanNavigationPose.current = null
|
||||
if (savedSmoothTimeRef.current !== null && controls.current) {
|
||||
controls.current.smoothTime = savedSmoothTimeRef.current
|
||||
savedSmoothTimeRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const camera = useThree((state) => state.camera)
|
||||
const gl = useThree((state) => state.gl)
|
||||
@@ -514,12 +469,10 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
}
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
|
||||
}, [
|
||||
camera,
|
||||
cancelPoseApplication,
|
||||
clearPendingFloorplanNavigationPose,
|
||||
freezeActivePoseInterpolation,
|
||||
isFirstPersonMode,
|
||||
viewportSize,
|
||||
@@ -584,19 +537,11 @@ export const CustomCameraControls = () => {
|
||||
if (!controls.current) return
|
||||
if (firstLoad.current) {
|
||||
firstLoad.current = false
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||
}
|
||||
controls.current.getTarget(currentTarget)
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
||||
}, [
|
||||
clearPendingFloorplanNavigationPose,
|
||||
currentLevelId,
|
||||
isPreviewMode,
|
||||
isFirstPersonMode,
|
||||
isRestoringFirstPersonPose,
|
||||
])
|
||||
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode || !controls.current) return
|
||||
@@ -624,7 +569,6 @@ export const CustomCameraControls = () => {
|
||||
controls.current.getTarget(tempTarget)
|
||||
tempDelta.copy(tempCenter).sub(tempTarget)
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.setLookAt(
|
||||
tempPosition.x + tempDelta.x,
|
||||
tempPosition.y + tempDelta.y,
|
||||
@@ -635,106 +579,9 @@ export const CustomCameraControls = () => {
|
||||
true,
|
||||
)
|
||||
},
|
||||
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
|
||||
[isPreviewMode, isFirstPersonMode],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode) return
|
||||
|
||||
return useEditor.subscribe((state) => {
|
||||
const pose = state.navigationSyncPose
|
||||
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
|
||||
|
||||
const control = controls.current
|
||||
if (!control) return
|
||||
|
||||
lastApplied2dNavigationRevision.current = pose.revision
|
||||
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
|
||||
const viewWidthUpdate = resolveCameraViewWidthUpdate(
|
||||
control,
|
||||
camera,
|
||||
pose.viewWidth,
|
||||
viewportSize,
|
||||
)
|
||||
pendingFloorplanNavigationPose.current = {
|
||||
target: [...pose.target],
|
||||
azimuth: targetAzimuth,
|
||||
viewWidth: viewWidthUpdate.viewWidth,
|
||||
publishOnComplete:
|
||||
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
|
||||
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
|
||||
}
|
||||
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
|
||||
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
|
||||
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
|
||||
if (savedSmoothTimeRef.current === null) {
|
||||
savedSmoothTimeRef.current = control.smoothTime
|
||||
}
|
||||
control.smoothTime = 0.18
|
||||
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
|
||||
control.rotateTo(targetAzimuth, control.polarAngle, true)
|
||||
applyCameraViewWidth(control, viewWidthUpdate)
|
||||
})
|
||||
}, [camera, isFirstPersonMode, viewportSize])
|
||||
|
||||
const publishCurrentNavigationPose = useCallback(() => {
|
||||
if (isFirstPersonMode || !controls.current) return
|
||||
|
||||
controls.current.getTarget(syncTarget, false)
|
||||
controls.current.getSpherical(syncSpherical, false)
|
||||
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
|
||||
|
||||
const pendingFloorplanPose = pendingFloorplanNavigationPose.current
|
||||
if (pendingFloorplanPose) {
|
||||
// The camera is still damping toward a 2D-originated pose; do not echo
|
||||
// intermediate 3D poses back into the floorplan.
|
||||
if (
|
||||
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
|
||||
) {
|
||||
lastPublishedNavigationSync.current = pendingFloorplanPose
|
||||
clearPendingFloorplanNavigationPose()
|
||||
if (pendingFloorplanPose.publishOnComplete) {
|
||||
useEditor.getState().publishNavigationSyncPose({
|
||||
source: '3d',
|
||||
target: [
|
||||
pendingFloorplanPose.target[0],
|
||||
pendingFloorplanPose.target[1],
|
||||
pendingFloorplanPose.target[2],
|
||||
],
|
||||
azimuth: pendingFloorplanPose.azimuth,
|
||||
viewWidth: pendingFloorplanPose.viewWidth,
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const previous = lastPublishedNavigationSync.current
|
||||
if (
|
||||
previous &&
|
||||
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
|
||||
Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
|
||||
NAVIGATION_SYNC_AZIMUTH_EPSILON &&
|
||||
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastPublishedNavigationSync.current = {
|
||||
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
||||
azimuth: syncSpherical.theta,
|
||||
viewWidth,
|
||||
}
|
||||
useEditor.getState().publishNavigationSyncPose({
|
||||
source: '3d',
|
||||
target: [syncTarget.x, syncTarget.y, syncTarget.z],
|
||||
azimuth: syncSpherical.theta,
|
||||
viewWidth,
|
||||
})
|
||||
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
|
||||
|
||||
const publishCurrentPose = useCallback(() => {
|
||||
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
|
||||
|
||||
@@ -756,27 +603,17 @@ export const CustomCameraControls = () => {
|
||||
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
|
||||
})
|
||||
if (pose) {
|
||||
emitter.emit('camera-controls:pose', pose)
|
||||
publishCameraPose(pose)
|
||||
}
|
||||
}, [camera, isFirstPersonMode, viewportSize])
|
||||
|
||||
const handleCameraUpdate = useCallback(() => {
|
||||
publishCurrentNavigationPose()
|
||||
publishCurrentPose()
|
||||
}, [publishCurrentNavigationPose, publishCurrentPose])
|
||||
}, [publishCurrentPose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
lastPublishedNavigationSync.current = null
|
||||
publishCurrentNavigationPose()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
||||
publishInitialCameraPose(publishCurrentPose)
|
||||
}, [publishCurrentPose])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (isFirstPersonMode || !controls.current) return
|
||||
@@ -814,12 +651,12 @@ export const CustomCameraControls = () => {
|
||||
} catch {
|
||||
if (activePoseInterpolation.current === activePose) {
|
||||
activePoseInterpolation.current = null
|
||||
suppressPoseEvents.current = false
|
||||
releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
|
||||
}
|
||||
}
|
||||
if (step.settled && activePoseInterpolation.current === activePose) {
|
||||
activePoseInterpolation.current = null
|
||||
suppressPoseEvents.current = false
|
||||
releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,7 +676,6 @@ export const CustomCameraControls = () => {
|
||||
)
|
||||
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
||||
if (vertical !== 0) control.forward(vertical * step, true)
|
||||
}, 0)
|
||||
@@ -992,7 +828,6 @@ export const CustomCameraControls = () => {
|
||||
) {
|
||||
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||
if (changed) beginLocalCameraInteraction()
|
||||
clearPendingFloorplanNavigationPose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -1058,7 +893,6 @@ export const CustomCameraControls = () => {
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
||||
clearPendingFloorplanNavigationPose()
|
||||
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
||||
|
||||
panPointerId = event.pointerId
|
||||
@@ -1069,7 +903,6 @@ export const CustomCameraControls = () => {
|
||||
const onWheel = () => {
|
||||
beginLocalCameraInteraction()
|
||||
cameraDraggingLifecycle.scheduleEnd()
|
||||
clearPendingFloorplanNavigationPose()
|
||||
}
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
@@ -1120,25 +953,18 @@ export const CustomCameraControls = () => {
|
||||
gl,
|
||||
isPreviewMode,
|
||||
isFirstPersonMode,
|
||||
clearPendingFloorplanNavigationPose,
|
||||
])
|
||||
|
||||
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
||||
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
||||
// fires only for user pointer interactions — not for programmatic
|
||||
// moveTo/rotateTo which emit `transitionstart` instead. It also fires for
|
||||
// pointerdowns whose button is mapped to ACTION.NONE (plain left click in
|
||||
// edit mode); those must not flag the camera as dragging — no rest/sleep
|
||||
// ever follows to clear the flag, which would leave canvas clicks
|
||||
// (selection, placement) suppressed until the next real camera move.
|
||||
// `controlstart` fires only for user pointer interactions. Pointerdowns
|
||||
// mapped to ACTION.NONE must not flag the camera as dragging because no
|
||||
// rest/sleep event follows to clear the flag.
|
||||
const handleControlStart = useCallback(() => {
|
||||
clearPendingFloorplanNavigationPose()
|
||||
beginLocalCameraInteraction({
|
||||
dragging: controls.current
|
||||
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
|
||||
: false,
|
||||
})
|
||||
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
|
||||
}, [beginLocalCameraInteraction])
|
||||
|
||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||
const previewTargetNodeId = isPreviewMode
|
||||
@@ -1345,7 +1171,6 @@ export const CustomCameraControls = () => {
|
||||
if (!node?.camera) return
|
||||
const { position, target } = node.camera
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.setLookAt(
|
||||
position[0],
|
||||
position[1],
|
||||
@@ -1366,7 +1191,6 @@ export const CustomCameraControls = () => {
|
||||
// Otherwise, go to top view (0°)
|
||||
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.rotatePolarTo(targetAngle, true)
|
||||
}
|
||||
|
||||
@@ -1379,7 +1203,6 @@ export const CustomCameraControls = () => {
|
||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||
const target = rounded - Math.PI / 2
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.rotateTo(target, currentPolar, true)
|
||||
}
|
||||
|
||||
@@ -1392,7 +1215,6 @@ export const CustomCameraControls = () => {
|
||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||
const target = rounded + Math.PI / 2
|
||||
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.rotateTo(target, currentPolar, true)
|
||||
}
|
||||
|
||||
@@ -1404,7 +1226,6 @@ export const CustomCameraControls = () => {
|
||||
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
||||
if (!bounds) {
|
||||
// Restore default framing pose when no bounds were computed.
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||
return
|
||||
}
|
||||
@@ -1415,7 +1236,6 @@ export const CustomCameraControls = () => {
|
||||
const maxExtent = Math.max(w, d)
|
||||
const distance = Math.max(maxExtent * 1.4, 15)
|
||||
const height = Math.max(maxExtent * 0.8, 10)
|
||||
clearPendingFloorplanNavigationPose()
|
||||
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
|
||||
}
|
||||
|
||||
@@ -1436,7 +1256,7 @@ export const CustomCameraControls = () => {
|
||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||
emitter.off('camera-controls:fit-scene', handleFitScene)
|
||||
}
|
||||
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
||||
}, [focusNode, isPreviewMode, isFirstPersonMode])
|
||||
|
||||
const onTransitionStart = useCallback(() => {
|
||||
cameraDraggingLifecycle.begin()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/primitives/dialog'
|
||||
|
||||
export function DeleteConfirmationDialog() {
|
||||
const request = useDeleteConfirmation((state) => state.request)
|
||||
const cancel = useDeleteConfirmation((state) => state.cancel)
|
||||
const confirm = useDeleteConfirmation((state) => state.confirm)
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
|
||||
<DialogContent
|
||||
className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
|
||||
data-delete-confirmation-dialog
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes every selected element. You can undo the deletion while it remains in the
|
||||
editor history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
|
||||
onClick={cancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
||||
onClick={confirm}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
runAsSingleSceneHistoryStep,
|
||||
type SlabNode,
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
sceneRegistry,
|
||||
summarizeSystemFor,
|
||||
@@ -47,6 +46,7 @@ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
||||
import {
|
||||
createFreshPlacementSubtree,
|
||||
duplicatesAsFreshSubtree,
|
||||
prepareFreshPlacementRootDuplicate,
|
||||
} from '../../lib/fresh-planar-placement'
|
||||
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
||||
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
||||
@@ -54,7 +54,6 @@ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||
import { cn } from '../../lib/utils'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useInteractionScope, {
|
||||
@@ -530,20 +529,26 @@ export function FloatingActionMenu() {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||
if (draft) {
|
||||
setMovingNode(draft as any)
|
||||
setSelection({ selectedIds: [] })
|
||||
return
|
||||
let draftId: AnyNodeId | null = null
|
||||
try {
|
||||
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||
if (draft) {
|
||||
setMovingNode(draft as any)
|
||||
setSelection({ selectedIds: [] })
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
if (draftId && useScene.getState().nodes[draftId]) {
|
||||
useScene.getState().deleteNode(draftId)
|
||||
}
|
||||
console.error('Failed to duplicate node subtree', error)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
return
|
||||
}
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
|
||||
|
||||
let duplicate: AnyNode | null = null
|
||||
try {
|
||||
@@ -566,11 +571,6 @@ export function FloatingActionMenu() {
|
||||
} else if (node.type === 'roof-segment') {
|
||||
duplicateInfo.id = generateId('rseg')
|
||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'stair') {
|
||||
duplicateInfo.children = []
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
||||
delete duplicateInfo.metadata?.isNew
|
||||
duplicate = StairNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'stair-segment') {
|
||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'spawn') {
|
||||
@@ -608,11 +608,7 @@ export function FloatingActionMenu() {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (duplicate.type === 'fence') {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (
|
||||
duplicate.type === 'roof-segment' ||
|
||||
duplicate.type === 'stair' ||
|
||||
duplicate.type === 'stair-segment'
|
||||
) {
|
||||
} else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
|
||||
// Add small offset to make it visible
|
||||
if ('position' in duplicate) {
|
||||
duplicate.position = [
|
||||
@@ -621,13 +617,7 @@ export function FloatingActionMenu() {
|
||||
duplicate.position[2] + 1,
|
||||
]
|
||||
}
|
||||
if (node.type === 'stair' && duplicate.type === 'stair') {
|
||||
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||
} else {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
// Duplicate children for stair nodes
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (
|
||||
duplicate.type === 'item' ||
|
||||
duplicate.type === 'chimney' ||
|
||||
@@ -696,12 +686,8 @@ export function FloatingActionMenu() {
|
||||
nodeRegistry.has(duplicate.type)
|
||||
) {
|
||||
setMovingNode(duplicate as any)
|
||||
} else if (duplicate.type === 'stair') {
|
||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||
}
|
||||
if (duplicate.type !== 'stair') {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
},
|
||||
[node, setMovingNode, setSelection],
|
||||
|
||||
@@ -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,221 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { CameraPose } from '@pascal-app/core'
|
||||
import { publishInitialCameraPose } from '../../lib/camera-pose'
|
||||
import type { NavigationSyncPose } from '../../store/use-editor'
|
||||
import {
|
||||
cameraPoseToFloorplanNavigationPose,
|
||||
createFloorplanCameraNavigationChannel,
|
||||
createFloorplanCameraSyncBridge,
|
||||
floorplanNavigationPoseToCameraPose,
|
||||
} from './floorplan-camera-sync'
|
||||
|
||||
const cameraPose: CameraPose = {
|
||||
fov: 50,
|
||||
position: [3, 4, 4],
|
||||
projection: 'perspective',
|
||||
target: [0, 1, 0],
|
||||
viewWidth: 12,
|
||||
}
|
||||
|
||||
function cameraPoseAtAzimuth(
|
||||
azimuth: number,
|
||||
target: [number, number, number] = [0, 1, 0],
|
||||
): CameraPose {
|
||||
const horizontalDistance = 5
|
||||
return {
|
||||
...cameraPose,
|
||||
position: [
|
||||
target[0] + Math.sin(azimuth) * horizontalDistance,
|
||||
4,
|
||||
target[2] + Math.cos(azimuth) * horizontalDistance,
|
||||
],
|
||||
target,
|
||||
}
|
||||
}
|
||||
|
||||
describe('floorplan camera sync', () => {
|
||||
test('derives the floor-plan navigation pose from the generic camera pose', () => {
|
||||
expect(
|
||||
cameraPoseToFloorplanNavigationPose({
|
||||
position: [10, 5, 0],
|
||||
projection: 'perspective',
|
||||
target: [0, 0, 0],
|
||||
viewWidth: 20,
|
||||
}),
|
||||
).toEqual({
|
||||
source: '3d',
|
||||
target: [0, 0, 0],
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 20,
|
||||
})
|
||||
})
|
||||
|
||||
test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
|
||||
expect(
|
||||
floorplanNavigationPoseToCameraPose(
|
||||
{
|
||||
source: '2d',
|
||||
revision: 4,
|
||||
target: [10, 2, 20],
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 8,
|
||||
},
|
||||
cameraPose,
|
||||
),
|
||||
).toEqual({
|
||||
fov: 50,
|
||||
position: [15, 5, 20],
|
||||
projection: 'perspective',
|
||||
target: [10, 2, 20],
|
||||
viewWidth: 8,
|
||||
})
|
||||
})
|
||||
|
||||
test('owns two-way synchronization without echoing tiny camera changes', () => {
|
||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||
const applied: CameraPose[] = []
|
||||
const bridge = createFloorplanCameraSyncBridge({
|
||||
applyCameraPose: (pose) => applied.push(pose),
|
||||
publishNavigationPose: (pose) => published.push(pose),
|
||||
})
|
||||
|
||||
bridge.receiveCameraPose(cameraPose)
|
||||
bridge.receiveCameraPose({
|
||||
...cameraPose,
|
||||
position: [3.0001, 4, 4],
|
||||
})
|
||||
bridge.receiveNavigationPose({
|
||||
source: '2d',
|
||||
revision: 1,
|
||||
target: [10, 2, 20],
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 8,
|
||||
})
|
||||
|
||||
expect(published).toHaveLength(1)
|
||||
expect(published[0]?.source).toBe('3d')
|
||||
expect(applied).toHaveLength(1)
|
||||
expect(applied[0]).toMatchObject({
|
||||
fov: 50,
|
||||
projection: 'perspective',
|
||||
target: [10, 2, 20],
|
||||
viewWidth: 8,
|
||||
})
|
||||
expect(applied[0]?.position[0]).toBeCloseTo(15)
|
||||
expect(applied[0]?.position[1]).toBeCloseTo(5)
|
||||
expect(applied[0]?.position[2]).toBeCloseTo(20)
|
||||
})
|
||||
|
||||
test('publishes sub-degree camera rotation for a real-time compass', () => {
|
||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||
const bridge = createFloorplanCameraSyncBridge({
|
||||
applyCameraPose: () => {},
|
||||
publishNavigationPose: (pose) => published.push(pose),
|
||||
})
|
||||
|
||||
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
||||
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
|
||||
expect(published).toHaveLength(2)
|
||||
expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
|
||||
})
|
||||
|
||||
test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
|
||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||
const bridge = createFloorplanCameraSyncBridge({
|
||||
applyCameraPose: () => {},
|
||||
publishNavigationPose: (pose) => published.push(pose),
|
||||
})
|
||||
|
||||
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
||||
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
|
||||
|
||||
expect(published).toHaveLength(2)
|
||||
expect(published[1]).toMatchObject({
|
||||
target: [1, 1, 0],
|
||||
})
|
||||
expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
|
||||
})
|
||||
|
||||
test('keeps streaming live camera headings in 3D-only mode', () => {
|
||||
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
||||
const applied: CameraPose[] = []
|
||||
const bridge = createFloorplanCameraSyncBridge({
|
||||
active: false,
|
||||
applyCameraPose: (pose) => applied.push(pose),
|
||||
publishNavigationPose: (pose) => published.push(pose),
|
||||
})
|
||||
const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
|
||||
|
||||
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
||||
bridge.receiveCameraPose(latestPose)
|
||||
bridge.receiveNavigationPose({
|
||||
source: '2d',
|
||||
revision: 1,
|
||||
target: [10, 2, 20],
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 8,
|
||||
})
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
source: '3d',
|
||||
target: cameraPose.target,
|
||||
azimuth: 0,
|
||||
viewWidth: 12,
|
||||
},
|
||||
{
|
||||
source: '3d',
|
||||
target: latestPose.target,
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 12,
|
||||
},
|
||||
])
|
||||
expect(applied).toEqual([])
|
||||
|
||||
bridge.setActive(true)
|
||||
expect(published).toHaveLength(2)
|
||||
expect(applied).toEqual([])
|
||||
})
|
||||
|
||||
test('applies 2D-first navigation on initial load without a 3D interaction', () => {
|
||||
const applied: CameraPose[] = []
|
||||
const bridge = createFloorplanCameraSyncBridge({
|
||||
applyCameraPose: (pose) => applied.push(pose),
|
||||
publishNavigationPose: () => {},
|
||||
})
|
||||
|
||||
bridge.receiveNavigationPose({
|
||||
source: '2d',
|
||||
revision: 1,
|
||||
target: [10, 2, 20],
|
||||
azimuth: Math.PI / 2,
|
||||
viewWidth: 8,
|
||||
})
|
||||
expect(applied).toEqual([])
|
||||
|
||||
publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
|
||||
expect(applied).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('delivers the live camera stream through transient subscribers', () => {
|
||||
const channel = createFloorplanCameraNavigationChannel()
|
||||
const received: NavigationSyncPose[] = []
|
||||
const unsubscribe = channel.subscribe((pose) => received.push(pose))
|
||||
const input = {
|
||||
source: '3d' as const,
|
||||
target: [0, 1, 2] as [number, number, number],
|
||||
azimuth: 0.5,
|
||||
viewWidth: 12,
|
||||
}
|
||||
|
||||
channel.publish(input)
|
||||
channel.publish({ ...input, azimuth: 0.75 })
|
||||
unsubscribe()
|
||||
channel.publish({ ...input, azimuth: 1 })
|
||||
|
||||
expect(received).toEqual([
|
||||
{ ...input, revision: 1 },
|
||||
{ ...input, azimuth: 0.75, revision: 2 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { type CameraPose, emitter } from '@pascal-app/core'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { subscribeCameraPose } from '../../store/camera-pose-store'
|
||||
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
|
||||
import useEditor, {
|
||||
type NavigationSyncPose,
|
||||
type NavigationSyncPoseInput,
|
||||
} from '../../store/use-editor'
|
||||
|
||||
const POSITION_EPSILON = 0.001
|
||||
const AZIMUTH_EPSILON = 1e-4
|
||||
const VIEW_WIDTH_EPSILON = 0.001
|
||||
|
||||
type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
|
||||
|
||||
function angleDeltaRadians(a: number, b: number) {
|
||||
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
||||
}
|
||||
|
||||
function normalizeNearZero(value: number) {
|
||||
return Math.abs(value) < Number.EPSILON * 10 ? 0 : value
|
||||
}
|
||||
|
||||
function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot {
|
||||
return {
|
||||
target: [...pose.target],
|
||||
azimuth: pose.azimuth,
|
||||
viewWidth: pose.viewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
function navigationSnapshotsEqual(
|
||||
previous: FloorplanNavigationSnapshot,
|
||||
next: FloorplanNavigationSnapshot,
|
||||
) {
|
||||
return (
|
||||
Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON &&
|
||||
Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON &&
|
||||
Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON &&
|
||||
Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON &&
|
||||
Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON
|
||||
)
|
||||
}
|
||||
|
||||
export function cameraPoseToFloorplanNavigationPose(
|
||||
pose: CameraPose,
|
||||
): NavigationSyncPoseInput | null {
|
||||
if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
source: '3d',
|
||||
target: [...pose.target],
|
||||
azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]),
|
||||
viewWidth: pose.viewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export function floorplanNavigationPoseToCameraPose(
|
||||
navigationPose: NavigationSyncPose,
|
||||
cameraPose: CameraPose,
|
||||
): CameraPose {
|
||||
const offsetX = cameraPose.position[0] - cameraPose.target[0]
|
||||
const offsetY = cameraPose.position[1] - cameraPose.target[1]
|
||||
const offsetZ = cameraPose.position[2] - cameraPose.target[2]
|
||||
const horizontalDistance = Math.hypot(offsetX, offsetZ)
|
||||
const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance)
|
||||
const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance)
|
||||
const target: [number, number, number] = [...navigationPose.target]
|
||||
|
||||
return {
|
||||
...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }),
|
||||
position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ],
|
||||
projection: cameraPose.projection,
|
||||
target,
|
||||
viewWidth: navigationPose.viewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export type FloorplanCameraSyncBridge = {
|
||||
receiveCameraPose: (pose: CameraPose) => void
|
||||
receiveNavigationPose: (pose: NavigationSyncPose | null) => void
|
||||
setActive: (active: boolean) => void
|
||||
}
|
||||
|
||||
export type FloorplanCameraNavigationChannel = {
|
||||
publish: (pose: NavigationSyncPoseInput) => void
|
||||
subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void
|
||||
}
|
||||
|
||||
export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel {
|
||||
const listeners = new Set<(pose: NavigationSyncPose) => void>()
|
||||
let revision = 0
|
||||
|
||||
return {
|
||||
publish: (pose) => {
|
||||
revision += 1
|
||||
const revisedPose = { ...pose, revision }
|
||||
for (const listener of listeners) {
|
||||
listener(revisedPose)
|
||||
}
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const liveCameraNavigation = createFloorplanCameraNavigationChannel()
|
||||
|
||||
export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) {
|
||||
return liveCameraNavigation.subscribe(listener)
|
||||
}
|
||||
|
||||
export function createFloorplanCameraSyncBridge({
|
||||
active: initialActive = true,
|
||||
applyCameraPose,
|
||||
publishNavigationPose,
|
||||
}: {
|
||||
active?: boolean
|
||||
applyCameraPose: (pose: CameraPose) => void
|
||||
publishNavigationPose: (pose: NavigationSyncPoseInput) => void
|
||||
}): FloorplanCameraSyncBridge {
|
||||
let active = initialActive
|
||||
let latestCameraPose: CameraPose | null = null
|
||||
let pendingNavigationPose: NavigationSyncPose | null = null
|
||||
let lastAppliedNavigationRevision = 0
|
||||
let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null
|
||||
|
||||
const applyPendingNavigationPose = () => {
|
||||
if (!(latestCameraPose && pendingNavigationPose)) return false
|
||||
if (pendingNavigationPose.revision === lastAppliedNavigationRevision) {
|
||||
pendingNavigationPose = null
|
||||
return false
|
||||
}
|
||||
|
||||
const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose)
|
||||
const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose)
|
||||
latestCameraPose = appliedPose
|
||||
lastAppliedNavigationRevision = pendingNavigationPose.revision
|
||||
pendingNavigationPose = null
|
||||
if (appliedNavigationPose) {
|
||||
lastPublishedNavigation = navigationSnapshot(appliedNavigationPose)
|
||||
}
|
||||
applyCameraPose(appliedPose)
|
||||
return true
|
||||
}
|
||||
|
||||
const publishCameraNavigationPose = (pose: CameraPose) => {
|
||||
const navigationPose = cameraPoseToFloorplanNavigationPose(pose)
|
||||
if (!navigationPose) return
|
||||
const nextSnapshot = navigationSnapshot(navigationPose)
|
||||
if (
|
||||
lastPublishedNavigation &&
|
||||
navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastPublishedNavigation = nextSnapshot
|
||||
publishNavigationPose(navigationPose)
|
||||
}
|
||||
|
||||
return {
|
||||
receiveCameraPose: (pose) => {
|
||||
latestCameraPose = pose
|
||||
if (active && applyPendingNavigationPose()) return
|
||||
|
||||
publishCameraNavigationPose(pose)
|
||||
},
|
||||
receiveNavigationPose: (pose) => {
|
||||
if (
|
||||
!active ||
|
||||
pose?.source !== '2d' ||
|
||||
pose.revision === lastAppliedNavigationRevision ||
|
||||
pose.revision === pendingNavigationPose?.revision
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingNavigationPose = pose
|
||||
applyPendingNavigationPose()
|
||||
},
|
||||
setActive: (nextActive) => {
|
||||
if (active === nextActive) return
|
||||
active = nextActive
|
||||
if (!active) {
|
||||
pendingNavigationPose = null
|
||||
return
|
||||
}
|
||||
if (latestCameraPose) publishCameraNavigationPose(latestCameraPose)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useFloorplanCameraSyncBridge() {
|
||||
const active = useEditor((state) => state.viewMode !== '3d')
|
||||
const bridgeRef = useRef<FloorplanCameraSyncBridge | null>(null)
|
||||
if (!bridgeRef.current) {
|
||||
bridgeRef.current = createFloorplanCameraSyncBridge({
|
||||
active,
|
||||
applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose),
|
||||
publishNavigationPose: (pose) => liveCameraNavigation.publish(pose),
|
||||
})
|
||||
}
|
||||
const bridge = bridgeRef.current
|
||||
|
||||
useEffect(() => bridge.setActive(active), [active, bridge])
|
||||
|
||||
useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge])
|
||||
|
||||
useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,42 @@ import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
canApplyFloorplanNavigationSync,
|
||||
canZoomFloorplanDuringNavigation,
|
||||
createFloorplanNavigationSyncScheduler,
|
||||
finalizeFloorplanNavigation,
|
||||
flushFloorplanRotationPresentationRestore,
|
||||
getFloorplanRotationOverscanViewBox,
|
||||
queueFloorplanRotationPresentationRestore,
|
||||
resolveFloorplanPresentationViewBox,
|
||||
setFloorplanCompassRotation,
|
||||
} from './floorplan-navigation-presentation'
|
||||
|
||||
describe('floorplan navigation presentation', () => {
|
||||
test('keeps the viewport inside the painted floorplan surface throughout rotation', () => {
|
||||
const viewport = { minX: -80, minY: -45, width: 160, height: 90 }
|
||||
const overscan = getFloorplanRotationOverscanViewBox(viewport)
|
||||
const viewportCorners: Array<[number, number]> = [
|
||||
[-80, -45],
|
||||
[80, -45],
|
||||
[80, 45],
|
||||
[-80, 45],
|
||||
]
|
||||
|
||||
for (let degrees = 0; degrees < 360; degrees += 1) {
|
||||
const radians = (-degrees * Math.PI) / 180
|
||||
const cos = Math.cos(radians)
|
||||
const sin = Math.sin(radians)
|
||||
|
||||
for (const [x, y] of viewportCorners) {
|
||||
const localX = x * cos - y * sin
|
||||
const localY = x * sin + y * cos
|
||||
expect(localX).toBeGreaterThanOrEqual(overscan.minX - 1e-10)
|
||||
expect(localX).toBeLessThanOrEqual(overscan.minX + overscan.width + 1e-10)
|
||||
expect(localY).toBeGreaterThanOrEqual(overscan.minY - 1e-10)
|
||||
expect(localY).toBeLessThanOrEqual(overscan.minY + overscan.height + 1e-10)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps the imperative viewBox authoritative during navigation', () => {
|
||||
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
||||
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
||||
@@ -29,6 +60,42 @@ describe('floorplan navigation presentation', () => {
|
||||
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
|
||||
})
|
||||
|
||||
test('updates the compass presentation on every live rotation frame', () => {
|
||||
const compass = { style: { transform: '' } }
|
||||
|
||||
setFloorplanCompassRotation(compass, 0.25)
|
||||
expect(compass.style.transform).toBe('rotate(0.25deg)')
|
||||
|
||||
setFloorplanCompassRotation(compass, 0.5)
|
||||
expect(compass.style.transform).toBe('rotate(0.5deg)')
|
||||
})
|
||||
|
||||
test('keeps the rotation preview in place until the committed state is ready to paint', () => {
|
||||
const pending = { current: null }
|
||||
const svg = {
|
||||
style: {
|
||||
transform: 'rotate(42deg)',
|
||||
transformOrigin: 'center',
|
||||
willChange: 'transform',
|
||||
},
|
||||
}
|
||||
const presentation = {
|
||||
svg,
|
||||
svgStyle: {
|
||||
transform: '',
|
||||
transformOrigin: '',
|
||||
willChange: '',
|
||||
},
|
||||
}
|
||||
|
||||
queueFloorplanRotationPresentationRestore(pending, presentation)
|
||||
expect(svg.style.transform).toBe('rotate(42deg)')
|
||||
|
||||
flushFloorplanRotationPresentationRestore(pending)
|
||||
expect(svg.style.transform).toBe('')
|
||||
expect(pending.current).toBeNull()
|
||||
})
|
||||
|
||||
test('commits every active navigation channel before teardown', () => {
|
||||
const calls: string[] = []
|
||||
const rotationState = { angle: 42 }
|
||||
@@ -44,4 +111,32 @@ describe('floorplan navigation presentation', () => {
|
||||
|
||||
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
|
||||
})
|
||||
|
||||
test('coalesces a camera pose stream into one settled React commit', () => {
|
||||
const presented: number[] = []
|
||||
const committed: number[] = []
|
||||
let scheduled: (() => void) | null = null
|
||||
const scheduler = createFloorplanNavigationSyncScheduler<number>({
|
||||
applyPresentation: (pose) => presented.push(pose),
|
||||
commit: (pose) => committed.push(pose),
|
||||
schedule: (callback) => {
|
||||
scheduled = callback
|
||||
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>
|
||||
},
|
||||
cancel: () => {
|
||||
scheduled = null
|
||||
},
|
||||
})
|
||||
|
||||
for (let pose = 1; pose <= 30; pose += 1) {
|
||||
scheduler.update(pose)
|
||||
}
|
||||
|
||||
expect(presented).toHaveLength(30)
|
||||
expect(committed).toEqual([])
|
||||
|
||||
const settle = scheduled as (() => void) | null
|
||||
settle?.()
|
||||
expect(committed).toEqual([30])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,63 @@ export type FloorplanPresentationViewBox = {
|
||||
height: number
|
||||
}
|
||||
|
||||
export function setFloorplanCompassRotation(
|
||||
compass: { style: { transform: string } } | null,
|
||||
rotationDeg: number,
|
||||
): void {
|
||||
if (compass) {
|
||||
compass.style.transform = `rotate(${rotationDeg}deg)`
|
||||
}
|
||||
}
|
||||
|
||||
type FloorplanRotationPresentation = {
|
||||
svg: {
|
||||
style: {
|
||||
transform: string
|
||||
transformOrigin: string
|
||||
willChange: string
|
||||
}
|
||||
}
|
||||
svgStyle: {
|
||||
transform: string
|
||||
transformOrigin: string
|
||||
willChange: string
|
||||
}
|
||||
}
|
||||
|
||||
export function queueFloorplanRotationPresentationRestore<
|
||||
Presentation extends FloorplanRotationPresentation,
|
||||
>(pending: { current: Presentation | null }, presentation: Presentation): void {
|
||||
pending.current = presentation
|
||||
}
|
||||
|
||||
export function flushFloorplanRotationPresentationRestore<
|
||||
Presentation extends FloorplanRotationPresentation,
|
||||
>(pending: { current: Presentation | null }): void {
|
||||
const presentation = pending.current
|
||||
if (!presentation) return
|
||||
|
||||
presentation.svg.style.transform = presentation.svgStyle.transform
|
||||
presentation.svg.style.transformOrigin = presentation.svgStyle.transformOrigin
|
||||
presentation.svg.style.willChange = presentation.svgStyle.willChange
|
||||
pending.current = null
|
||||
}
|
||||
|
||||
export function getFloorplanRotationOverscanViewBox(
|
||||
viewBox: FloorplanPresentationViewBox,
|
||||
): FloorplanPresentationViewBox {
|
||||
const size = Math.hypot(viewBox.width, viewBox.height)
|
||||
const centerX = viewBox.minX + viewBox.width / 2
|
||||
const centerY = viewBox.minY + viewBox.height / 2
|
||||
|
||||
return {
|
||||
minX: centerX - size / 2,
|
||||
minY: centerY - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFloorplanPresentationViewBox(
|
||||
reactViewBox: FloorplanPresentationViewBox,
|
||||
imperativeViewBox: FloorplanPresentationViewBox | null,
|
||||
@@ -21,6 +78,63 @@ export function canApplyFloorplanNavigationSync(interactionInProgress: boolean):
|
||||
return !interactionInProgress
|
||||
}
|
||||
|
||||
export type FloorplanNavigationSyncScheduler<Pose> = {
|
||||
update: (pose: Pose) => void
|
||||
flush: () => void
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
export function createFloorplanNavigationSyncScheduler<Pose>({
|
||||
applyPresentation,
|
||||
commit,
|
||||
settleMs = 120,
|
||||
schedule = globalThis.setTimeout,
|
||||
cancel = globalThis.clearTimeout,
|
||||
}: {
|
||||
applyPresentation: (pose: Pose) => void
|
||||
commit: (pose: Pose) => void
|
||||
settleMs?: number
|
||||
schedule?: (callback: () => void, delay: number) => ReturnType<typeof globalThis.setTimeout>
|
||||
cancel?: (timer: ReturnType<typeof globalThis.setTimeout>) => void
|
||||
}): FloorplanNavigationSyncScheduler<Pose> {
|
||||
let latestPose: Pose | null = null
|
||||
let settleTimer: ReturnType<typeof globalThis.setTimeout> | null = null
|
||||
|
||||
const clearSettleTimer = () => {
|
||||
if (settleTimer === null) return
|
||||
cancel(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
clearSettleTimer()
|
||||
if (latestPose === null) return
|
||||
const pose = latestPose
|
||||
latestPose = null
|
||||
commit(pose)
|
||||
}
|
||||
|
||||
const update = (pose: Pose) => {
|
||||
latestPose = pose
|
||||
applyPresentation(pose)
|
||||
clearSettleTimer()
|
||||
settleTimer = schedule(() => {
|
||||
settleTimer = null
|
||||
if (latestPose === null) return
|
||||
const settledPose = latestPose
|
||||
latestPose = null
|
||||
commit(settledPose)
|
||||
}, settleMs)
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
clearSettleTimer()
|
||||
latestPose = null
|
||||
}
|
||||
|
||||
return { update, flush, discard }
|
||||
}
|
||||
|
||||
export function finalizeFloorplanNavigation<RotationState>({
|
||||
zoomPending,
|
||||
panActive,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,13 @@ import {
|
||||
type AnyNodeId,
|
||||
bboxCornerAnchors,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
pauseSceneHistory,
|
||||
pauseSpaceDetection,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
resumeSpaceDetection,
|
||||
type SceneMaterialId,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Plane, Vector2, Vector3 } from 'three'
|
||||
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||
import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import {
|
||||
copySelectedNodesToEditorClipboard,
|
||||
duplicateNodesToLevel,
|
||||
getEditorClipboardSnapshot,
|
||||
pasteSystemEditorClipboardToLevel,
|
||||
} from '../../lib/scene-clipboard'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||
import useEditor, {
|
||||
isAlignmentGuideActive,
|
||||
isGridSnapActive,
|
||||
@@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean {
|
||||
* and drag them along with the copies.
|
||||
*/
|
||||
export function startGroupPickUp(
|
||||
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
||||
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
||||
): boolean {
|
||||
const { selectedIds, levelId } = useViewer.getState().selection
|
||||
const participantIds = groupParticipantIds()
|
||||
@@ -202,16 +210,18 @@ export function startGroupPickUp(
|
||||
const applyMove = (e: PointerEvent) => {
|
||||
const plan = resolvePlanPoint(e)
|
||||
if (!plan) return
|
||||
// Delta-relative to where tracking starts so the group never teleports
|
||||
// to the cursor.
|
||||
if (!startPlan) {
|
||||
// Ordinary moves are delta-relative so the group never teleports. A
|
||||
// pasted selection instead arrives centered under the cursor.
|
||||
if (!opts.positionAtCursor && !startPlan) {
|
||||
startPlan = plan
|
||||
return
|
||||
}
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const snap = isGridSnapActive() && step > 0
|
||||
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
||||
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
||||
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
||||
const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
|
||||
let dx = snap ? Math.round(rawDx / step) * step : rawDx
|
||||
let dz = snap ? Math.round(rawDz / step) * step : rawDz
|
||||
|
||||
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
@@ -361,6 +371,7 @@ export function startGroupPickUp(
|
||||
// Only a press over a tracked surface commits; a click on side panels or
|
||||
// the toolbar keeps the pick-up alive.
|
||||
if (!resolvePlanPoint(e)) return
|
||||
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
commitPointerId = e.pointerId
|
||||
@@ -380,6 +391,17 @@ export function startGroupPickUp(
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase()
|
||||
if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
|
||||
// A clipboard chord replaces the current carry. Let the global keyboard
|
||||
// arm receive the same event after this cancellation. Capture C/X first:
|
||||
// pasted or duplicated carries delete their transient selection while
|
||||
// cancelling, so the global arm would otherwise see nothing.
|
||||
if (key === 'c' || key === 'x') {
|
||||
copySelectedNodesToEditorClipboard()
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function sceneReferencesMaterial(materialId: string) {
|
||||
const reference = `scene:${materialId}`
|
||||
const containsReference = (value: unknown): boolean => {
|
||||
if (value === reference) return true
|
||||
if (Array.isArray(value)) return value.some(containsReference)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(containsReference)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return Object.values(useScene.getState().nodes).some(containsReference)
|
||||
}
|
||||
|
||||
function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
|
||||
for (const materialId of materialIds) {
|
||||
if (!sceneReferencesMaterial(materialId)) {
|
||||
useScene.getState().removeSceneMaterial(materialId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste the Pascal scene payload from the browser clipboard onto the active
|
||||
* level, then carry the clones under the cursor until click-to-place. Escape
|
||||
* removes the uncommitted clones and any scene materials imported with them.
|
||||
*/
|
||||
export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
|
||||
const activeScope = useInteractionScope.getState().scope
|
||||
if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
|
||||
emitter.emit('tool:cancel')
|
||||
}
|
||||
|
||||
const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
|
||||
if (!result || result.pastedIds.length === 0) return false
|
||||
|
||||
const discardPaste = () => {
|
||||
useScene.getState().deleteNodes(result.pastedIds)
|
||||
removeUnusedPasteMaterials(result.createdMaterialIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
if (result.pastedIds.length === 1) {
|
||||
const rootId = result.pastedIds[0]!
|
||||
const root = useScene.getState().nodes[rootId]
|
||||
if (root?.type === 'door' || root?.type === 'window') {
|
||||
const metadata =
|
||||
root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
|
||||
? (root.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const draft = { ...root, metadata: { ...metadata, isNew: true } }
|
||||
useScene.getState().updateNode(rootId, { metadata: draft.metadata })
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
const unsubscribe = useInteractionScope.subscribe((state, previous) => {
|
||||
const previousOwnsDraft =
|
||||
(previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
|
||||
previous.scope.nodeId === rootId
|
||||
const currentOwnsDraft =
|
||||
(state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
|
||||
state.scope.nodeId === rootId
|
||||
if (!previousOwnsDraft || currentOwnsDraft) return
|
||||
unsubscribe()
|
||||
removeUnusedPasteMaterials(result.createdMaterialIds)
|
||||
})
|
||||
useEditor.getState().setMovingNode(draft)
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const started = startGroupPickUp({
|
||||
positionAtCursor: true,
|
||||
scopeToSelection: true,
|
||||
onCancel: discardPaste,
|
||||
})
|
||||
if (!started) sfxEmitter.emit('sfx:item-place')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
|
||||
* the copied roots. Promoted subtree selections (such as all modules in one
|
||||
* cabinet run) therefore remove the same root that Paste will recreate.
|
||||
*/
|
||||
export function cutSelectionToEditorClipboard(): boolean {
|
||||
if (!copySelectedNodesToEditorClipboard()) return false
|
||||
const payload = getEditorClipboardSnapshot()
|
||||
if (!payload || payload.rootIds.length === 0) return false
|
||||
|
||||
if (payload.rootIds.length === 1) {
|
||||
emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
|
||||
} else {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
}
|
||||
useScene.getState().deleteNodes(payload.rootIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every selected node — same semantics as the keyboard Delete arm,
|
||||
* including the accidental-bulk-delete confirm.
|
||||
@@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean {
|
||||
export function deleteSelection(): boolean {
|
||||
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedIds.length === 0) return false
|
||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
||||
)
|
||||
if (!confirmed) return false
|
||||
|
||||
const commitDelete = () => {
|
||||
if (selectedIds.length === 1) {
|
||||
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
||||
} else {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
}
|
||||
useScene.getState().deleteNodes(selectedIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
useScene.getState().deleteNodes(selectedIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
|
||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||
useDeleteConfirmation.getState().requestConfirmation({
|
||||
count: selectedIds.length,
|
||||
onConfirm: commitDelete,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
commitDelete()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -58,12 +59,14 @@ import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
|
||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||
import { ExportManager } from './export-manager'
|
||||
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'
|
||||
@@ -1037,6 +1040,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
2d / 3d / split alike) can anchor to this container's bottom-left. */}
|
||||
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
||||
<QuickMeasurementHud />
|
||||
<DeleteConfirmationDialog />
|
||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
@@ -1174,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])
|
||||
|
||||
@@ -1388,6 +1394,7 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloorplanModeCoordinator />
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader className="bg-background" />
|
||||
@@ -1463,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" />
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from '../../lib/paint-scope'
|
||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||
import {
|
||||
emitCanvasNodeSelection,
|
||||
resolveCanvasSelectionNode,
|
||||
resolveNodeSelectionTarget,
|
||||
resolveSelectedIdsForNodeClick,
|
||||
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
|
||||
useEffect(() => {
|
||||
if (mode !== 'select') return
|
||||
let owns = false
|
||||
let prevKey = ' | ||||