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
|
# Pascal Editor
|
||||||
|
|
||||||
A 3D building editor built with React Three Fiber and WebGPU.
|
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 Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { BuildTab } from '@/components/build-tab'
|
import { BuildTab } from '@/components/build-tab'
|
||||||
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
|
|
||||||
import {
|
import {
|
||||||
CommunityViewerToolbarLeft,
|
CommunityViewerToolbarLeft,
|
||||||
CommunityViewerToolbarRight,
|
CommunityViewerToolbarRight,
|
||||||
@@ -90,7 +89,6 @@ const PROJECT_ID = 'local-editor'
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-screen">
|
<div className="relative h-screen w-screen">
|
||||||
<FloorplanConstructionPreflight />
|
|
||||||
{PROJECT_ID === 'local-editor' && (
|
{PROJECT_ID === 'local-editor' && (
|
||||||
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
|
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
|
||||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
|
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { nodeRegistry } from '@pascal-app/core'
|
import { nodeRegistry } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
|
type FloorplanMode,
|
||||||
getFloorplanNodeExtension,
|
getFloorplanNodeExtension,
|
||||||
|
isFloorplanToolAvailableInMode,
|
||||||
MaterialPaintPanel,
|
MaterialPaintPanel,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
useFloorplanMode,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@@ -73,7 +76,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
|
|||||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
|
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function collectBuildTypes(): BuildType[] {
|
function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] {
|
||||||
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
|
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) => ({
|
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
|
||||||
...type,
|
...type,
|
||||||
@@ -86,6 +89,7 @@ function collectBuildTypes(): BuildType[] {
|
|||||||
if (
|
if (
|
||||||
baseKinds.has(kind) ||
|
baseKinds.has(kind) ||
|
||||||
!extension?.tool ||
|
!extension?.tool ||
|
||||||
|
!isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) ||
|
||||||
!presentation ||
|
!presentation ||
|
||||||
presentation.hidden ||
|
presentation.hidden ||
|
||||||
presentation.paletteSection !== 'structure'
|
presentation.paletteSection !== 'structure'
|
||||||
@@ -126,7 +130,15 @@ const MEP_ITEMS: MepItem[] = [
|
|||||||
*/
|
*/
|
||||||
function activateBuildTool(kind: string): void {
|
function activateBuildTool(kind: string): void {
|
||||||
const ed = useEditor.getState()
|
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)
|
if (preferredView) ed.setViewMode(preferredView)
|
||||||
ed.setPhase('structure')
|
ed.setPhase('structure')
|
||||||
ed.setStructureLayer('elements')
|
ed.setStructureLayer('elements')
|
||||||
@@ -184,9 +196,10 @@ const MEP_TOOL_KINDS = new Set<string>([
|
|||||||
export function BuildTab() {
|
export function BuildTab() {
|
||||||
const activeTool = useEditor((s) => s.tool)
|
const activeTool = useEditor((s) => s.tool)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const floorplanMode = useFloorplanMode((s) => s.mode)
|
||||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||||
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||||
const buildTypes = useMemo(collectBuildTypes, [])
|
const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode])
|
||||||
|
|
||||||
// The fitting / follow tools are armed from a segment's panel, not a grid
|
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||||
// tile — keep the segment tile lit so the panel (and the way back) stays
|
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||||
|
|||||||
@@ -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 { Icon as IconifyIcon } from '@iconify/react'
|
||||||
import {
|
import {
|
||||||
DRAWING_TYPE_OPTIONS,
|
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@@ -11,9 +10,9 @@ import {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
useDrawingView,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
useFloorplanAnnotationVisibility,
|
useFloorplanAnnotationVisibility,
|
||||||
|
useFloorplanMode,
|
||||||
useSidebarStore,
|
useSidebarStore,
|
||||||
type ViewMode,
|
type ViewMode,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
@@ -150,6 +149,19 @@ const FLOORPLAN_ANNOTATION_OPTIONS = [
|
|||||||
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
|
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
|
||||||
] as const
|
] 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 = [
|
const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
|
||||||
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
|
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
|
||||||
{ id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
|
{ 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() {
|
function CollapseSidebarButton() {
|
||||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||||
@@ -373,6 +342,8 @@ function DisplayMenu() {
|
|||||||
const setWallDimensionReference = useFloorplanAnnotationVisibility(
|
const setWallDimensionReference = useFloorplanAnnotationVisibility(
|
||||||
(state) => state.setWallDimensionReference,
|
(state) => state.setWallDimensionReference,
|
||||||
)
|
)
|
||||||
|
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||||
|
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
|
||||||
|
|
||||||
const activeShading =
|
const activeShading =
|
||||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||||
@@ -432,62 +403,88 @@ function DisplayMenu() {
|
|||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
<Layers3 className="h-4 w-4" />
|
<Layers3 className="h-4 w-4" />
|
||||||
<span>Floor plan annotations</span>
|
<span>Floor plan mode</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">
|
<span className="ml-auto text-muted-foreground text-xs">
|
||||||
{
|
{floorplanMode === 'default' ? 'Default' : 'Expert'}
|
||||||
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
|
|
||||||
(option) => option.id === wallDimensionReference,
|
|
||||||
)?.name
|
|
||||||
}
|
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||||
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
|
{FLOORPLAN_MODE_OPTIONS.map((option) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem key={option.id} onSelect={() => setFloorplanMode(option.id)}>
|
||||||
key={option.id}
|
|
||||||
onSelect={(event) =>
|
|
||||||
keepOpen(event, () => setWallDimensionReference(option.id))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-foreground">{option.name}</span>
|
<span className="text-foreground">{option.name}</span>
|
||||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||||
</div>
|
</div>
|
||||||
{wallDimensionReference === option.id ? (
|
{floorplanMode === option.id ? (
|
||||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</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}
|
) : null}
|
||||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
||||||
@@ -694,7 +691,6 @@ export function CommunityViewerToolbarLeft() {
|
|||||||
<>
|
<>
|
||||||
<CollapseSidebarButton />
|
<CollapseSidebarButton />
|
||||||
<ViewModeControl />
|
<ViewModeControl />
|
||||||
<DrawingTypeControl />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
"agentation": "^3.0.2",
|
"agentation": "^3.0.2",
|
||||||
"react-grab": "^0.1.29",
|
"react-grab": "^0.1.50",
|
||||||
"react-scan": "^0.5.3",
|
"react-scan": "^0.5.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
"agentation": "^3.0.2",
|
"agentation": "^3.0.2",
|
||||||
"react-grab": "^0.1.29",
|
"react-grab": "^0.1.50",
|
||||||
"react-scan": "^0.5.3",
|
"react-scan": "^0.5.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
},
|
},
|
||||||
@@ -315,6 +315,7 @@
|
|||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/three": "0.184.1",
|
"@types/three": "0.184.1",
|
||||||
|
"react-grab": "0.1.50",
|
||||||
"three": "0.185.1",
|
"three": "0.185.1",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -784,7 +785,7 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"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": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/three": "0.184.1",
|
"@types/three": "0.184.1",
|
||||||
|
"react-grab": "0.1.50",
|
||||||
"three": "0.185.1"
|
"three": "0.185.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsgo --build --watch",
|
"dev": "tsgo --build --watch",
|
||||||
"test": "bun test",
|
"test": "bun test src",
|
||||||
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
DoorNode,
|
DoorNode,
|
||||||
DormerNode,
|
DormerNode,
|
||||||
DownspoutNode,
|
DownspoutNode,
|
||||||
DrawingSheetNode,
|
|
||||||
DuctFittingNode,
|
DuctFittingNode,
|
||||||
DuctSegmentNode,
|
DuctSegmentNode,
|
||||||
DuctTerminalNode,
|
DuctTerminalNode,
|
||||||
@@ -126,7 +125,6 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
|||||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||||
export type DormerEvent = NodeEvent<DormerNode>
|
export type DormerEvent = NodeEvent<DormerNode>
|
||||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||||
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
|
|
||||||
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||||
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||||
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||||
@@ -222,7 +220,6 @@ type CameraControlEvents = {
|
|||||||
'camera-controls:orbit-ccw': undefined
|
'camera-controls:orbit-ccw': undefined
|
||||||
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
||||||
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
||||||
'camera-controls:pose': CameraPose
|
|
||||||
'camera-controls:apply-pose': CameraPose
|
'camera-controls:apply-pose': CameraPose
|
||||||
'camera-controls:cancel-pose': undefined
|
'camera-controls:cancel-pose': undefined
|
||||||
'camera-controls:interaction-start': undefined
|
'camera-controls:interaction-start': undefined
|
||||||
@@ -277,6 +274,12 @@ type RoomPresetEvents = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SelectionEvents = {
|
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
|
* "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
|
* selected node; whoever owns the node's catalog/panel (host browser, a
|
||||||
@@ -322,7 +325,6 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'skylight', SkylightEvent> &
|
NodeEvents<'skylight', SkylightEvent> &
|
||||||
NodeEvents<'dormer', DormerEvent> &
|
NodeEvents<'dormer', DormerEvent> &
|
||||||
NodeEvents<'downspout', DownspoutEvent> &
|
NodeEvents<'downspout', DownspoutEvent> &
|
||||||
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
|
|
||||||
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||||
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||||
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type {
|
|||||||
ConstructionDimensionEvent,
|
ConstructionDimensionEvent,
|
||||||
DoorEvent,
|
DoorEvent,
|
||||||
DormerEvent,
|
DormerEvent,
|
||||||
DrawingSheetEvent,
|
|
||||||
ElevatorEvent,
|
ElevatorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
FenceEvent,
|
FenceEvent,
|
||||||
@@ -230,12 +229,15 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li
|
|||||||
export {
|
export {
|
||||||
type ApplySceneSnapshotOptions,
|
type ApplySceneSnapshotOptions,
|
||||||
acquireSceneReadOnlyLease,
|
acquireSceneReadOnlyLease,
|
||||||
|
applySceneOperationPatch,
|
||||||
applyScenePatch,
|
applyScenePatch,
|
||||||
applySceneSnapshot,
|
applySceneSnapshot,
|
||||||
clearSceneHistory,
|
clearSceneHistory,
|
||||||
default as useScene,
|
default as useScene,
|
||||||
type SceneMaterialPatch,
|
type SceneMaterialPatch,
|
||||||
type SceneNodePatch,
|
type SceneNodePatch,
|
||||||
|
type SceneNodeStructuralPatch,
|
||||||
|
type SceneOperationPatch,
|
||||||
type ScenePatch,
|
type ScenePatch,
|
||||||
} from './store/use-scene'
|
} from './store/use-scene'
|
||||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ export const MATERIAL_CATEGORIES = [
|
|||||||
'stone',
|
'stone',
|
||||||
'brick',
|
'brick',
|
||||||
'tile',
|
'tile',
|
||||||
|
'wallpaper',
|
||||||
'concrete',
|
'concrete',
|
||||||
'metal',
|
'metal',
|
||||||
|
'plastic',
|
||||||
'fabric',
|
'fabric',
|
||||||
|
'carpet',
|
||||||
'leather',
|
'leather',
|
||||||
'roofing',
|
'roofing',
|
||||||
'ground',
|
'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', () => {
|
test('parents the cloned root under opts.parentId when supplied', () => {
|
||||||
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
|
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
|
||||||
const { nodes } = cloneNodesInto([orig], {
|
const { nodes } = cloneNodesInto([orig], {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
remapMeasurementReferences,
|
remapMeasurementReferences,
|
||||||
} from '../lib/measurement-geometry'
|
} from '../lib/measurement-geometry'
|
||||||
import { generateId } from '../schema/base'
|
import { generateId } from '../schema/base'
|
||||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
|
||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
|
||||||
// Generic, opinion-free primitives the host app composes to implement
|
// Generic, opinion-free primitives the host app composes to implement
|
||||||
@@ -176,10 +175,6 @@ export function cloneNodesInto(
|
|||||||
if (cloned.type === 'construction-dimension') {
|
if (cloned.type === 'construction-dimension') {
|
||||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||||
}
|
}
|
||||||
if (cloned.type === 'drawing-sheet') {
|
|
||||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (original.id === opts.rootId) {
|
if (original.id === opts.rootId) {
|
||||||
if (opts.position) {
|
if (opts.position) {
|
||||||
;(cloned as { position: [number, number, number] }).position = [
|
;(cloned as { position: [number, number, number] }).position = [
|
||||||
|
|||||||
@@ -85,25 +85,6 @@ export {
|
|||||||
getEffectiveDormerSurfaceMaterial,
|
getEffectiveDormerSurfaceMaterial,
|
||||||
} from './nodes/dormer'
|
} from './nodes/dormer'
|
||||||
export { DownspoutNode } from './nodes/downspout'
|
export { DownspoutNode } from './nodes/downspout'
|
||||||
export {
|
|
||||||
DrawingSheetAnnotationProfile,
|
|
||||||
DrawingSheetDocumentMarker,
|
|
||||||
DrawingSheetDocumentMarkerKind,
|
|
||||||
DrawingSheetGeneralNote,
|
|
||||||
DrawingSheetGeneralNoteSet,
|
|
||||||
DrawingSheetKeyedNote,
|
|
||||||
DrawingSheetKeyedNoteDefinition,
|
|
||||||
DrawingSheetKeyedNoteInstance,
|
|
||||||
DrawingSheetNode,
|
|
||||||
DrawingSheetOrientation,
|
|
||||||
DrawingSheetPaperSize,
|
|
||||||
DrawingSheetPlacedView,
|
|
||||||
DrawingSheetRect,
|
|
||||||
DrawingSheetScale,
|
|
||||||
DrawingSheetSchedulePlacement,
|
|
||||||
DrawingSheetTitleBlock,
|
|
||||||
remapDrawingSheetReferences,
|
|
||||||
} from './nodes/drawing-sheet'
|
|
||||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||||
@@ -248,9 +229,6 @@ export { StructuralGridNode } from './nodes/structural-grid'
|
|||||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||||
export { TurbineVentNode } from './nodes/turbine-vent'
|
export { TurbineVentNode } from './nodes/turbine-vent'
|
||||||
export type {
|
export type {
|
||||||
WallAssemblyDatumReference,
|
|
||||||
WallAssemblyDatumSide,
|
|
||||||
WallAssemblyLayer,
|
|
||||||
WallBandSurfaceSlotId,
|
WallBandSurfaceSlotId,
|
||||||
WallFaceBand,
|
WallFaceBand,
|
||||||
WallFaceBandConfig,
|
WallFaceBandConfig,
|
||||||
@@ -263,18 +241,11 @@ export {
|
|||||||
buildEnabledWallFaceBandPatch,
|
buildEnabledWallFaceBandPatch,
|
||||||
buildWallFaceBandCountPatch,
|
buildWallFaceBandCountPatch,
|
||||||
getEffectiveWallSurfaceMaterial,
|
getEffectiveWallSurfaceMaterial,
|
||||||
getWallAssemblyDatumReferenceId,
|
|
||||||
getWallAssemblyFaceOffsets,
|
|
||||||
getWallAssemblyLayers,
|
|
||||||
getWallAssemblyThickness,
|
|
||||||
getWallBandSlotId,
|
getWallBandSlotId,
|
||||||
getWallDatumEligibleLayers,
|
|
||||||
getWallFaceBandConfig,
|
getWallFaceBandConfig,
|
||||||
getWallFaceBandForHeight,
|
getWallFaceBandForHeight,
|
||||||
getWallSurfaceMaterialSignature,
|
getWallSurfaceMaterialSignature,
|
||||||
getWallSurfaceSideFromBandSlot,
|
getWallSurfaceSideFromBandSlot,
|
||||||
resolveWallAssemblyDatumReference,
|
|
||||||
resolveWallAssemblyDatumReferences,
|
|
||||||
WALL_CHAIR_RAIL_DEFAULT,
|
WALL_CHAIR_RAIL_DEFAULT,
|
||||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||||
WALL_CROWN_DEFAULT,
|
WALL_CROWN_DEFAULT,
|
||||||
@@ -285,8 +256,6 @@ export {
|
|||||||
WALL_SLOT_DEFAULT,
|
WALL_SLOT_DEFAULT,
|
||||||
WALL_SURFACE_SLOT_DEFAULTS,
|
WALL_SURFACE_SLOT_DEFAULTS,
|
||||||
WALL_TRIM_DEFAULTS,
|
WALL_TRIM_DEFAULTS,
|
||||||
WallAssemblyLayerRole,
|
|
||||||
WallDimensionDatum,
|
|
||||||
WallNode,
|
WallNode,
|
||||||
WallTreatmentSide,
|
WallTreatmentSide,
|
||||||
WallTrimProfile,
|
WallTrimProfile,
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { DrawingSheetNode } from './drawing-sheet'
|
|
||||||
import { ElevatorNode } from './elevator'
|
import { ElevatorNode } from './elevator'
|
||||||
import { LevelNode } from './level'
|
import { LevelNode } from './level'
|
||||||
|
|
||||||
export const BuildingNode = BaseNode.extend({
|
export const BuildingNode = BaseNode.extend({
|
||||||
id: objectId('building'),
|
id: objectId('building'),
|
||||||
type: nodeType('building'),
|
type: nodeType('building'),
|
||||||
children: z
|
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
|
||||||
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
|
|
||||||
.default([]),
|
|
||||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
}).describe(
|
}).describe(
|
||||||
@@ -18,7 +15,7 @@ export const BuildingNode = BaseNode.extend({
|
|||||||
Building node - used to represent a building
|
Building node - used to represent a building
|
||||||
- position: position in site coordinate system
|
- position: position in site coordinate system
|
||||||
- rotation: rotation in site coordinate system
|
- rotation: rotation in site coordinate system
|
||||||
- children: array of level nodes, 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', () => {
|
test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
|
||||||
expect('height' in LevelNode.parse({})).toBe(false)
|
expect('height' in LevelNode.parse({})).toBe(false)
|
||||||
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
|
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
|
||||||
|
|||||||
@@ -1,66 +1,67 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { CeilingNode } from './ceiling'
|
import type { CeilingNode } from './ceiling'
|
||||||
import { ColumnNode } from './column'
|
import type { ColumnNode } from './column'
|
||||||
import { ConstructionDimensionNode } from './construction-dimension'
|
import type { ConstructionDimensionNode } from './construction-dimension'
|
||||||
import { DuctFittingNode } from './duct-fitting'
|
import type { DuctFittingNode } from './duct-fitting'
|
||||||
import { DuctSegmentNode } from './duct-segment'
|
import type { DuctSegmentNode } from './duct-segment'
|
||||||
import { DuctTerminalNode } from './duct-terminal'
|
import type { DuctTerminalNode } from './duct-terminal'
|
||||||
import { FenceNode } from './fence'
|
import type { FenceNode } from './fence'
|
||||||
import { GuideNode } from './guide'
|
import type { GuideNode } from './guide'
|
||||||
import { HvacEquipmentNode } from './hvac-equipment'
|
import type { HvacEquipmentNode } from './hvac-equipment'
|
||||||
import { ItemNode } from './item'
|
import type { ItemNode } from './item'
|
||||||
import { LinesetNode } from './lineset'
|
import type { LinesetNode } from './lineset'
|
||||||
import { LiquidLineNode } from './liquid-line'
|
import type { LiquidLineNode } from './liquid-line'
|
||||||
import { MeasurementNode } from './measurement'
|
import type { MeasurementNode } from './measurement'
|
||||||
import { PipeFittingNode } from './pipe-fitting'
|
import type { PipeFittingNode } from './pipe-fitting'
|
||||||
import { PipeSegmentNode } from './pipe-segment'
|
import type { PipeSegmentNode } from './pipe-segment'
|
||||||
import { PipeTrapNode } from './pipe-trap'
|
import type { PipeTrapNode } from './pipe-trap'
|
||||||
import { RoofNode } from './roof'
|
import type { RoofNode } from './roof'
|
||||||
import { ScanNode } from './scan'
|
import type { ScanNode } from './scan'
|
||||||
import { ShelfNode } from './shelf'
|
import type { ShelfNode } from './shelf'
|
||||||
import { SlabNode } from './slab'
|
import type { SlabNode } from './slab'
|
||||||
import { SpawnNode } from './spawn'
|
import type { SpawnNode } from './spawn'
|
||||||
import { StairNode } from './stair'
|
import type { StairNode } from './stair'
|
||||||
import { StructuralGridNode } from './structural-grid'
|
import type { StructuralGridNode } from './structural-grid'
|
||||||
import { WallNode } from './wall'
|
import type { WallNode } from './wall'
|
||||||
import { ZoneNode } from './zone'
|
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({
|
export const LevelNode = BaseNode.extend({
|
||||||
id: objectId('level'),
|
id: objectId('level'),
|
||||||
type: nodeType('level'),
|
type: nodeType('level'),
|
||||||
children: z
|
// The node registry owns child-kind validity. Persisted level relationships
|
||||||
.array(
|
// must also admit IDs minted by plugins that core cannot enumerate.
|
||||||
z.union([
|
children: z.array(LevelChildId).default([]),
|
||||||
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([]),
|
|
||||||
// Specific props
|
// Specific props
|
||||||
level: z.number().default(0),
|
level: z.number().default(0),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
import {
|
import {
|
||||||
buildEnabledWallFaceBandPatch,
|
buildEnabledWallFaceBandPatch,
|
||||||
buildWallFaceBandCountPatch,
|
buildWallFaceBandCountPatch,
|
||||||
getWallAssemblyDatumReferenceId,
|
|
||||||
getWallAssemblyFaceOffsets,
|
|
||||||
getWallAssemblyThickness,
|
|
||||||
getWallDatumEligibleLayers,
|
|
||||||
getWallFaceBandConfig,
|
getWallFaceBandConfig,
|
||||||
resolveWallAssemblyDatumReference,
|
|
||||||
resolveWallAssemblyDatumReferences,
|
|
||||||
WALL_CHAIR_RAIL_DEFAULT,
|
WALL_CHAIR_RAIL_DEFAULT,
|
||||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||||
WALL_CROWN_DEFAULT,
|
WALL_CROWN_DEFAULT,
|
||||||
@@ -19,7 +13,6 @@ import {
|
|||||||
WALL_SKIRTING_SLOT_DEFAULT,
|
WALL_SKIRTING_SLOT_DEFAULT,
|
||||||
WALL_SURFACE_SLOT_DEFAULTS,
|
WALL_SURFACE_SLOT_DEFAULTS,
|
||||||
WallFaceBandConfig,
|
WallFaceBandConfig,
|
||||||
WallNode,
|
|
||||||
type WallNode as WallNodeType,
|
type WallNode as WallNodeType,
|
||||||
WallTrimConfig,
|
WallTrimConfig,
|
||||||
} from './wall'
|
} from './wall'
|
||||||
@@ -267,206 +260,3 @@ describe('wall trim profiles', () => {
|
|||||||
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
|
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('wall assembly layers', () => {
|
|
||||||
test('defaults to legacy thickness when no assembly layers are modeled', () => {
|
|
||||||
const wall = WallNode.parse({
|
|
||||||
start: [0, 0],
|
|
||||||
end: [4, 0],
|
|
||||||
thickness: 0.14,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wall.assemblyLayers).toEqual([])
|
|
||||||
expect(getWallAssemblyThickness(wall)).toBe(0.14)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stores role, side, thickness, material reference, and datum eligibility', () => {
|
|
||||||
const wall = WallNode.parse({
|
|
||||||
start: [0, 0],
|
|
||||||
end: [4, 0],
|
|
||||||
assemblyLayers: [
|
|
||||||
{
|
|
||||||
id: 'stud-core',
|
|
||||||
role: 'structure',
|
|
||||||
side: 'core',
|
|
||||||
thickness: 0.09,
|
|
||||||
materialRef: 'library:wood-framing',
|
|
||||||
datumEligible: ['centerline', 'structural-face'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'interior-gwb',
|
|
||||||
role: 'interior-finish',
|
|
||||||
side: 'interior',
|
|
||||||
thickness: 0.016,
|
|
||||||
materialRef: 'library:gypsum-board',
|
|
||||||
datumEligible: ['finish-face'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brick-veneer',
|
|
||||||
role: 'masonry-veneer',
|
|
||||||
side: 'exterior',
|
|
||||||
thickness: 0.09,
|
|
||||||
materialRef: 'library:brick',
|
|
||||||
datumEligible: ['veneer-face', 'finish-face'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
|
|
||||||
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
|
|
||||||
'interior-gwb',
|
|
||||||
'brick-veneer',
|
|
||||||
])
|
|
||||||
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
|
|
||||||
{ id: 'stud-core', role: 'structure', side: 'core' },
|
|
||||||
])
|
|
||||||
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
|
|
||||||
interior: -0.061,
|
|
||||||
exterior: 0.135,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resolves stable datum references for legacy single-thickness walls', () => {
|
|
||||||
const wall = WallNode.parse({
|
|
||||||
start: [0, 0],
|
|
||||||
end: [4, 0],
|
|
||||||
thickness: 0.14,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
|
|
||||||
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
|
|
||||||
{
|
|
||||||
id: 'wall:structural-face:interior',
|
|
||||||
datum: 'structural-face',
|
|
||||||
side: 'interior',
|
|
||||||
offset: -0.07,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wall:structural-face:exterior',
|
|
||||||
datum: 'structural-face',
|
|
||||||
side: 'exterior',
|
|
||||||
offset: 0.07,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wall:finish-face:interior',
|
|
||||||
datum: 'finish-face',
|
|
||||||
side: 'interior',
|
|
||||||
offset: -0.07,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wall:finish-face:exterior',
|
|
||||||
datum: 'finish-face',
|
|
||||||
side: 'exterior',
|
|
||||||
offset: 0.07,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
|
|
||||||
const wall = WallNode.parse({
|
|
||||||
start: [0, 0],
|
|
||||||
end: [4, 0],
|
|
||||||
assemblyLayers: [
|
|
||||||
{
|
|
||||||
id: 'stud-core',
|
|
||||||
role: 'structure',
|
|
||||||
side: 'core',
|
|
||||||
thickness: 0.09,
|
|
||||||
materialRef: 'library:wood-framing',
|
|
||||||
datumEligible: ['centerline', 'structural-face'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'interior-gwb',
|
|
||||||
role: 'interior-finish',
|
|
||||||
side: 'interior',
|
|
||||||
thickness: 0.016,
|
|
||||||
materialRef: 'library:gypsum-board',
|
|
||||||
datumEligible: ['finish-face'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'exterior-sheathing',
|
|
||||||
role: 'exterior-sheathing',
|
|
||||||
side: 'exterior',
|
|
||||||
thickness: 0.012,
|
|
||||||
materialRef: 'library:sheathing',
|
|
||||||
datumEligible: ['finish-face'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brick-veneer',
|
|
||||||
role: 'masonry-veneer',
|
|
||||||
side: 'exterior',
|
|
||||||
thickness: 0.09,
|
|
||||||
materialRef: 'library:brick',
|
|
||||||
datumEligible: ['veneer-face'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const references = resolveWallAssemblyDatumReferences(wall)
|
|
||||||
|
|
||||||
expect(references).toContainEqual({
|
|
||||||
id: 'wall:centerline:center',
|
|
||||||
datum: 'centerline',
|
|
||||||
side: 'center',
|
|
||||||
offset: 0,
|
|
||||||
})
|
|
||||||
expect(references).toContainEqual({
|
|
||||||
id: 'wall:structural-face:interior:stud-core',
|
|
||||||
datum: 'structural-face',
|
|
||||||
side: 'interior',
|
|
||||||
layerId: 'stud-core',
|
|
||||||
offset: -0.045,
|
|
||||||
})
|
|
||||||
expect(references).toContainEqual({
|
|
||||||
id: 'wall:structural-face:exterior:stud-core',
|
|
||||||
datum: 'structural-face',
|
|
||||||
side: 'exterior',
|
|
||||||
layerId: 'stud-core',
|
|
||||||
offset: 0.045,
|
|
||||||
})
|
|
||||||
expect(references).toContainEqual({
|
|
||||||
id: 'wall:finish-face:interior:interior-gwb',
|
|
||||||
datum: 'finish-face',
|
|
||||||
side: 'interior',
|
|
||||||
layerId: 'interior-gwb',
|
|
||||||
offset: -0.061,
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
references.find(
|
|
||||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
datum: 'finish-face',
|
|
||||||
side: 'exterior',
|
|
||||||
layerId: 'exterior-sheathing',
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
references.find(
|
|
||||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
|
||||||
)?.offset,
|
|
||||||
).toBeCloseTo(0.057)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
|
|
||||||
).toMatchObject({
|
|
||||||
datum: 'veneer-face',
|
|
||||||
side: 'exterior',
|
|
||||||
layerId: 'brick-veneer',
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
|
|
||||||
?.offset,
|
|
||||||
).toBeCloseTo(0.147)
|
|
||||||
expect(
|
|
||||||
resolveWallAssemblyDatumReference(
|
|
||||||
wall,
|
|
||||||
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
|
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
datum: 'veneer-face',
|
|
||||||
side: 'exterior',
|
|
||||||
layerId: 'brick-veneer',
|
|
||||||
offset: 0.147,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -127,48 +127,6 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
|
|||||||
|
|
||||||
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
|
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
|
||||||
|
|
||||||
export const WallAssemblyLayerRole = z.enum([
|
|
||||||
'structure',
|
|
||||||
'interior-finish',
|
|
||||||
'exterior-sheathing',
|
|
||||||
'exterior-finish',
|
|
||||||
'masonry-veneer',
|
|
||||||
'air-space',
|
|
||||||
'concrete-block',
|
|
||||||
'structural-masonry',
|
|
||||||
'solid-concrete',
|
|
||||||
'furring',
|
|
||||||
])
|
|
||||||
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
|
|
||||||
|
|
||||||
export const WallDimensionDatum = z.enum([
|
|
||||||
'centerline',
|
|
||||||
'structural-face',
|
|
||||||
'finish-face',
|
|
||||||
'veneer-face',
|
|
||||||
])
|
|
||||||
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
|
|
||||||
|
|
||||||
export const WallAssemblyLayer = z.object({
|
|
||||||
id: z.string().trim().min(1).max(80).default('structure'),
|
|
||||||
role: WallAssemblyLayerRole.default('structure'),
|
|
||||||
side: z.enum(['core', 'interior', 'exterior']).default('core'),
|
|
||||||
thickness: z.number().finite().positive().default(0.1),
|
|
||||||
materialRef: z.string().trim().max(120).default(''),
|
|
||||||
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
|
|
||||||
})
|
|
||||||
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
|
|
||||||
|
|
||||||
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
|
|
||||||
|
|
||||||
export type WallAssemblyDatumReference = {
|
|
||||||
id: string
|
|
||||||
datum: WallDimensionDatum
|
|
||||||
side: WallAssemblyDatumSide
|
|
||||||
layerId?: string
|
|
||||||
offset: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WallNode = BaseNode.extend({
|
export const WallNode = BaseNode.extend({
|
||||||
id: objectId('wall'),
|
id: objectId('wall'),
|
||||||
type: nodeType('wall'),
|
type: nodeType('wall'),
|
||||||
@@ -191,7 +149,6 @@ export const WallNode = BaseNode.extend({
|
|||||||
// in a follow-up once migrated scenes are the norm.
|
// in a follow-up once migrated scenes are the norm.
|
||||||
slots: z.record(z.string(), z.string()).optional(),
|
slots: z.record(z.string(), z.string()).optional(),
|
||||||
thickness: z.number().optional(),
|
thickness: z.number().optional(),
|
||||||
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
|
|
||||||
height: z.number().optional(),
|
height: z.number().optional(),
|
||||||
curveOffset: z.number().optional(),
|
curveOffset: z.number().optional(),
|
||||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||||
@@ -210,7 +167,6 @@ export const WallNode = BaseNode.extend({
|
|||||||
dedent`
|
dedent`
|
||||||
Wall node - used to represent a wall in the building
|
Wall node - used to represent a wall in the building
|
||||||
- thickness: thickness in meters
|
- thickness: thickness in meters
|
||||||
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
|
|
||||||
- height: height in meters
|
- height: height in meters
|
||||||
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
|
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
|
||||||
- start: start point of the wall in level coordinate system
|
- start: start point of the wall in level coordinate system
|
||||||
@@ -234,222 +190,6 @@ export type WallBandSurfaceSlotId =
|
|||||||
| 'upperExterior'
|
| 'upperExterior'
|
||||||
| 'topExterior'
|
| 'topExterior'
|
||||||
|
|
||||||
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
|
|
||||||
return wall.assemblyLayers ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWallAssemblyThickness(
|
|
||||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
|
||||||
): number {
|
|
||||||
const layers = wall.assemblyLayers ?? []
|
|
||||||
if (layers.length === 0) return wall.thickness ?? 0.1
|
|
||||||
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
|
|
||||||
interior: number
|
|
||||||
exterior: number
|
|
||||||
} {
|
|
||||||
const layers = wall.assemblyLayers ?? []
|
|
||||||
if (layers.length === 0) {
|
|
||||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
|
||||||
return { interior: -halfThickness, exterior: halfThickness }
|
|
||||||
}
|
|
||||||
|
|
||||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
|
||||||
const coreThickness =
|
|
||||||
coreLayers.length > 0
|
|
||||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
|
||||||
: (wall.thickness ?? 0.1)
|
|
||||||
const interiorFinishThickness = layers
|
|
||||||
.filter((layer) => layer.side === 'interior')
|
|
||||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
|
||||||
const exteriorFinishThickness = layers
|
|
||||||
.filter((layer) => layer.side === 'exterior')
|
|
||||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
interior: -coreThickness / 2 - interiorFinishThickness,
|
|
||||||
exterior: coreThickness / 2 + exteriorFinishThickness,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWallDatumEligibleLayers(
|
|
||||||
wall: Pick<WallNode, 'assemblyLayers'>,
|
|
||||||
datum: WallDimensionDatum,
|
|
||||||
): WallAssemblyLayer[] {
|
|
||||||
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWallAssemblyDatumReferenceId(
|
|
||||||
datum: WallDimensionDatum,
|
|
||||||
side: WallAssemblyDatumSide,
|
|
||||||
layerId?: string,
|
|
||||||
): string {
|
|
||||||
return ['wall', datum, side, layerId].filter(Boolean).join(':')
|
|
||||||
}
|
|
||||||
|
|
||||||
type WallAssemblyLayerSpan = {
|
|
||||||
layer: WallAssemblyLayer
|
|
||||||
interiorOffset: number
|
|
||||||
exteriorOffset: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWallAssemblyLayerSpans(
|
|
||||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
|
||||||
): WallAssemblyLayerSpan[] {
|
|
||||||
const layers = wall.assemblyLayers ?? []
|
|
||||||
if (layers.length === 0) return []
|
|
||||||
|
|
||||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
|
||||||
const coreThickness =
|
|
||||||
coreLayers.length > 0
|
|
||||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
|
||||||
: (wall.thickness ?? 0.1)
|
|
||||||
const coreInteriorFace = -coreThickness / 2
|
|
||||||
const coreExteriorFace = coreThickness / 2
|
|
||||||
const spans: WallAssemblyLayerSpan[] = []
|
|
||||||
|
|
||||||
let coreOffset = coreInteriorFace
|
|
||||||
for (const layer of coreLayers) {
|
|
||||||
const interiorOffset = coreOffset
|
|
||||||
const exteriorOffset = coreOffset + layer.thickness
|
|
||||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
|
||||||
coreOffset = exteriorOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
let interiorOffset = coreInteriorFace
|
|
||||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
|
||||||
const exteriorOffset = interiorOffset
|
|
||||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
|
||||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
|
||||||
interiorOffset = nextInteriorOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
let exteriorOffset = coreExteriorFace
|
|
||||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
|
||||||
const interiorFaceOffset = exteriorOffset
|
|
||||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
|
||||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
|
||||||
exteriorOffset = nextExteriorOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
return spans
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWallAssemblyDatumReference(
|
|
||||||
datum: WallDimensionDatum,
|
|
||||||
side: WallAssemblyDatumSide,
|
|
||||||
offset: number,
|
|
||||||
layerId?: string,
|
|
||||||
): WallAssemblyDatumReference {
|
|
||||||
return {
|
|
||||||
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
|
|
||||||
datum,
|
|
||||||
side,
|
|
||||||
...(layerId ? { layerId } : {}),
|
|
||||||
offset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWallAssemblyDatumReferences(
|
|
||||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
|
||||||
): WallAssemblyDatumReference[] {
|
|
||||||
const layers = wall.assemblyLayers ?? []
|
|
||||||
const references: WallAssemblyDatumReference[] = [
|
|
||||||
createWallAssemblyDatumReference('centerline', 'center', 0),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (layers.length === 0) {
|
|
||||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
|
||||||
return [
|
|
||||||
...references,
|
|
||||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
|
||||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
|
||||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
|
||||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const spans = getWallAssemblyLayerSpans(wall)
|
|
||||||
|
|
||||||
for (const span of spans) {
|
|
||||||
if (span.layer.datumEligible.includes('structural-face')) {
|
|
||||||
if (span.layer.side === 'core') {
|
|
||||||
references.push(
|
|
||||||
createWallAssemblyDatumReference(
|
|
||||||
'structural-face',
|
|
||||||
'interior',
|
|
||||||
span.interiorOffset,
|
|
||||||
span.layer.id,
|
|
||||||
),
|
|
||||||
createWallAssemblyDatumReference(
|
|
||||||
'structural-face',
|
|
||||||
'exterior',
|
|
||||||
span.exteriorOffset,
|
|
||||||
span.layer.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const side = span.layer.side
|
|
||||||
references.push(
|
|
||||||
createWallAssemblyDatumReference(
|
|
||||||
'structural-face',
|
|
||||||
side,
|
|
||||||
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
|
|
||||||
span.layer.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (span.layer.datumEligible.includes('finish-face')) {
|
|
||||||
const side = span.layer.side === 'core' ? 'center' : span.layer.side
|
|
||||||
const offset =
|
|
||||||
span.layer.side === 'interior'
|
|
||||||
? span.interiorOffset
|
|
||||||
: span.layer.side === 'exterior'
|
|
||||||
? span.exteriorOffset
|
|
||||||
: (span.interiorOffset + span.exteriorOffset) / 2
|
|
||||||
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (span.layer.datumEligible.includes('veneer-face')) {
|
|
||||||
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
|
|
||||||
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
|
|
||||||
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!references.some((reference) => reference.datum === 'structural-face')) {
|
|
||||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
|
||||||
references.push(
|
|
||||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
|
||||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!references.some((reference) => reference.datum === 'finish-face')) {
|
|
||||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
|
||||||
references.push(
|
|
||||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
|
||||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return references
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWallAssemblyDatumReference(
|
|
||||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
|
||||||
referenceId: string,
|
|
||||||
): WallAssemblyDatumReference | null {
|
|
||||||
return (
|
|
||||||
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declared default appearance for an unpainted wall face in colored mode —
|
// Declared default appearance for an unpainted wall face in colored mode —
|
||||||
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
||||||
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { CupolaNode } from './nodes/cupola'
|
|||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
import { DormerNode } from './nodes/dormer'
|
import { DormerNode } from './nodes/dormer'
|
||||||
import { DownspoutNode } from './nodes/downspout'
|
import { DownspoutNode } from './nodes/downspout'
|
||||||
import { DrawingSheetNode } from './nodes/drawing-sheet'
|
|
||||||
import { DuctFittingNode } from './nodes/duct-fitting'
|
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||||
import { DuctSegmentNode } from './nodes/duct-segment'
|
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||||
import { DuctTerminalNode } from './nodes/duct-terminal'
|
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||||
@@ -84,7 +83,6 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
SkylightNode,
|
SkylightNode,
|
||||||
DormerNode,
|
DormerNode,
|
||||||
DownspoutNode,
|
DownspoutNode,
|
||||||
DrawingSheetNode,
|
|
||||||
DuctSegmentNode,
|
DuctSegmentNode,
|
||||||
DuctFittingNode,
|
DuctFittingNode,
|
||||||
DuctTerminalNode,
|
DuctTerminalNode,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
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 { BuildingNode } from '../schema/nodes/building'
|
||||||
import { LevelNode } from '../schema/nodes/level'
|
import { LevelNode } from '../schema/nodes/level'
|
||||||
import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material'
|
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 useLiveTransforms from './use-live-transforms'
|
||||||
import useScene, {
|
import useScene, {
|
||||||
acquireSceneReadOnlyLease,
|
acquireSceneReadOnlyLease,
|
||||||
|
applySceneOperationPatch,
|
||||||
applyScenePatch,
|
applyScenePatch,
|
||||||
applySceneSnapshot,
|
applySceneSnapshot,
|
||||||
clearSceneHistory,
|
clearSceneHistory,
|
||||||
@@ -141,8 +145,6 @@ describe('scene commit boundary', () => {
|
|||||||
test('applies host patches without local history and marks node and parent dirty', () => {
|
test('applies host patches without local history and marks node and parent dirty', () => {
|
||||||
const commits: SceneCommit[] = []
|
const commits: SceneCommit[] = []
|
||||||
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
|
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(
|
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 3 } as Partial<AnyNode> }])).toBe(
|
||||||
true,
|
true,
|
||||||
@@ -154,8 +156,6 @@ describe('scene commit boundary', () => {
|
|||||||
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
|
||||||
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
|
expect(useScene.getState().dirtyNodes.has(LEVEL_ID)).toBe(true)
|
||||||
expect(useScene.getState().dirtyNodes.has(BUILDING_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', () => {
|
test('applies material patches atomically and dirties nodes that reference them', () => {
|
||||||
@@ -320,19 +320,343 @@ describe('scene commit boundary', () => {
|
|||||||
expect(commits).toHaveLength(0)
|
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)
|
pauseSceneHistory(useScene)
|
||||||
try {
|
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(
|
expect(applyHostNodePatches([{ id: LEVEL_ID, data: { level: 7 } as Partial<AnyNode> }])).toBe(
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
expect(levelNumber()).toBe(0)
|
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 {
|
} finally {
|
||||||
resumeSceneHistory(useScene)
|
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', () => {
|
test('applies a host snapshot as a history floor and clears live state', () => {
|
||||||
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
|
useScene.getState().updateNode(LEVEL_ID, { level: 1 } as Partial<AnyNode>)
|
||||||
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
|
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 { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
|
||||||
import {
|
import {
|
||||||
generateSceneMaterialId,
|
generateSceneMaterialId,
|
||||||
type SceneMaterial,
|
SceneMaterial,
|
||||||
type SceneMaterialId,
|
type SceneMaterialId,
|
||||||
} from '../schema/scene-material'
|
} 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 { deriveLegacyLevelHeight } from '../services/level-height'
|
||||||
import { getCeilingClampBound } from '../services/storey'
|
import { getCeilingClampBound } from '../services/storey'
|
||||||
import { computeWallSlabSupport } from '../systems/slab/slab-support'
|
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;
|
// 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
|
// ceilings whose stored height lands within this of their clamp bound become
|
||||||
// follows-mode (step 3f) — same census-backed threshold for both.
|
// 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.
|
// any per-type migration runs, so already-saved scenes load cleanly.
|
||||||
const { nodes: healed } = healSceneNodes(nodes)
|
const { nodes: healed } = healSceneNodes(nodes)
|
||||||
const patchedNodes = { ...healed } as Record<string, any>
|
const patchedNodes = { ...healed } as Record<string, any>
|
||||||
|
removeRetiredDrawingSheets(patchedNodes)
|
||||||
|
|
||||||
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
||||||
// merged into the scene material map by the caller (`setScene`).
|
// merged into the scene material map by the caller (`setScene`).
|
||||||
@@ -728,7 +765,10 @@ function migrateNodes(nodes: Record<string, any>): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'wall') {
|
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
|
// Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
|
||||||
@@ -1594,76 +1634,345 @@ export type ScenePatch = {
|
|||||||
nodeUpdates: SceneNodePatch[]
|
nodeUpdates: SceneNodePatch[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyScenePatch(changes: ScenePatch): boolean {
|
export type SceneNodeStructuralPatch = {
|
||||||
const beforeState = useScene.getState()
|
node: AnyNode
|
||||||
const hasInvalidNodeTarget = changes.nodeUpdates.some(({ id, data, removeFields }) => {
|
position: number
|
||||||
const node = beforeState.nodes[id]
|
}
|
||||||
if (!node) return true
|
|
||||||
if ('id' in data && data.id !== node.id) return true
|
export type SceneOperationPatch = ScenePatch & {
|
||||||
if ('type' in data && data.type !== node.type) return true
|
nodeCreates: SceneNodeStructuralPatch[]
|
||||||
if ('object' in data && data.object !== node.object) return true
|
nodeDeletes: SceneNodeStructuralPatch[]
|
||||||
if (removeFields.some((field) => field === 'id' || field === 'object' || field === 'type')) {
|
}
|
||||||
return true
|
|
||||||
|
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))
|
}
|
||||||
})
|
for (const { node } of changes.nodeCreates) addNodeAndParent(node)
|
||||||
const hasInvalidMaterialTarget = changes.materialChanges.some(
|
for (const { node } of changes.nodeDeletes) addNodeAndParent(node)
|
||||||
({ id, material }) => material !== null && material.id !== id,
|
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 (
|
if (
|
||||||
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
|
changes.nodeUpdates.length === 0 &&
|
||||||
hasInvalidNodeTarget ||
|
changes.materialChanges.length === 0 &&
|
||||||
hasInvalidMaterialTarget
|
changes.nodeCreates.length === 0 &&
|
||||||
|
changes.nodeDeletes.length === 0
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false
|
||||||
const temporalState = useScene.temporal.getState()
|
const next = sceneOperationPatchNextState(beforeState, changes)
|
||||||
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false
|
if (!next) return false
|
||||||
|
|
||||||
const before = sceneHistorySnapshotFromState(beforeState)
|
const before = sceneHistorySnapshotFromState(beforeState)
|
||||||
pauseSceneHistory(useScene)
|
const shouldScopeHistoryPause =
|
||||||
|
useScene.temporal.getState().isTracking || getSceneHistoryPauseDepth() > 0
|
||||||
|
if (shouldScopeHistoryPause) pauseSceneHistory(useScene)
|
||||||
try {
|
try {
|
||||||
// Host-owned fields bypass the UI lock without running local mutation cascades.
|
useScene.setState(next)
|
||||||
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 }
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
resumeSceneHistory(useScene)
|
if (shouldScopeHistoryPause) resumeSceneHistory(useScene)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentState = useScene.getState()
|
const currentState = useScene.getState()
|
||||||
const current = sceneHistorySnapshotFromState(currentState)
|
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)
|
useLiveNodeOverrides.getState().clear(id)
|
||||||
useLiveTransforms.getState().clear(id)
|
useLiveTransforms.getState().clear(id)
|
||||||
}
|
}
|
||||||
if (areSceneSnapshotsEqual(before, current)) return false
|
if (areSceneSnapshotsEqual(before, current)) return false
|
||||||
|
|
||||||
for (const { id } of changes.nodeUpdates) {
|
for (const id of touchedNodeIds) {
|
||||||
currentState.markDirty(id)
|
if (current.nodes[id]) currentState.markDirty(id)
|
||||||
|
else currentState.clearDirty(id)
|
||||||
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
|
const beforeParentId = before.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||||
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
|
const currentParentId = current.nodes[id]?.parentId as AnyNodeId | null | undefined
|
||||||
if (beforeParentId) currentState.markDirty(beforeParentId)
|
if (beforeParentId) currentState.markDirty(beforeParentId)
|
||||||
if (currentParentId) currentState.markDirty(currentParentId)
|
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) {
|
if (changes.materialChanges.length > 0) {
|
||||||
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
|
const materialRefs = new Set(changes.materialChanges.map(({ id }) => toSceneMaterialRef(id)))
|
||||||
for (const node of Object.values(current.nodes)) {
|
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)
|
if (node.parentId) currentState.markDirty(node.parentId as AnyNodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const { node } of changes.nodeDeletes) currentState.clearDirty(node.id)
|
||||||
|
|
||||||
notifySceneCommit({
|
notifySceneCommit({
|
||||||
origin: 'host',
|
origin: 'host',
|
||||||
@@ -1682,6 +1992,14 @@ export function applyScenePatch(changes: ScenePatch): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyScenePatch(changes: ScenePatch): boolean {
|
||||||
|
return applySceneOperationPatch({
|
||||||
|
...changes,
|
||||||
|
nodeCreates: [],
|
||||||
|
nodeDeletes: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type ApplySceneSnapshotOptions = {
|
export type ApplySceneSnapshotOptions = {
|
||||||
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
|
origin: Extract<SceneCommitOrigin, 'load' | 'host'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
|
|||||||
visible: true,
|
visible: true,
|
||||||
parentId: 'level_test',
|
parentId: 'level_test',
|
||||||
children: [],
|
children: [],
|
||||||
assemblyLayers: [],
|
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
thickness: 0.1,
|
thickness: 0.1,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('supportSlabId remap', () => {
|
||||||
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
|
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
|
||||||
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
|
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import type { AnyNode, AnyNodeId } from '../schema'
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
import { generateId } from '../schema/base'
|
import { generateId } from '../schema/base'
|
||||||
import type { Collection, CollectionId } from '../schema/collections'
|
import type { Collection, CollectionId } from '../schema/collections'
|
||||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
|
||||||
|
|
||||||
export type SceneGraph = {
|
export type SceneGraph = {
|
||||||
nodes: Record<AnyNodeId, AnyNode>
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
@@ -114,10 +113,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
if (clonedNode.type === 'construction-dimension') {
|
if (clonedNode.type === 'construction-dimension') {
|
||||||
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
|
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
|
||||||
}
|
}
|
||||||
if (clonedNode.type === 'drawing-sheet') {
|
|
||||||
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
clonedNodes[newId] = clonedNode
|
clonedNodes[newId] = clonedNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,10 +282,6 @@ export function cloneLevelSubtree(
|
|||||||
if (cloned.type === 'construction-dimension') {
|
if (cloned.type === 'construction-dimension') {
|
||||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||||
}
|
}
|
||||||
if (cloned.type === 'drawing-sheet') {
|
|
||||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
clonedNodes.push(cloned)
|
clonedNodes.push(cloned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ describe('healSceneNodes', () => {
|
|||||||
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
|
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', () => {
|
test('drops childless zero-length walls and removes their parent reference', () => {
|
||||||
const { nodes, droppedWallIds } = healSceneNodes({
|
const { nodes, droppedWallIds } = healSceneNodes({
|
||||||
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface HealSceneResult {
|
|||||||
nodes: Record<string, unknown>
|
nodes: Record<string, unknown>
|
||||||
/** Ids of zero-length walls that were dropped. */
|
/** Ids of zero-length walls that were dropped. */
|
||||||
droppedWallIds: string[]
|
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
|
strippedChildRefs: number
|
||||||
/**
|
/**
|
||||||
* Count of child references removed because the child's `parentId` points at
|
* 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 strippedChildRefs = 0
|
||||||
let strippedStaleChildRefs = 0
|
let strippedStaleChildRefs = 0
|
||||||
|
|
||||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]`
|
// Pass 2: clean `children` arrays — drop invalid non-string entries (the
|
||||||
// bug), references to walls we just removed, same-array duplicates, and
|
// `[null]` bug), references to walls we just removed, same-array duplicates,
|
||||||
// stale references whose child's `parentId` names a different parent.
|
// 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> = {}
|
const nodes: Record<string, unknown> = {}
|
||||||
for (const [id, node] of Object.entries(kept)) {
|
for (const [id, node] of Object.entries(kept)) {
|
||||||
const children = (node as { children?: unknown })?.children
|
const children = (node as { children?: unknown })?.children
|
||||||
if (Array.isArray(children)) {
|
if (Array.isArray(children)) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const cleaned = children.filter((c): c is string => {
|
const cleaned = children.filter((child) => {
|
||||||
if (typeof c !== 'string' || dropped.has(c)) {
|
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++
|
strippedChildRefs++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (seen.has(c)) {
|
if (seen.has(child)) {
|
||||||
strippedStaleChildRefs++
|
strippedStaleChildRefs++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
seen.add(c)
|
seen.add(child)
|
||||||
const child = kept[c] as { parentId?: unknown } | undefined
|
const childNode = kept[child] as { parentId?: unknown } | undefined
|
||||||
if (child && typeof child.parentId === 'string' && child.parentId !== id) {
|
if (childNode && typeof childNode.parentId === 'string' && childNode.parentId !== id) {
|
||||||
strippedStaleChildRefs++
|
strippedStaleChildRefs++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
"types": ["bun"]
|
"types": ["bun"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
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 useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
|
||||||
|
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
||||||
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
||||||
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
||||||
|
import {
|
||||||
type SvgPoint = {
|
type FloorplanCursorPoint,
|
||||||
x: number
|
projectFloorplanCursorPoint,
|
||||||
y: number
|
resolveFloorplanCursorIndicatorPosition,
|
||||||
}
|
} from './floorplan-cursor-indicator-position'
|
||||||
|
|
||||||
type FloorplanCursorIndicator =
|
type FloorplanCursorIndicator =
|
||||||
| {
|
| {
|
||||||
@@ -22,7 +23,7 @@ type FloorplanCursorIndicator =
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FloorplanCursorIndicatorOverlayProps = {
|
type FloorplanCursorIndicatorOverlayProps = {
|
||||||
cursorPosition: SvgPoint | null
|
cursorPosition: FloorplanCursorPoint | null
|
||||||
floorplanSelectionTool: FloorplanSelectionTool
|
floorplanSelectionTool: FloorplanSelectionTool
|
||||||
movingOpeningType: 'door' | 'window' | null
|
movingOpeningType: 'door' | 'window' | null
|
||||||
isPanning: boolean
|
isPanning: boolean
|
||||||
@@ -46,6 +47,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
|||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const structureLayer = useEditor((state) => state.structureLayer)
|
const structureLayer = useEditor((state) => state.structureLayer)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
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(() => {
|
const activeFloorplanToolConfig = useMemo(() => {
|
||||||
if (movingOpeningType) {
|
if (movingOpeningType) {
|
||||||
@@ -83,7 +88,32 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
|||||||
return null
|
return null
|
||||||
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
}, [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) {
|
if (!(indicator && position) || isPanning) {
|
||||||
return null
|
return null
|
||||||
@@ -93,6 +123,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
|||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute z-20"
|
className="pointer-events-none absolute z-20"
|
||||||
|
ref={anchorRef}
|
||||||
style={{ left: position.x, top: position.y }}
|
style={{ left: position.x, top: position.y }}
|
||||||
>
|
>
|
||||||
{mode === 'delete' ? (
|
{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,
|
bboxCornerAnchors,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
|
type FloorplanGeometry,
|
||||||
type FloorplanPalette,
|
type FloorplanPalette,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
pauseSpaceDetection,
|
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 { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
|
||||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||||
import { isHistoryShortcut } from '../../lib/history'
|
import { isHistoryShortcut } from '../../lib/history'
|
||||||
|
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
isAlignmentGuideActive,
|
isAlignmentGuideActive,
|
||||||
|
isAngleSnapActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
} from '../../store/use-editor'
|
} from '../../store/use-editor'
|
||||||
|
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||||
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
||||||
import {
|
import {
|
||||||
classifyParticipant,
|
classifyParticipant,
|
||||||
@@ -44,6 +48,8 @@ import {
|
|||||||
} from '../editor/group-transform-shared'
|
} from '../editor/group-transform-shared'
|
||||||
import { swallowNextClick } from '../editor/handles/use-handle-drag'
|
import { swallowNextClick } from '../editor/handles/use-handle-drag'
|
||||||
import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
|
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
|
// 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
|
// 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
|
let delta = angleOf([plan[0], plan[1]]) - initialAngle
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||||
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
|
if (isAngleSnapActive()) {
|
||||||
// as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
|
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||||
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
}
|
||||||
|
|
||||||
const entries = rotateGroupPatches(starts, links, pivot, delta)
|
const entries = rotateGroupPatches(starts, links, pivot, delta)
|
||||||
const patchById = new Map(entries)
|
const patchById = new Map(entries)
|
||||||
@@ -586,11 +592,15 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
|||||||
}) {
|
}) {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
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 nodes = useScene((s) => s.nodes)
|
||||||
const delta = useFloorplanGroupDrag((s) => s.delta)
|
const delta = useFloorplanGroupDrag((s) => s.delta)
|
||||||
const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
|
const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
|
||||||
const movingNode = useMovingNode()
|
const movingNode = useMovingNode()
|
||||||
const mode = useEditor((s) => s.mode)
|
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
|
// While a selection modifier is held the box steps aside so clicks reach
|
||||||
// the entries underneath (toggle membership) instead of starting a drag.
|
// 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 pad = 6 * unitsPerPixel
|
||||||
const stroke = palette?.selectedStroke ?? '#3b82f6'
|
const stroke = palette?.selectedStroke ?? '#3b82f6'
|
||||||
const interactive = !modifierHeld && !!onPointerDown
|
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
|
// 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
|
// 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.
|
// 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}
|
x={box.x - pad}
|
||||||
y={box.z - 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:
|
{/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
|
||||||
drag a corner to spin the group (15° steps, Shift = free). */}
|
drag a corner to spin the group (15° steps, Shift = free). */}
|
||||||
{interactive && onRotatePointerDown
|
{interactive && onRotatePointerDown
|
||||||
|
|||||||
@@ -7,13 +7,23 @@ import {
|
|||||||
type FloorplanToolContext,
|
type FloorplanToolContext,
|
||||||
getFloorplanNodeExtension,
|
getFloorplanNodeExtension,
|
||||||
} from '../../lib/floorplan/floorplan-extension'
|
} from '../../lib/floorplan/floorplan-extension'
|
||||||
|
import {
|
||||||
|
type FloorplanMode,
|
||||||
|
isFloorplanToolAvailableInMode,
|
||||||
|
} from '../../lib/floorplan/floorplan-mode'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||||
|
|
||||||
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
|
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
|
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
|
if (!loader) return null
|
||||||
const cached = lazyToolCache.get(loader)
|
const cached = lazyToolCache.get(loader)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -25,6 +35,7 @@ function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanTo
|
|||||||
export function FloorplanRegisteredToolLayer() {
|
export function FloorplanRegisteredToolLayer() {
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
|
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||||
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||||
const toolDefaults = useEditor((state) =>
|
const toolDefaults = useEditor((state) =>
|
||||||
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
|
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
|
||||||
@@ -43,7 +54,7 @@ export function FloorplanRegisteredToolLayer() {
|
|||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
}, [])
|
}, [])
|
||||||
if (mode !== 'build') return null
|
if (mode !== 'build') return null
|
||||||
const Tool = registeredFloorplanTool(tool)
|
const Tool = registeredFloorplanTool(tool, floorplanMode)
|
||||||
return Tool ? (
|
return Tool ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Tool
|
<Tool
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extensi
|
|||||||
import {
|
import {
|
||||||
createFreshPlacementSubtree,
|
createFreshPlacementSubtree,
|
||||||
duplicatesAsFreshSubtree,
|
duplicatesAsFreshSubtree,
|
||||||
|
prepareFreshPlacementRootDuplicate,
|
||||||
} from '../../lib/fresh-planar-placement'
|
} from '../../lib/fresh-planar-placement'
|
||||||
import { curveReshapeScope } from '../../lib/interaction/scope'
|
import { curveReshapeScope } from '../../lib/interaction/scope'
|
||||||
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||||
@@ -116,8 +117,8 @@ function collectQuickActionNodes(
|
|||||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||||
* - Duplicate: deep-clones the node, marks it new, sets it as the
|
* - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
|
||||||
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
|
* root-only copy, then hands that real draft to the placement cursor.
|
||||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||||
* `relations.cascadeDelete` if declared on the def.
|
* `relations.cascadeDelete` if declared on the def.
|
||||||
*
|
*
|
||||||
@@ -320,34 +321,30 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
if (!node.parentId) return
|
if (!node.parentId) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
let draftId: AnyNodeId | null = null
|
||||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
try {
|
||||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||||
if (draft) {
|
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||||
|
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||||
|
if (!draft) return
|
||||||
setMovingNode(draft as never)
|
setMovingNode(draft as never)
|
||||||
setMovingNodeOrigin('2d')
|
} else {
|
||||||
useScene.temporal.getState().resume()
|
const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
|
||||||
return
|
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()
|
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 = () => {
|
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'
|
'use client'
|
||||||
|
|
||||||
import type { FloorplanPalette } from '@pascal-app/core'
|
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`
|
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
||||||
@@ -34,7 +42,43 @@ export type FloorplanRenderContextValue = {
|
|||||||
sceneRotationDeg: number
|
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({
|
export function FloorplanRenderProvider({
|
||||||
children,
|
children,
|
||||||
@@ -42,12 +86,39 @@ export function FloorplanRenderProvider({
|
|||||||
palette,
|
palette,
|
||||||
hatchPatternId,
|
hatchPatternId,
|
||||||
sceneRotationDeg,
|
sceneRotationDeg,
|
||||||
}: FloorplanRenderContextValue & { children: ReactNode }) {
|
getSceneRotationDeg,
|
||||||
const value = useMemo<FloorplanRenderContextValue>(
|
}: FloorplanRenderContextValue & {
|
||||||
() => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
|
children: ReactNode
|
||||||
[unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
|
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.
|
* whole legacy panel along.
|
||||||
*/
|
*/
|
||||||
export function useFloorplanRender(): FloorplanRenderContextValue | null {
|
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 { describe, expect, test } from 'bun:test'
|
||||||
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||||
import {
|
import {
|
||||||
collectAnnotationLayoutPreflightIssues,
|
|
||||||
floorplanAnnotationObstacleMode,
|
floorplanAnnotationObstacleMode,
|
||||||
observeSvgAnnotationLayoutChanges,
|
|
||||||
polylineObstacleRectangles,
|
polylineObstacleRectangles,
|
||||||
resolveAnnotationLabelRectangles,
|
resolveAnnotationLabelRectangles,
|
||||||
|
resolveSvgAnnotationCollisions,
|
||||||
} from './floorplan-annotation-layout'
|
} from './floorplan-annotation-layout'
|
||||||
|
|
||||||
describe('floorplanAnnotationObstacleMode', () => {
|
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', () => {
|
describe('resolveAnnotationLabelRectangles', () => {
|
||||||
test('keeps the higher-priority label and moves the conflicting label', () => {
|
test('keeps the higher-priority label and moves the conflicting label', () => {
|
||||||
const shifts = resolveAnnotationLabelRectangles([
|
const shifts = resolveAnnotationLabelRectangles([
|
||||||
@@ -333,135 +272,14 @@ describe('resolveAnnotationLabelRectangles', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('observeSvgAnnotationLayoutChanges', () => {
|
describe('resolveSvgAnnotationCollisions', () => {
|
||||||
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
|
test('uses captured label references instead of querying for them again', () => {
|
||||||
const OriginalMutationObserver = globalThis.MutationObserver
|
const svg = {
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
querySelectorAll: () => {
|
||||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
|
throw new Error('labels were rediscovered')
|
||||||
let notify: MutationCallback | undefined
|
},
|
||||||
let animationFrames: FrameRequestCallback[] = []
|
} as unknown as SVGSVGElement
|
||||||
let disconnected = false
|
|
||||||
let observedOptions: MutationObserverInit | undefined
|
|
||||||
|
|
||||||
class FakeMutationObserver {
|
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toBeUndefined()
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export type AnnotationLabelRectangle = {
|
|||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
priority: number
|
priority: number
|
||||||
text?: string
|
|
||||||
labelPlacement?: 'inside' | 'outside-end'
|
|
||||||
pinnedShift?: { dx: number; dy: number }
|
pinnedShift?: { dx: number; dy: number }
|
||||||
tangentX?: number
|
tangentX?: number
|
||||||
tangentY?: number
|
tangentY?: number
|
||||||
@@ -79,16 +77,6 @@ class AnnotationObstacleIndex {
|
|||||||
|
|
||||||
export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
|
export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
|
||||||
export type AnnotationLayoutOverrides = Readonly<Record<string, AnnotationLayoutOverride>>
|
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(
|
export function resolveAnnotationLabelRectangles(
|
||||||
rectangles: readonly AnnotationLabelRectangle[],
|
rectangles: readonly AnnotationLabelRectangle[],
|
||||||
@@ -127,15 +115,19 @@ export function resolveAnnotationLabelRectangles(
|
|||||||
|
|
||||||
export function resolveSvgAnnotationCollisions(
|
export function resolveSvgAnnotationCollisions(
|
||||||
svg: SVGSVGElement,
|
svg: SVGSVGElement,
|
||||||
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
|
options: {
|
||||||
): AnnotationPreflightIssue[] {
|
labels?: readonly SVGGElement[]
|
||||||
const labels = Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
|
layoutOverrides?: AnnotationLayoutOverrides
|
||||||
if (labels.length === 0) return []
|
} = {},
|
||||||
|
): void {
|
||||||
|
const labels =
|
||||||
|
options.labels ??
|
||||||
|
Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
|
||||||
|
if (labels.length === 0) return
|
||||||
|
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
|
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
|
||||||
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
|
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
|
||||||
label.removeAttribute('data-floorplan-layout-unresolved')
|
|
||||||
delete label.dataset.floorplanAnnotationLayoutDx
|
delete label.dataset.floorplanAnnotationLayoutDx
|
||||||
delete label.dataset.floorplanAnnotationLayoutDy
|
delete label.dataset.floorplanAnnotationLayoutDy
|
||||||
}
|
}
|
||||||
@@ -183,9 +175,6 @@ export function resolveSvgAnnotationCollisions(
|
|||||||
width: bounds.width,
|
width: bounds.width,
|
||||||
height: bounds.height,
|
height: bounds.height,
|
||||||
priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
|
priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
|
||||||
text: label.textContent?.trim() ?? '',
|
|
||||||
labelPlacement:
|
|
||||||
label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside',
|
|
||||||
pinnedShift,
|
pinnedShift,
|
||||||
tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
|
tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
|
||||||
tangentY: tangentLength > 1e-9 && matrix ? matrix.b / 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]'),
|
svg.querySelectorAll<SVGGraphicsElement>('[data-floorplan-annotation-obstacle]'),
|
||||||
).flatMap(svgAnnotationObstacleRectangles)
|
).flatMap(svgAnnotationObstacleRectangles)
|
||||||
const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
|
const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
|
||||||
const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles)
|
|
||||||
|
|
||||||
labels.forEach((label, index) => {
|
labels.forEach((label, index) => {
|
||||||
const rectangle = rectangles[index]
|
const rectangle = rectangles[index]
|
||||||
@@ -204,7 +192,6 @@ export function resolveSvgAnnotationCollisions(
|
|||||||
if (!shift || (shift.dx === 0 && shift.dy === 0)) {
|
if (!shift || (shift.dx === 0 && shift.dy === 0)) {
|
||||||
label.dataset.floorplanAnnotationLayoutDx = '0'
|
label.dataset.floorplanAnnotationLayoutDx = '0'
|
||||||
label.dataset.floorplanAnnotationLayoutDy = '0'
|
label.dataset.floorplanAnnotationLayoutDy = '0'
|
||||||
if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const matrix = label.getScreenCTM()
|
const matrix = label.getScreenCTM()
|
||||||
@@ -222,173 +209,7 @@ export function resolveSvgAnnotationCollisions(
|
|||||||
else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
|
else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
|
||||||
showDimensionLeader(label, matrix, shift.dx, shift.dy)
|
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 {
|
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
|
||||||
|
|||||||
@@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<g
|
<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-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||||
geometry.offsetDistance,
|
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-label-placement={layout.labelPlacement}
|
||||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||||
@@ -429,11 +434,16 @@ export function FloorplanDimensionStringRenderer({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<g
|
<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-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||||
geometry.offsetDistance,
|
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-label-placement={layout.labelPlacement}
|
||||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||||
|
|||||||
@@ -487,9 +487,15 @@ function renderNode(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
data-floorplan-annotation-angle-radians={g.angle}
|
||||||
data-floorplan-annotation-default-transform={labelTransform}
|
data-floorplan-annotation-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority="20"
|
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}
|
key={keyHint}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
transform={labelTransform}
|
transform={labelTransform}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
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', () => {
|
describe('resolveFloorplanLabelAngle', () => {
|
||||||
test('keeps segment labels readable while preserving their screen direction', () => {
|
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(0, 90, true)).toBe(-90)
|
||||||
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
|
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
|
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
||||||
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 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,
|
AnyNodeId,
|
||||||
FloorplanAffordanceSession,
|
FloorplanAffordanceSession,
|
||||||
FloorplanGeometry,
|
FloorplanGeometry,
|
||||||
|
FloorplanPalette,
|
||||||
LiveNodeOverrides,
|
LiveNodeOverrides,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||||
@@ -19,12 +20,83 @@ import {
|
|||||||
collectFloorplanDependencyNodes,
|
collectFloorplanDependencyNodes,
|
||||||
collectFloorplanLinkedLevelNodes,
|
collectFloorplanLinkedLevelNodes,
|
||||||
computeAffectedSiblingIds,
|
computeAffectedSiblingIds,
|
||||||
|
floorplanAffordanceReshapeScope,
|
||||||
floorplanHandleDoubleClickAffordance,
|
floorplanHandleDoubleClickAffordance,
|
||||||
InteractiveGeometry,
|
InteractiveGeometry,
|
||||||
|
isFloorplanOpeningPlacementState,
|
||||||
|
resolveFloorplanHandleUnitsPerPixel,
|
||||||
splitFloorplanOverlay,
|
splitFloorplanOverlay,
|
||||||
subscribeFloorplanAffordanceToolCancel,
|
subscribeFloorplanAffordanceToolCancel,
|
||||||
} from './floorplan-registry-layer'
|
} 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') {
|
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
||||||
return {
|
return {
|
||||||
id,
|
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', () => {
|
describe('floorplan vertex double-click routing', () => {
|
||||||
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -220,6 +321,62 @@ describe('floorplan vertex double-click routing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('floorplan annotation overlay 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', () => {
|
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
|
||||||
const noop = () => {}
|
const noop = () => {}
|
||||||
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
|
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,
|
type CameraPoseApplicationPlan,
|
||||||
normalizeCameraPose,
|
normalizeCameraPose,
|
||||||
planCameraPoseApplication,
|
planCameraPoseApplication,
|
||||||
|
publishInitialCameraPose,
|
||||||
|
releaseCameraPoseEventSuppression,
|
||||||
stepCameraPoseInterpolation,
|
stepCameraPoseInterpolation,
|
||||||
withCameraPoseDistance,
|
withCameraPoseDistance,
|
||||||
} from '../../lib/camera-pose'
|
} from '../../lib/camera-pose'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
|
import { publishCameraPose } from '../../store/camera-pose-store'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import {
|
import {
|
||||||
useActiveHandleDrag,
|
useActiveHandleDrag,
|
||||||
@@ -46,14 +49,9 @@ const tempSize = new Vector3()
|
|||||||
const tempTarget = new Vector3()
|
const tempTarget = new Vector3()
|
||||||
const transitionFreezePosition = new Vector3()
|
const transitionFreezePosition = new Vector3()
|
||||||
const transitionFreezeTarget = new Vector3()
|
const transitionFreezeTarget = new Vector3()
|
||||||
const syncTarget = new Vector3()
|
|
||||||
const syncSpherical = new Spherical()
|
|
||||||
const keyboardPanSpherical = new Spherical()
|
const keyboardPanSpherical = new Spherical()
|
||||||
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
||||||
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
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_VIEW_WIDTH_PER_SECOND = 0.65
|
||||||
const KEYBOARD_PAN_MIN_SPEED = 2
|
const KEYBOARD_PAN_MIN_SPEED = 2
|
||||||
const KEYBOARD_PAN_MAX_SPEED = 55
|
const KEYBOARD_PAN_MAX_SPEED = 55
|
||||||
@@ -63,14 +61,6 @@ type CameraPoseSnapshot = {
|
|||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
target: [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 CameraViewWidthUpdate =
|
||||||
| { type: 'distance'; distance: number; viewWidth: number }
|
| { type: 'distance'; distance: number; viewWidth: number }
|
||||||
| { type: 'zoom'; viewWidth: number; zoom: 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)
|
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) {
|
function clampFinite(value: number, min: number, max: number) {
|
||||||
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
||||||
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_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(
|
function getCameraDistanceForViewWidth(
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
viewWidth: number,
|
viewWidth: number,
|
||||||
@@ -397,7 +364,6 @@ export const CustomCameraControls = () => {
|
|||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
const cameraMode = useViewer((state) => state.cameraMode)
|
||||||
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
||||||
@@ -407,19 +373,8 @@ export const CustomCameraControls = () => {
|
|||||||
)
|
)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
const firstLoad = useRef(true)
|
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 =
|
const maxPolarAngle =
|
||||||
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
!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 camera = useThree((state) => state.camera)
|
||||||
const gl = useThree((state) => state.gl)
|
const gl = useThree((state) => state.gl)
|
||||||
@@ -514,12 +469,10 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
|
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
|
||||||
}, [
|
}, [
|
||||||
camera,
|
camera,
|
||||||
cancelPoseApplication,
|
cancelPoseApplication,
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
freezeActivePoseInterpolation,
|
freezeActivePoseInterpolation,
|
||||||
isFirstPersonMode,
|
isFirstPersonMode,
|
||||||
viewportSize,
|
viewportSize,
|
||||||
@@ -584,19 +537,11 @@ export const CustomCameraControls = () => {
|
|||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
if (firstLoad.current) {
|
if (firstLoad.current) {
|
||||||
firstLoad.current = false
|
firstLoad.current = false
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
}
|
}
|
||||||
controls.current.getTarget(currentTarget)
|
controls.current.getTarget(currentTarget)
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
||||||
}, [
|
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
currentLevelId,
|
|
||||||
isPreviewMode,
|
|
||||||
isFirstPersonMode,
|
|
||||||
isRestoringFirstPersonPose,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode || !controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
@@ -624,7 +569,6 @@ export const CustomCameraControls = () => {
|
|||||||
controls.current.getTarget(tempTarget)
|
controls.current.getTarget(tempTarget)
|
||||||
tempDelta.copy(tempCenter).sub(tempTarget)
|
tempDelta.copy(tempCenter).sub(tempTarget)
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
tempPosition.x + tempDelta.x,
|
tempPosition.x + tempDelta.x,
|
||||||
tempPosition.y + tempDelta.y,
|
tempPosition.y + tempDelta.y,
|
||||||
@@ -635,106 +579,9 @@ export const CustomCameraControls = () => {
|
|||||||
true,
|
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(() => {
|
const publishCurrentPose = useCallback(() => {
|
||||||
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
|
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
|
||||||
|
|
||||||
@@ -756,27 +603,17 @@ export const CustomCameraControls = () => {
|
|||||||
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
|
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
|
||||||
})
|
})
|
||||||
if (pose) {
|
if (pose) {
|
||||||
emitter.emit('camera-controls:pose', pose)
|
publishCameraPose(pose)
|
||||||
}
|
}
|
||||||
}, [camera, isFirstPersonMode, viewportSize])
|
}, [camera, isFirstPersonMode, viewportSize])
|
||||||
|
|
||||||
const handleCameraUpdate = useCallback(() => {
|
const handleCameraUpdate = useCallback(() => {
|
||||||
publishCurrentNavigationPose()
|
|
||||||
publishCurrentPose()
|
publishCurrentPose()
|
||||||
}, [publishCurrentNavigationPose, publishCurrentPose])
|
}, [publishCurrentPose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
publishInitialCameraPose(publishCurrentPose)
|
||||||
|
}, [publishCurrentPose])
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
lastPublishedNavigationSync.current = null
|
|
||||||
publishCurrentNavigationPose()
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
}
|
|
||||||
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (isFirstPersonMode || !controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
@@ -814,12 +651,12 @@ export const CustomCameraControls = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
if (activePoseInterpolation.current === activePose) {
|
if (activePoseInterpolation.current === activePose) {
|
||||||
activePoseInterpolation.current = null
|
activePoseInterpolation.current = null
|
||||||
suppressPoseEvents.current = false
|
releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (step.settled && activePoseInterpolation.current === activePose) {
|
if (step.settled && activePoseInterpolation.current === activePose) {
|
||||||
activePoseInterpolation.current = null
|
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)
|
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
||||||
if (vertical !== 0) control.forward(vertical * step, true)
|
if (vertical !== 0) control.forward(vertical * step, true)
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -992,7 +828,6 @@ export const CustomCameraControls = () => {
|
|||||||
) {
|
) {
|
||||||
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||||
if (changed) beginLocalCameraInteraction()
|
if (changed) beginLocalCameraInteraction()
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -1058,7 +893,6 @@ export const CustomCameraControls = () => {
|
|||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
||||||
|
|
||||||
panPointerId = event.pointerId
|
panPointerId = event.pointerId
|
||||||
@@ -1069,7 +903,6 @@ export const CustomCameraControls = () => {
|
|||||||
const onWheel = () => {
|
const onWheel = () => {
|
||||||
beginLocalCameraInteraction()
|
beginLocalCameraInteraction()
|
||||||
cameraDraggingLifecycle.scheduleEnd()
|
cameraDraggingLifecycle.scheduleEnd()
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerUp = (event: PointerEvent) => {
|
const onPointerUp = (event: PointerEvent) => {
|
||||||
@@ -1120,25 +953,18 @@ export const CustomCameraControls = () => {
|
|||||||
gl,
|
gl,
|
||||||
isPreviewMode,
|
isPreviewMode,
|
||||||
isFirstPersonMode,
|
isFirstPersonMode,
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
// `controlstart` fires only for user pointer interactions. Pointerdowns
|
||||||
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
// mapped to ACTION.NONE must not flag the camera as dragging because no
|
||||||
// fires only for user pointer interactions — not for programmatic
|
// rest/sleep event follows to clear the flag.
|
||||||
// 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.
|
|
||||||
const handleControlStart = useCallback(() => {
|
const handleControlStart = useCallback(() => {
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
beginLocalCameraInteraction({
|
beginLocalCameraInteraction({
|
||||||
dragging: controls.current
|
dragging: controls.current
|
||||||
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
|
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
|
||||||
: false,
|
: false,
|
||||||
})
|
})
|
||||||
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
|
}, [beginLocalCameraInteraction])
|
||||||
|
|
||||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||||
const previewTargetNodeId = isPreviewMode
|
const previewTargetNodeId = isPreviewMode
|
||||||
@@ -1345,7 +1171,6 @@ export const CustomCameraControls = () => {
|
|||||||
if (!node?.camera) return
|
if (!node?.camera) return
|
||||||
const { position, target } = node.camera
|
const { position, target } = node.camera
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
position[0],
|
position[0],
|
||||||
position[1],
|
position[1],
|
||||||
@@ -1366,7 +1191,6 @@ export const CustomCameraControls = () => {
|
|||||||
// Otherwise, go to top view (0°)
|
// Otherwise, go to top view (0°)
|
||||||
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotatePolarTo(targetAngle, true)
|
controls.current.rotatePolarTo(targetAngle, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,7 +1203,6 @@ export const CustomCameraControls = () => {
|
|||||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
const target = rounded - Math.PI / 2
|
const target = rounded - Math.PI / 2
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
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 rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
const target = rounded + Math.PI / 2
|
const target = rounded + Math.PI / 2
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
controls.current.rotateTo(target, currentPolar, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1404,7 +1226,6 @@ export const CustomCameraControls = () => {
|
|||||||
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
// Restore default framing pose when no bounds were computed.
|
// Restore default framing pose when no bounds were computed.
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1415,7 +1236,6 @@ export const CustomCameraControls = () => {
|
|||||||
const maxExtent = Math.max(w, d)
|
const maxExtent = Math.max(w, d)
|
||||||
const distance = Math.max(maxExtent * 1.4, 15)
|
const distance = Math.max(maxExtent * 1.4, 15)
|
||||||
const height = Math.max(maxExtent * 0.8, 10)
|
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)
|
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:orbit-ccw', handleOrbitCCW)
|
||||||
emitter.off('camera-controls:fit-scene', handleFitScene)
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
||||||
}
|
}
|
||||||
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
}, [focusNode, isPreviewMode, isFirstPersonMode])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
cameraDraggingLifecycle.begin()
|
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,
|
runAsSingleSceneHistoryStep,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
SpawnNode,
|
SpawnNode,
|
||||||
StairNode,
|
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
summarizeSystemFor,
|
summarizeSystemFor,
|
||||||
@@ -47,6 +46,7 @@ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
|||||||
import {
|
import {
|
||||||
createFreshPlacementSubtree,
|
createFreshPlacementSubtree,
|
||||||
duplicatesAsFreshSubtree,
|
duplicatesAsFreshSubtree,
|
||||||
|
prepareFreshPlacementRootDuplicate,
|
||||||
} from '../../lib/fresh-planar-placement'
|
} from '../../lib/fresh-planar-placement'
|
||||||
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
||||||
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
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 { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
@@ -530,20 +529,26 @@ export function FloatingActionMenu() {
|
|||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
let draftId: AnyNodeId | null = null
|
||||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
try {
|
||||||
if (draft) {
|
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||||
setMovingNode(draft as any)
|
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||||
setSelection({ selectedIds: [] })
|
if (draft) {
|
||||||
return
|
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()
|
useScene.temporal.getState().resume()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let duplicateInfo = structuredClone(node) as any
|
const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
|
||||||
delete duplicateInfo.id
|
|
||||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
||||||
|
|
||||||
let duplicate: AnyNode | null = null
|
let duplicate: AnyNode | null = null
|
||||||
try {
|
try {
|
||||||
@@ -566,11 +571,6 @@ export function FloatingActionMenu() {
|
|||||||
} else if (node.type === 'roof-segment') {
|
} else if (node.type === 'roof-segment') {
|
||||||
duplicateInfo.id = generateId('rseg')
|
duplicateInfo.id = generateId('rseg')
|
||||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
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') {
|
} else if (node.type === 'stair-segment') {
|
||||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'spawn') {
|
} else if (node.type === 'spawn') {
|
||||||
@@ -608,11 +608,7 @@ export function FloatingActionMenu() {
|
|||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (duplicate.type === 'fence') {
|
} else if (duplicate.type === 'fence') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (
|
} else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
|
||||||
duplicate.type === 'roof-segment' ||
|
|
||||||
duplicate.type === 'stair' ||
|
|
||||||
duplicate.type === 'stair-segment'
|
|
||||||
) {
|
|
||||||
// Add small offset to make it visible
|
// Add small offset to make it visible
|
||||||
if ('position' in duplicate) {
|
if ('position' in duplicate) {
|
||||||
duplicate.position = [
|
duplicate.position = [
|
||||||
@@ -621,13 +617,7 @@ export function FloatingActionMenu() {
|
|||||||
duplicate.position[2] + 1,
|
duplicate.position[2] + 1,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (node.type === 'stair' && duplicate.type === 'stair') {
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
||||||
} else {
|
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate children for stair nodes
|
|
||||||
} else if (
|
} else if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
duplicate.type === 'chimney' ||
|
duplicate.type === 'chimney' ||
|
||||||
@@ -696,12 +686,8 @@ export function FloatingActionMenu() {
|
|||||||
nodeRegistry.has(duplicate.type)
|
nodeRegistry.has(duplicate.type)
|
||||||
) {
|
) {
|
||||||
setMovingNode(duplicate as any)
|
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],
|
[node, setMovingNode, setSelection],
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const baseArgs = {
|
|||||||
modifierKeys: { meta: false, ctrl: false, shift: false },
|
modifierKeys: { meta: false, ctrl: false, shift: false },
|
||||||
planPoint: [0, 0] as [number, number],
|
planPoint: [0, 0] as [number, number],
|
||||||
structureLayer: 'elements',
|
structureLayer: 'elements',
|
||||||
toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
|
|
||||||
visibleZonePolygons: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('resolveFloorplanBackgroundSelection', () => {
|
describe('resolveFloorplanBackgroundSelection', () => {
|
||||||
@@ -55,4 +53,20 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|||||||
preserveSelection: true,
|
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'
|
'use client'
|
||||||
|
|
||||||
import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
||||||
import { isPointInsidePolygon } from '../../lib/floorplan'
|
|
||||||
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
|
|
||||||
type ModifierKeys = {
|
type ModifierKeys = {
|
||||||
@@ -10,13 +9,6 @@ type ModifierKeys = {
|
|||||||
shift: boolean
|
shift: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ZoneHitEntry = {
|
|
||||||
zone: {
|
|
||||||
id: ZoneNodeType['id']
|
|
||||||
}
|
|
||||||
polygon: Point2D[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResolveFloorplanBackgroundSelectionArgs = {
|
type ResolveFloorplanBackgroundSelectionArgs = {
|
||||||
canSelectElementFloorplanGeometry: boolean
|
canSelectElementFloorplanGeometry: boolean
|
||||||
canSelectFloorplanZones: boolean
|
canSelectFloorplanZones: boolean
|
||||||
@@ -26,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
|
|||||||
modifierKeys: ModifierKeys
|
modifierKeys: ModifierKeys
|
||||||
planPoint: WallPlanPoint
|
planPoint: WallPlanPoint
|
||||||
structureLayer: string
|
structureLayer: string
|
||||||
toPoint2D: (point: WallPlanPoint) => Point2D
|
|
||||||
visibleZonePolygons: ZoneHitEntry[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FloorplanBackgroundSelectionResult =
|
export type FloorplanBackgroundSelectionResult =
|
||||||
@@ -63,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
|
|||||||
modifierKeys,
|
modifierKeys,
|
||||||
planPoint,
|
planPoint,
|
||||||
structureLayer,
|
structureLayer,
|
||||||
toPoint2D,
|
|
||||||
visibleZonePolygons,
|
|
||||||
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
|
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
|
||||||
if (canSelectFloorplanZones) {
|
if (canSelectFloorplanZones) {
|
||||||
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
|
const zoneId = getFloorplanHitIdAtPoint(planPoint)
|
||||||
isPointInsidePolygon(toPoint2D(planPoint), polygon),
|
if (zoneId) {
|
||||||
)
|
|
||||||
if (zoneHit) {
|
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
kind: 'select-zone',
|
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 {
|
import {
|
||||||
canApplyFloorplanNavigationSync,
|
canApplyFloorplanNavigationSync,
|
||||||
canZoomFloorplanDuringNavigation,
|
canZoomFloorplanDuringNavigation,
|
||||||
|
createFloorplanNavigationSyncScheduler,
|
||||||
finalizeFloorplanNavigation,
|
finalizeFloorplanNavigation,
|
||||||
|
flushFloorplanRotationPresentationRestore,
|
||||||
|
getFloorplanRotationOverscanViewBox,
|
||||||
|
queueFloorplanRotationPresentationRestore,
|
||||||
resolveFloorplanPresentationViewBox,
|
resolveFloorplanPresentationViewBox,
|
||||||
|
setFloorplanCompassRotation,
|
||||||
} from './floorplan-navigation-presentation'
|
} from './floorplan-navigation-presentation'
|
||||||
|
|
||||||
describe('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', () => {
|
test('keeps the imperative viewBox authoritative during navigation', () => {
|
||||||
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
|
||||||
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
|
||||||
@@ -29,6 +60,42 @@ describe('floorplan navigation presentation', () => {
|
|||||||
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
|
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', () => {
|
test('commits every active navigation channel before teardown', () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const rotationState = { angle: 42 }
|
const rotationState = { angle: 42 }
|
||||||
@@ -44,4 +111,32 @@ describe('floorplan navigation presentation', () => {
|
|||||||
|
|
||||||
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
|
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
|
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(
|
export function resolveFloorplanPresentationViewBox(
|
||||||
reactViewBox: FloorplanPresentationViewBox,
|
reactViewBox: FloorplanPresentationViewBox,
|
||||||
imperativeViewBox: FloorplanPresentationViewBox | null,
|
imperativeViewBox: FloorplanPresentationViewBox | null,
|
||||||
@@ -21,6 +78,63 @@ export function canApplyFloorplanNavigationSync(interactionInProgress: boolean):
|
|||||||
return !interactionInProgress
|
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>({
|
export function finalizeFloorplanNavigation<RotationState>({
|
||||||
zoomPending,
|
zoomPending,
|
||||||
panActive,
|
panActive,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,13 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
bboxCornerAnchors,
|
bboxCornerAnchors,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
|
emitter,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
pauseSpaceDetection,
|
pauseSpaceDetection,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
resumeSpaceDetection,
|
resumeSpaceDetection,
|
||||||
|
type SceneMaterialId,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { Plane, Vector2, Vector3 } from 'three'
|
import { Plane, Vector2, Vector3 } from 'three'
|
||||||
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||||
import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
|
import {
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
copySelectedNodesToEditorClipboard,
|
||||||
|
duplicateNodesToLevel,
|
||||||
|
getEditorClipboardSnapshot,
|
||||||
|
pasteSystemEditorClipboardToLevel,
|
||||||
|
} from '../../lib/scene-clipboard'
|
||||||
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
|
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
isAlignmentGuideActive,
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
@@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean {
|
|||||||
* and drag them along with the copies.
|
* and drag them along with the copies.
|
||||||
*/
|
*/
|
||||||
export function startGroupPickUp(
|
export function startGroupPickUp(
|
||||||
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
const { selectedIds, levelId } = useViewer.getState().selection
|
const { selectedIds, levelId } = useViewer.getState().selection
|
||||||
const participantIds = groupParticipantIds()
|
const participantIds = groupParticipantIds()
|
||||||
@@ -202,16 +210,18 @@ export function startGroupPickUp(
|
|||||||
const applyMove = (e: PointerEvent) => {
|
const applyMove = (e: PointerEvent) => {
|
||||||
const plan = resolvePlanPoint(e)
|
const plan = resolvePlanPoint(e)
|
||||||
if (!plan) return
|
if (!plan) return
|
||||||
// Delta-relative to where tracking starts so the group never teleports
|
// Ordinary moves are delta-relative so the group never teleports. A
|
||||||
// to the cursor.
|
// pasted selection instead arrives centered under the cursor.
|
||||||
if (!startPlan) {
|
if (!opts.positionAtCursor && !startPlan) {
|
||||||
startPlan = plan
|
startPlan = plan
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const step = useEditor.getState().gridSnapStep
|
const step = useEditor.getState().gridSnapStep
|
||||||
const snap = isGridSnapActive() && step > 0
|
const snap = isGridSnapActive() && step > 0
|
||||||
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
||||||
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
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) {
|
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
||||||
const result = resolveAlignment({
|
const result = resolveAlignment({
|
||||||
@@ -361,6 +371,7 @@ export function startGroupPickUp(
|
|||||||
// Only a press over a tracked surface commits; a click on side panels or
|
// Only a press over a tracked surface commits; a click on side panels or
|
||||||
// the toolbar keeps the pick-up alive.
|
// the toolbar keeps the pick-up alive.
|
||||||
if (!resolvePlanPoint(e)) return
|
if (!resolvePlanPoint(e)) return
|
||||||
|
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
commitPointerId = e.pointerId
|
commitPointerId = e.pointerId
|
||||||
@@ -380,6 +391,17 @@ export function startGroupPickUp(
|
|||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
const key = e.key.toLowerCase()
|
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) {
|
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|||||||
return true
|
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,
|
* Delete every selected node — same semantics as the keyboard Delete arm,
|
||||||
* including the accidental-bulk-delete confirm.
|
* including the accidental-bulk-delete confirm.
|
||||||
@@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|||||||
export function deleteSelection(): boolean {
|
export function deleteSelection(): boolean {
|
||||||
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedIds.length === 0) return false
|
if (selectedIds.length === 0) return false
|
||||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
|
||||||
const confirmed = window.confirm(
|
const commitDelete = () => {
|
||||||
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
if (selectedIds.length === 1) {
|
||||||
)
|
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
||||||
if (!confirmed) return false
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
useScene.getState().deleteNodes(selectedIds)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
}
|
}
|
||||||
sfxEmitter.emit('sfx:structure-delete')
|
|
||||||
useScene.getState().deleteNodes(selectedIds)
|
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
useDeleteConfirmation.getState().requestConfirmation({
|
||||||
|
count: selectedIds.length,
|
||||||
|
onConfirm: commitDelete,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
commitDelete()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../lib/scene'
|
} from '../../lib/scene'
|
||||||
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
|
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||||
import { RoofEditSystem } from '../systems/roof/roof-edit-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 type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||||
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
|
import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
|
||||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
||||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||||
import { FloatingActionMenu } from './floating-action-menu'
|
import { FloatingActionMenu } from './floating-action-menu'
|
||||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||||
|
import { FloorplanModeCoordinator } from './floorplan-mode-coordinator'
|
||||||
import { FloorplanPanel } from './floorplan-panel'
|
import { FloorplanPanel } from './floorplan-panel'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { GroupFloatingActionMenu } from './group-floating-action-menu'
|
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. */}
|
2d / 3d / split alike) can anchor to this container's bottom-left. */}
|
||||||
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
||||||
<QuickMeasurementHud />
|
<QuickMeasurementHud />
|
||||||
|
<DeleteConfirmationDialog />
|
||||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||||
<div
|
<div
|
||||||
className="relative h-full flex-shrink-0"
|
className="relative h-full flex-shrink-0"
|
||||||
@@ -1174,9 +1178,11 @@ export default function Editor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useViewer.getState().setProjectId(projectId ?? null)
|
useViewer.getState().setProjectId(projectId ?? null)
|
||||||
|
useFloorplanMode.getState().setProjectId(projectId ?? null)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
useViewer.getState().setProjectId(null)
|
useViewer.getState().setProjectId(null)
|
||||||
|
useFloorplanMode.getState().setProjectId(null)
|
||||||
}
|
}
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
@@ -1388,6 +1394,7 @@ export default function Editor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FloorplanModeCoordinator />
|
||||||
{showLoader && (
|
{showLoader && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
<SceneLoader className="bg-background" />
|
<SceneLoader className="bg-background" />
|
||||||
@@ -1463,6 +1470,7 @@ export default function Editor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||||
|
<FloorplanModeCoordinator />
|
||||||
{showLoader && (
|
{showLoader && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
<SceneLoader className="bg-background" />
|
<SceneLoader className="bg-background" />
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from '../../lib/paint-scope'
|
} from '../../lib/paint-scope'
|
||||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||||
import {
|
import {
|
||||||
|
emitCanvasNodeSelection,
|
||||||
resolveCanvasSelectionNode,
|
resolveCanvasSelectionNode,
|
||||||
resolveNodeSelectionTarget,
|
resolveNodeSelectionTarget,
|
||||||
resolveSelectedIdsForNodeClick,
|
resolveSelectedIdsForNodeClick,
|
||||||
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
let owns = false
|
let owns = false
|
||||||
let prevKey = ' | |||||||