Compare commits

...
25 Commits
Author SHA1 Message Date
morgan 5dea46a451 Обновить README.md
CI / quality (push) Has been cancelled
2026-07-29 09:15:37 +03:00
karachkov_is c848a33f06 Docker fix 4
CI / quality (push) Has been cancelled
2026-07-28 13:32:49 +03:00
karachkov_is aec4aa2b5a Docker fix 3
CI / quality (push) Has been cancelled
2026-07-28 09:46:33 +03:00
karachkov_is e334a2371d Docker fix 2
CI / quality (push) Has been cancelled
2026-07-28 09:05:36 +03:00
karachkov_is 4e11b333bb Docker fix
CI / quality (push) Has been cancelled
2026-07-28 08:55:44 +03:00
karachkov_is c6a70579cc Docker fix + ignore
CI / quality (push) Has been cancelled
2026-07-28 08:46:34 +03:00
karachkov_is fb7aa22946 Docker fix
CI / quality (push) Has been cancelled
2026-07-28 08:39:52 +03:00
karachkov_is 0addef89a4 Docker added
CI / quality (push) Has been cancelled
2026-07-28 08:17:50 +03:00
ab76686b8b editor: improve floorplan modes and annotations (#549)
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled
* Add roof surface placement support for items

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

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

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

* fixed conflict

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

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

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

* fix(nodes): render crisp wall selection hatching

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

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

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

* refactor(nodes): colocate contextual dimension builders

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

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

* chore(editor): update react scan tooling

* refactor(floorplan): streamline construction documentation

* refactor(floorplan): remove wall assembly roadmap

* fix(floorplan): migrate retired scene data

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 18:57:59 -04:00
Wassim SAMADandGitHub daa1f3e99b feat: add material browse categories (#546) 2026-07-24 14:22:13 -04:00
7309fc8f1d editor: stabilize floorplan and camera interactions (#545)
* Add roof surface placement support for items

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

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

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

* fixed conflict

* fix(viewer): keep outlines during camera movement

* fix(editor): prevent floorplan clipping during rotation

* fix(editor): keep compass rotation in sync

Stream live headings in 2D and 3D, and defer restoring the compositor rotation preview until the committed floor-plan state is ready to paint so pointer release cannot snap back.

* fix(editor): stabilize floorplan and camera sync

* fix floorplan registry scale subscriptions

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 12:33:58 -04:00
Wassim SAMADandGitHub f0985df887 fix(core): preserve legacy site children during scene healing (#544) 2026-07-24 10:12:27 -04:00
Aymeric RabotandGitHub 99812cd4cb Improve editor placement and selection workflows (#543)
* feat: expose accepted canvas node selections

* feat(editor): replace bulk delete alert with dialog

* fix(editor): allow immediate selection after opening placement

* feat(spawn): preview model during placement

* feat(editor): add cross-project selection clipboard

* fix(editor): harden placement and clipboard previews

* fix(editor): preserve carried clipboard state

* fix(editor): replace active paste drafts safely
2026-07-24 15:35:10 +02:00
Aymeric RabotandGitHub b95d737eb6 Fix composite node duplication placement (#542)
Route composite duplication through the fresh-subtree placement lifecycle, preserve descendant geometry and IDs, clear stale selection, and guarantee failed drafts restore history tracking.
2026-07-24 14:17:05 +02:00
3b70deb837 editor: improve floorplan navigation performance (#541)
* Add roof surface placement support for items

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

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

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

* fixed conflict

* perf(editor): reduce floorplan annotation navigation work

* perf(editor): reduce floorplan camera update work

* perf(editor): isolate floorplan pose updates

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 08:00:03 -04:00
Aymeric RabotandGitHub 28ab27f5ec Merge pull request #539 from pascalorg/feat/realtime-collaboration-sfx
Harden remote scene commit convergence
2026-07-24 00:33:14 +02:00
Aymeric Rabot d0afde192e Merge remote-tracking branch 'origin/main' into feat/realtime-collaboration-sfx 2026-07-24 00:28:45 +02:00
7cfb88dcad viewer: snapshot capture pipeline + hero framing for thumbnails (#538)
* feat(viewer,editor): extract snapshot pipeline, add hero framing + BakeThumbnail

Move the offscreen SSGI capture pipeline out of ThumbnailGenerator into
viewer's snapshot-pipeline (adding the missing scene-referred GRADE, uniform-
driven), add hero-pose per-node-box corner framing helpers, and a BakeThumbnail
component so the community bake page can render a publish-quality hero shot
headlessly. Auto-save thumbnails now re-pose onto the same hero angle instead
of copying the user's mid-edit camera; user-driven captures keep the exact
viewport pose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): tune hero framing — 19° elevation, tighter fit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer,editor): ink edges in snapshot captures

Captures now run the viewport's AO -> ink -> grade order with the same
soft/strong edge derivation (edgeColorFor/edgeOpacityScaleFor), uniform-gated
so one cached pipeline serves all modes. Radius scales with render height so
supersampled captures keep the viewport's line weight. Preset/item
(transparent) captures stay ink-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): lower hero elevation to 13°, exact fit — match reference framing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): hero framing — site plate in frame, building-centered aim, facade-relative 45°

Three framing fixes from review: the intentionally-shaped site plate joins the
fit constraints (from scene data — the site Object3D carries the ±400m horizon
disc and can't be measured), the aim centers the building (plate+structure on
XZ, structure alone on Y) so outlying boxes take asymmetric margin instead of
shifting the subject, and the azimuth sits 45° to the dominant wall axis
(length-weighted fold-4 vector sum) so rotated plans still read as a proper
corner shot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:43:00 -04:00
Aymeric Rabot 8b8ef23a59 Merge remote-tracking branch 'origin/main' into feat/realtime-collaboration-sfx
# Conflicts:
#	packages/core/src/schema/nodes/level.test.ts
#	packages/core/src/schema/nodes/level.ts
#	packages/core/src/store/use-scene.ts
2026-07-23 17:43:41 +02:00
Aymeric Rabot 8715d481ba test(core): avoid duplicate compiled test runs 2026-07-23 17:38:47 +02:00
Aymeric Rabot c42b3f01c9 fix(core): preserve remote patch interaction state 2026-07-23 14:14:24 +02:00
Aymeric Rabot 4b27966f9a fix(core): preserve plugin create fields 2026-07-23 12:32:48 +02:00
Aymeric Rabot 08d1bbf017 fix(core): preserve level child id types 2026-07-23 12:22:03 +02:00
Aymeric Rabot 3126b0b430 fix(core): allow plugin nodes under levels 2026-07-23 12:16:02 +02:00
Aymeric Rabot 4be0e55836 fix(core): apply exact remote scene patches 2026-07-23 12:00:39 +02:00
197 changed files with 8304 additions and 9175 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.git
.next
.turbo
dist
*.log
.idea
.vscode
.DS_Store
*.md
+8
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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>
+5
View File
@@ -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>
+8
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+44
View File
@@ -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"]
+11
View File
@@ -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.
-2
View File
@@ -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">
+16 -3
View File
@@ -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
}
+42 -46
View File
@@ -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]
@@ -428,6 +399,30 @@ function DisplayMenu() {
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{viewMode !== '3d' ? ( {viewMode !== '3d' ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Layers3 className="h-4 w-4" />
<span>Floor plan mode</span>
<span className="ml-auto text-muted-foreground text-xs">
{floorplanMode === 'default' ? 'Default' : 'Expert'}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{FLOORPLAN_MODE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setFloorplanMode(option.id)}>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{floorplanMode === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
{floorplanMode === 'expert' ? (
<> <>
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger>
@@ -490,6 +485,8 @@ function DisplayMenu() {
</DropdownMenuSub> </DropdownMenuSub>
</> </>
) : null} ) : null}
</>
) : null}
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}> <DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" /> <Magnet className="h-4 w-4" />
<span>Magnetic snap</span> <span>Magnetic snap</span>
@@ -694,7 +691,6 @@ export function CommunityViewerToolbarLeft() {
<> <>
<CollapseSidebarButton /> <CollapseSidebarButton />
<ViewModeControl /> <ViewModeControl />
<DrawingTypeControl />
</> </>
) )
} }
+2 -2
View File
@@ -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"
} }
+13 -6
View File
@@ -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=="],
+12
View File
@@ -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
+1
View File
@@ -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": {
+1 -1
View File
@@ -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"
}, },
+6 -4
View File
@@ -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> &
+3 -1
View File
@@ -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'
+3
View File
@@ -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], {
-5
View File
@@ -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 = [
-31
View File
@@ -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,
+2 -5
View File
@@ -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)
+57 -56
View File
@@ -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),
/** /**
-210
View File
@@ -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,
})
})
})
-260
View File
@@ -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.
-2
View File
@@ -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'])
})
})
+369 -51
View File
@@ -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)
}
}
for (const { node } of changes.nodeCreates) addNodeAndParent(node)
for (const { node } of changes.nodeDeletes) addNodeAndParent(node)
return ids
}
function sceneOperationPatchHasLiveConflict(
beforeState: SceneState,
changes: SceneOperationPatch,
): boolean {
const overrides = useLiveNodeOverrides.getState()
const transforms = useLiveTransforms.getState()
for (const id of sceneOperationPatchLiveConflictIds(beforeState, changes)) {
if (overrides.get(id) || transforms.get(id)) return true
} }
return removeFields.some((field) => Object.hasOwn(data, field))
})
const hasInvalidMaterialTarget = changes.materialChanges.some(
({ id, material }) => material !== null && material.id !== id,
)
if (
(changes.nodeUpdates.length === 0 && changes.materialChanges.length === 0) ||
hasInvalidNodeTarget ||
hasInvalidMaterialTarget
) {
return false return false
} }
const temporalState = useScene.temporal.getState() function areScenePatchValuesEqual(left: unknown, right: unknown): boolean {
if (!temporalState.isTracking || getSceneHistoryPauseDepth() > 0) return false if (Object.is(left, right)) return true
if (typeof left !== typeof right || left === null || right === null) return false
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => areScenePatchValuesEqual(value, right[index]))
)
}
if (typeof left !== 'object' || typeof right !== 'object') return false
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord)
if (leftKeys.length !== Object.keys(rightRecord).length) return false
return leftKeys.every(
(key) =>
Object.hasOwn(rightRecord, key) &&
areScenePatchValuesEqual(leftRecord[key], rightRecord[key]),
)
}
function parseSceneOperationPatchNode(value: unknown): AnyNode | null {
const builtin = AnyNodeSchema.safeParse(value)
if (builtin.success) return builtin.data
if (!(value && typeof value === 'object' && !Array.isArray(value))) return null
const type = (value as { type?: unknown }).type
if (typeof type !== 'string') return null
const registered = nodeRegistry.get(type)?.schema.safeParse(value)
return registered?.success ? (registered.data as AnyNode) : null
}
function structuralSiblingIds(
nodes: Record<AnyNodeId, AnyNode>,
rootNodeIds: AnyNodeId[],
parentId: AnyNodeId | null,
): AnyNodeId[] | null {
if (!parentId) return rootNodeIds
const parent = nodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
return parent.children.every((id) => typeof id === 'string')
? (parent.children as AnyNodeId[])
: null
}
function insertSceneStructuralPlacements(
base: AnyNodeId[],
placements: readonly SceneNodeStructuralPatch[],
): AnyNodeId[] | null {
if (placements.length === 0) return base
const result = new Array<AnyNodeId | undefined>(base.length + placements.length)
for (const change of placements) {
if (
!Number.isSafeInteger(change.position) ||
change.position < 0 ||
change.position >= result.length ||
result[change.position] !== undefined
) {
return null
}
result[change.position] = change.node.id
}
let baseIndex = 0
for (let index = 0; index < result.length; index += 1) {
if (result[index] !== undefined) continue
result[index] = base[baseIndex]
baseIndex += 1
}
return result as AnyNodeId[]
}
function sceneOperationPatchNextState(
beforeState: SceneState,
changes: SceneOperationPatch,
): Pick<SceneState, 'materials' | 'nodes' | 'rootNodeIds'> | null {
const createIds = new Set<AnyNodeId>()
const deleteIds = new Set<AnyNodeId>()
const updateIds = new Set<AnyNodeId>()
const materialIds = new Set<SceneMaterialId>()
const parsedCreates: SceneNodeStructuralPatch[] = []
for (const change of changes.nodeCreates) {
const parsed = parseSceneOperationPatchNode(change.node)
if (
!parsed ||
parsed.id !== change.node.id ||
createIds.has(parsed.id) ||
Object.hasOwn(beforeState.nodes, parsed.id) ||
!Number.isSafeInteger(change.position) ||
change.position < 0
) {
return null
}
createIds.add(parsed.id)
parsedCreates.push({ node: change.node, position: change.position })
}
for (const change of changes.nodeDeletes) {
const id = change.node.id
const current = beforeState.nodes[id]
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
const siblings = structuralSiblingIds(beforeState.nodes, beforeState.rootNodeIds, parentId)
if (
!current ||
createIds.has(id) ||
deleteIds.has(id) ||
!Number.isSafeInteger(change.position) ||
change.position < 0 ||
siblings?.[change.position] !== id ||
!areScenePatchValuesEqual(current, change.node)
) {
return null
}
deleteIds.add(id)
}
for (const id of createIds) {
if (deleteIds.has(id)) return null
}
for (const node of Object.values(beforeState.nodes)) {
const parentId = (node.parentId as AnyNodeId | null | undefined) ?? null
if (parentId && deleteIds.has(parentId) && !deleteIds.has(node.id)) return null
}
const nextNodes = { ...beforeState.nodes }
let nextRootNodeIds =
deleteIds.size > 0
? beforeState.rootNodeIds.filter((id) => !deleteIds.has(id))
: beforeState.rootNodeIds
const changedParentIds = new Set<AnyNodeId>()
for (const change of changes.nodeDeletes) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
if (parentId && !deleteIds.has(parentId)) changedParentIds.add(parentId)
delete nextNodes[change.node.id]
}
for (const parentId of changedParentIds) {
const parent = nextNodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
nextNodes[parentId] = {
...parent,
children: (parent.children as AnyNodeId[]).filter((id) => !deleteIds.has(id)),
} as AnyNode
}
for (const change of parsedCreates) nextNodes[change.node.id] = change.node
const rootCreates: SceneNodeStructuralPatch[] = []
const existingParentCreates = new Map<AnyNodeId, SceneNodeStructuralPatch[]>()
for (const change of parsedCreates) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
if (!parentId) {
rootCreates.push(change)
continue
}
const parent = nextNodes[parentId]
if (!parent) return null
if (createIds.has(parentId)) {
if (
!('children' in parent) ||
!Array.isArray(parent.children) ||
parent.children[change.position] !== change.node.id
) {
return null
}
continue
}
const placements = existingParentCreates.get(parentId) ?? []
placements.push(change)
existingParentCreates.set(parentId, placements)
}
const insertedRoots = insertSceneStructuralPlacements(nextRootNodeIds, rootCreates)
if (!insertedRoots) return null
nextRootNodeIds = insertedRoots
for (const [parentId, placements] of existingParentCreates) {
const parent = nextNodes[parentId]
if (!(parent && 'children' in parent && Array.isArray(parent.children))) return null
const children = insertSceneStructuralPlacements(parent.children as AnyNodeId[], placements)
if (!children) return null
nextNodes[parentId] = { ...parent, children } as AnyNode
}
for (const change of parsedCreates) {
const parentId = (change.node.parentId as AnyNodeId | null | undefined) ?? null
const siblings = structuralSiblingIds(nextNodes, nextRootNodeIds, parentId)
if (siblings?.[change.position] !== change.node.id) return null
if (!('children' in change.node && Array.isArray(change.node.children))) continue
for (const childId of change.node.children as AnyNodeId[]) {
if (nextNodes[childId]?.parentId !== change.node.id) return null
}
}
for (const { id, data, removeFields } of changes.nodeUpdates) {
const node = nextNodes[id]
if (
!node ||
createIds.has(id) ||
deleteIds.has(id) ||
updateIds.has(id) ||
('id' in data && data.id !== node.id) ||
('type' in data && data.type !== node.type) ||
('object' in data && data.object !== node.object) ||
removeFields.some(
(field) =>
field === 'id' || field === 'object' || field === 'type' || Object.hasOwn(data, field),
)
) {
return null
}
updateIds.add(id)
const candidate = { ...node, ...data } as Record<string, unknown>
for (const field of removeFields) delete candidate[field]
const validated = parseSceneOperationPatchNode(candidate)
if (
!validated ||
validated.id !== id ||
validated.type !== node.type ||
validated.object !== node.object
) {
return null
}
nextNodes[id] = candidate as AnyNode
}
const materials =
changes.materialChanges.length > 0 ? { ...beforeState.materials } : beforeState.materials
for (const { id, material } of changes.materialChanges) {
if (
materialIds.has(id) ||
(material !== null && (material.id !== id || !SceneMaterial.safeParse(material).success))
) {
return null
}
materialIds.add(id)
if (material === null) delete materials[id]
else materials[id] = material
}
return { materials, nodes: nextNodes, rootNodeIds: nextRootNodeIds }
}
export function applySceneOperationPatch(changes: SceneOperationPatch): boolean {
const beforeState = useScene.getState()
if (
changes.nodeUpdates.length === 0 &&
changes.materialChanges.length === 0 &&
changes.nodeCreates.length === 0 &&
changes.nodeDeletes.length === 0
) {
return false
}
if (sceneOperationPatchHasLiveConflict(beforeState, changes)) return false
const next = sceneOperationPatchNextState(beforeState, changes)
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'] },
+30 -13
View File
@@ -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 =
strippedChildRefs++ (node as { type?: unknown }).type === 'site' &&
return false child &&
} typeof child === 'object' &&
if (seen.has(c)) { typeof (child as { id?: unknown }).id === 'string'
? (child as { id: string }).id
: null
if (embeddedSiteChildId) {
if (seen.has(embeddedSiteChildId)) {
strippedStaleChildRefs++ strippedStaleChildRefs++
return false return false
} }
seen.add(c) seen.add(embeddedSiteChildId)
const child = kept[c] as { parentId?: unknown } | undefined return true
if (child && typeof child.parentId === 'string' && child.parentId !== id) { }
if (typeof child !== 'string' || dropped.has(child)) {
strippedChildRefs++
return false
}
if (seen.has(child)) {
strippedStaleChildRefs++
return false
}
seen.add(child)
const childNode = kept[child] as { parentId?: unknown } | undefined
if (childNode && typeof childNode.parentId === 'string' && childNode.parentId !== id) {
strippedStaleChildRefs++ strippedStaleChildRefs++
return false return false
} }
+1 -1
View File
@@ -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,35 +321,31 @@ 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()
let draftId: AnyNodeId | null = null
try {
if (duplicatesAsFreshSubtree(node as AnyNode)) { if (duplicatesAsFreshSubtree(node as AnyNode)) {
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) { 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
}
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 const parsed = def.schema.parse(cloned) as AnyNode
draftId = parsed.id as AnyNodeId
useScene.getState().createNode(parsed, node.parentId as AnyNodeId) useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
setMovingNode(parsed as never) 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()
} }
}
const handleDelete = () => { const handleDelete = () => {
sfxEmitter.emit('sfx:item-delete') sfxEmitter.emit('sfx:item-delete')
@@ -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)
} }
@@ -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
try {
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) { if (draft) {
setMovingNode(draft as any) setMovingNode(draft as any)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
return 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') {
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} else {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) 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,13 +686,9 @@ 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') sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedIds) useScene.getState().deleteNodes(selectedIds)
useViewer.getState().setSelection({ selectedIds: [] }) useViewer.getState().setSelection({ selectedIds: [] })
}
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
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 = '' let prevKey = '\0'
const applyCursor = () => { const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState() const { selection, hoveredId } = useViewer.getState()
const selectedIds = selection.selectedIds const selectedIds = selection.selectedIds
@@ -1648,6 +1649,7 @@ export const SelectionManager = () => {
modifierKeysRef.current, modifierKeysRef.current,
selectedIdsBeforeRouting, selectedIdsBeforeRouting,
) )
emitCanvasNodeSelection(nodeToSelect)
let nextMaterialTargetHandled = false let nextMaterialTargetHandled = false
@@ -1,47 +1,25 @@
'use client' 'use client'
import { emitter, sceneRegistry } from '@pascal-app/core' import { emitter } from '@pascal-app/core'
import { import {
backdropGradient, computeHeroFraming,
deepSkyColor, createSnapshotPipeline,
GRID_LAYER, GRID_LAYER,
getSceneTheme, heroCameraPose,
horizonHazeColor, type SnapshotPipeline,
packNormalToRGB,
SSGI_PARAMS,
snapLevelsToTruePositions, snapLevelsToTruePositions,
unpackRGBToNormal, THUMBNAIL_HEIGHT,
THUMBNAIL_WIDTH,
temporarilyHideNodeTypes,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei' import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { UnsignedByteType } from 'three' import type { WebGPURenderer } from 'three/webgpu'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'
import {
convertToTexture,
diffuseColor,
float,
mix,
mrt,
normalView,
output,
pass,
sample,
screenUV,
smoothstep,
uniform,
vec4,
} from 'three/tsl'
import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
export interface SnapshotCameraData { export interface SnapshotCameraData {
position: [number, number, number] position: [number, number, number]
target: [number, number, number] | null target: [number, number, number] | null
@@ -64,20 +42,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const onThumbnailCaptureRef = useRef(onThumbnailCapture) const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null) const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
const pipelineRef = useRef<RenderPipeline | null>(null) const pipelineRef = useRef<SnapshotPipeline | null>(null)
const renderTargetRef = useRef<RenderTarget | null>(null)
// Backdrop compositing for scene snapshots (studio renders, project
// thumbnails): theme background + sky gradient, same world-ray math as the
// viewport backdrop in viewer's post-processing. Uniform-driven so the one
// cached pipeline serves both opaque and transparent (preset/item) captures.
const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff')))
const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff')))
const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff')))
const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff')))
const bgProjInvUniform = useRef(uniform(new THREE.Matrix4()))
const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4()))
const bgMixUniform = useRef(uniform(1))
useEffect(() => { useEffect(() => {
onThumbnailCaptureRef.current = onThumbnailCapture onThumbnailCaptureRef.current = onThumbnailCapture
@@ -93,116 +58,24 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
let mounted = true let mounted = true
const buildPipeline = async () => { const buildPipeline = async () => {
try { const pipeline = await createSnapshotPipeline({
if ((gl as any).init) await (gl as any).init() renderer: gl as unknown as WebGPURenderer,
if (!mounted) return scene,
camera: cam,
// pass() handles MRT internally for all material types, including custom
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered.
const scenePass = pass(scene, cam)
scenePass.setMRT(
mrt({
output,
diffuseColor,
normal: packNormalToRGB(normalView),
}),
)
const scenePassColor = scenePass.getTextureNode('output')
const scenePassDepth = scenePass.getTextureNode('depth')
const scenePassNormal = scenePass.getTextureNode('normal')
scenePass.getTexture('diffuseColor').type = UnsignedByteType
scenePass.getTexture('normal').type = UnsignedByteType
const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv)))
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
giPass.stepCount.value = SSGI_PARAMS.stepCount
giPass.radius.value = SSGI_PARAMS.radius
giPass.expFactor.value = SSGI_PARAMS.expFactor
giPass.thickness.value = SSGI_PARAMS.thickness
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
// r185: SSGI's AO lives in its own single-channel texture (getAONode)
// rather than the alpha of one packed rgba texture.
const aoTexture = (giPass as any).getAONode()
const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam)
denoisePass.index.value = 0
denoisePass.radius.value = 4
// Same far-field AO fade as the viewport pipeline — without it the
// horizon picks up a visible AO line in captures.
const aoFarFade = smoothstep(
float(0.9994),
float(0.9998),
scenePassDepth.sample(screenUV).r,
)
const ao = mix((denoisePass as any).r, float(1), aoFarFade)
const sceneRgb = scenePassColor.rgb.mul(ao)
// Per-pixel world ray from the capture camera → sky gradient above the
// horizon (dir.y = 0), flat background below — mirrors the viewport
// backdrop. bgMix 0 bypasses it and keeps the capture transparent.
const ndc = vec4(
screenUV.x.mul(2).sub(1),
float(1).sub(screenUV.y).mul(2).sub(1),
1,
1,
) as any
const viewRay = (bgProjInvUniform.current as any).mul(ndc)
const worldDir = (bgCamWorldUniform.current as any)
.mul(vec4(viewRay.xyz, 0))
.xyz.normalize()
const bgGradient = backdropGradient({
dirY: worldDir.y,
background: bgColorUniform.current,
haze: bgHazeUniform.current,
sky: bgSkyUniform.current,
skyDeep: bgSkyDeepUniform.current,
}) })
const alpha = scenePassColor.a if (!mounted) {
const finalOutput = vec4( pipeline?.dispose()
mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current), return
mix(alpha, float(1), bgMixUniform.current), }
)
// FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput
pipelineRef.current = pipeline pipelineRef.current = pipeline
// Dedicated render target — pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
} catch (error) {
console.error(
'[thumbnail] Failed to build post-processing pipeline, will use fallback render.',
error,
)
}
} }
buildPipeline() void buildPipeline()
return () => { return () => {
mounted = false mounted = false
pipelineRef.current?.dispose() pipelineRef.current?.dispose()
pipelineRef.current = null pipelineRef.current = null
renderTargetRef.current?.dispose()
renderTargetRef.current = null
} }
}, [gl, scene]) }, [gl, scene])
@@ -242,16 +115,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
// uniforms below. // uniforms below.
thumbnailCamera.updateMatrixWorld() thumbnailCamera.updateMatrixWorld()
const theme = getSceneTheme(useViewer.getState().sceneTheme) const pipeline = pipelineRef.current
bgColorUniform.current.value.set(theme.background) pipeline?.applyEnvironment({
bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) theme: useViewer.getState().sceneTheme,
bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background)) transparent,
bgHazeUniform.current.value.set( grade: useViewer.getState().shading === 'rendered',
horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance), // Preset/item captures stay clean; scene captures mirror the canvas.
) edges: transparent ? 'off' : useViewer.getState().edges,
bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) camera: thumbnailCamera,
bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) })
bgMixUniform.current.value = transparent ? 0 : 1
// Capture camera data for snapshot storage // Capture camera data for snapshot storage
const pos = mainCamera.position const pos = mainCamera.position
@@ -286,166 +158,66 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
// are registered. Spawn renders on SCENE_LAYER for occlusion, so the // are registered. Spawn renders on SCENE_LAYER for occlusion, so the
// thumbnail camera's layer mask can't filter it either. Returns a // thumbnail camera's layer mask can't filter it either. Returns a
// function that restores the original visibility. // function that restores the original visibility.
const restoreNodeVisibility = (() => { const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn'])
const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide', 'spawn'] as const) { // Auto-save shots don't copy the user's mid-edit camera — they re-pose
const ids = sceneRegistry.byType[type]! // onto the same computed hero angle the published thumbnail uses, so a
ids.forEach((id) => { // project's card never shows a half-zoomed working view. Measured after
const node = sceneRegistry.nodes.get(id) // the level snap so stacked positions frame correctly. User-driven
if (node) { // captures (captureMode set) keep the exact viewport pose.
saved.set(node, node.visible) if (snapLevels) {
node.visible = false const framing = computeHeroFraming()
} if (framing) {
const pose = heroCameraPose({
boxes: framing.boxes,
aim: framing.aim,
azimuthRad: framing.azimuthRad,
aspect: width / height,
}) })
} thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2])
return () => { thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2])
saved.forEach((wasVisible, node) => { thumbnailCamera.updateMatrixWorld()
node.visible = wasVisible pipeline?.applyEnvironment({
theme: useViewer.getState().sceneTheme,
transparent,
grade: useViewer.getState().shading === 'rendered',
edges: transparent ? 'off' : useViewer.getState().edges,
camera: thumbnailCamera,
}) })
cameraData.position = pose.position
cameraData.target = pose.target
}
} }
})()
let blob: Blob let blob: Blob
if (pipelineRef.current && renderTargetRef.current) { if (pipeline) {
const rt = renderTargetRef.current let capturePromise: ReturnType<SnapshotPipeline['capture']>
// Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height)
}
const renderer = gl as unknown as WebGPURenderer
// Notify other systems (wall cutouts, selection manager) to restore // Notify other systems (wall cutouts, selection manager) to restore
// their overrides before capture and re-apply them after. // their overrides before capture and re-apply them after.
try { try {
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
;(renderer as any).setClearAlpha(0) capturePromise = pipeline.capture({
renderer.setRenderTarget(rt) captureMode,
pipelineRef.current.render() cropRegion,
standardSize,
})
} finally { } finally {
// Restore level positions, levelMode, and node visibility immediately // Restore level positions, levelMode, and node visibility immediately
// after the render — before the async GPU readback. Runs in `finally` // after the render — before the async GPU readback. Runs in `finally`
// so a render failure can't leave helpers permanently hidden. // so a render failure can't leave helpers permanently hidden.
renderer.setRenderTarget(null)
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
restoreLevels() restoreLevels()
restoreLevelMode?.() restoreLevelMode?.()
restoreNodeVisibility() restoreNodeVisibility()
} }
// Read pixels from the RT asynchronously. const result = await capturePromise
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must blob = result.blob
// depad the rows before constructing ImageData.
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
rt,
0,
0,
width,
height,
)) as Uint8Array
const actualBytesPerRow = width * 4
const tightTotal = actualBytesPerRow * height
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
// Two readback shapes to handle:
// - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
// when width*4 isn't already a multiple of 256.
// - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
// (OpenGL framebuffer convention).
// `isWebGPURenderer` lies — it stays true even when the renderer
// falls back to the WebGL backend. Inspect the actual backend
// instead (presence of a GPU device, or backend constructor name).
const backend = (renderer as any).backend
const isWebGPU =
!!backend?.device ||
backend?.isWebGPUBackend === true ||
backend?.constructor?.name === 'WebGPUBackend'
let tightPixels: Uint8ClampedArray
if (isWebGPU) {
// WebGPU: depad rows if needed; orientation is already top-down.
if (paddedBytesPerRow === actualBytesPerRow) {
tightPixels = new Uint8ClampedArray(
pixels.buffer,
pixels.byteOffset,
Math.min(pixels.byteLength, tightTotal),
)
} else {
tightPixels = new Uint8ClampedArray(tightTotal)
for (let row = 0; row < height; row++) {
tightPixels.set(
pixels.subarray(
row * paddedBytesPerRow,
row * paddedBytesPerRow + actualBytesPerRow,
),
row * actualBytesPerRow,
)
}
}
} else {
// WebGL2: tight buffer in bottom-up order — flip rows.
tightPixels = new Uint8ClampedArray(tightTotal)
for (let row = 0; row < height; row++) {
const srcStart = (height - 1 - row) * actualBytesPerRow
tightPixels.set(
pixels.subarray(srcStart, srcStart + actualBytesPerRow),
row * actualBytesPerRow,
)
}
}
const imageData = new ImageData(
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
width,
height,
)
const srcCanvas = new OffscreenCanvas(width, height)
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
let outW: number
let outH: number
if (captureMode === 'viewport') {
outW = width
outH = height
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else if (captureMode === 'area' && cropRegion) {
const sx = Math.round(cropRegion.x * width)
const sy = Math.round(cropRegion.y * height)
outW = Math.round(cropRegion.width * width)
outH = Math.round(cropRegion.height * height)
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
// Standard: center-crop to the requested aspect (default 1920×1080)
const srcAspect = width / height
const dstAspect = standardW / standardH
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
outW = standardW
outH = standardH
const offscreen = new OffscreenCanvas(outW, outH)
offscreen
.getContext('2d')!
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
}
if (captureMode !== undefined) cameraData.captureMode = captureMode if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH } cameraData.resolution = { w: result.outW, h: result.outH }
} else { } else {
// Fallback: plain render directly to the canvas // Fallback: plain render directly to the canvas
try { try {
@@ -0,0 +1,82 @@
import { describe, expect, test } from 'bun:test'
import { collectRegistrySelectionIdsInBounds } from './use-floorplan-hit-testing'
const identityMatrix = {
inverse() {
return this
},
}
function pointFactory() {
return {
x: 0,
y: 0,
matrixTransform() {
return pointFactoryWithPosition(this.x, this.y)
},
}
}
function pointFactoryWithPosition(x: number, y: number) {
return {
x,
y,
matrixTransform() {
return pointFactoryWithPosition(x, y)
},
}
}
function geometry(x: number, y: number, width: number, height: number) {
return {
getBBox: () => ({ x, y, width, height }),
getScreenCTM: () => identityMatrix,
} as unknown as SVGGraphicsElement
}
function entry(nodeId: string, children: SVGGraphicsElement[]) {
return {
dataset: { nodeId },
querySelectorAll: () => children,
} as unknown as SVGGElement
}
function scene(entries: SVGGElement[]) {
return {
getScreenCTM: () => identityMatrix,
ownerSVGElement: {
createSVGPoint: pointFactory,
},
querySelectorAll: () => entries,
} as unknown as SVGGElement
}
describe('collectRegistrySelectionIdsInBounds', () => {
test('selects rendered registry geometry and preserves candidate order', () => {
const renderedScene = scene([
entry('wall_1', [geometry(0, 0, 4, 0.2)]),
entry('door_1', [geometry(1, 1, 0.9, 0.9)]),
entry('outside_1', [geometry(20, 20, 1, 1)]),
])
expect(
collectRegistrySelectionIdsInBounds(renderedScene, { minX: -1, minY: -1, maxX: 5, maxY: 5 }, [
'door_1',
'outside_1',
'wall_1',
]),
).toEqual(['door_1', 'wall_1'])
})
test('ignores rendered entries outside the selectable registry candidates', () => {
const renderedScene = scene([entry('zone_1', [geometry(0, 0, 2, 2)])])
expect(
collectRegistrySelectionIdsInBounds(
renderedScene,
{ minX: -1, minY: -1, maxX: 3, maxY: 3 },
[],
),
).toEqual([])
})
})
@@ -1,195 +1,178 @@
'use client' 'use client'
import type { import { type RefObject, useCallback } from 'react'
AnyNode,
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { useCallback } from 'react'
import {
getFloorplanHitNodeId,
getFloorplanSelectionIdsInBounds,
} from '../../lib/floorplan/selection-tool'
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types' import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
import {
type Point2 as MarqueePoint2,
polygonsIntersect,
segmentIntersectsPolygon,
} from '../tools/select/marquee-geometry'
import { collectSelectableCandidateIds } from '../tools/select/select-candidates'
import type { WallPlanPoint } from '../tools/wall/wall-drafting' import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type OpeningNode = WindowNode | DoorNode const REGISTRY_ENTRY_SELECTOR = '.floorplan-registry-base .floorplan-registry-entry[data-node-id]'
const REGISTRY_GEOMETRY_SELECTOR = 'path, polygon, polyline, rect, circle, line, image'
const POINT_HIT_EPSILON = 1e-4
type WallPolygonEntry = { type FloorplanHitTestingOptions = {
wall: WallNode sceneRef: RefObject<SVGGElement | null>
polygon: Point2D[]
} }
type OpeningPolygonEntry = { function boundsPolygon(bounds: FloorplanSelectionBounds): MarqueePoint2[] {
opening: OpeningNode return [
polygon: Point2D[] [bounds.minX, bounds.minY],
[bounds.maxX, bounds.minY],
[bounds.maxX, bounds.maxY],
[bounds.minX, bounds.maxY],
]
} }
type SlabPolygonEntry = { function transformElementBoxToScene(
slab: SlabNode element: SVGGraphicsElement,
polygon: Point2D[] scene: SVGGElement,
holes: Point2D[][] ): MarqueePoint2[] | null {
const elementMatrix = element.getScreenCTM()
const sceneMatrix = scene.getScreenCTM()
const svg = scene.ownerSVGElement
if (!(elementMatrix && sceneMatrix && svg)) {
return null
} }
type CeilingPolygonEntry = { let box: DOMRect
ceiling: CeilingNode try {
polygon: Point2D[] box = element.getBBox()
holes: Point2D[][] } catch {
return null
} }
type ColumnPolygonEntry = { const inverseSceneMatrix = sceneMatrix.inverse()
column: ColumnNode const corners: MarqueePoint2[] = [
polygon: Point2D[] [box.x, box.y],
[box.x + box.width, box.y],
[box.x + box.width, box.y + box.height],
[box.x, box.y + box.height],
]
return corners.map(([x, y]) => {
const point = svg.createSVGPoint()
point.x = x
point.y = y
const screenPoint = point.matrixTransform(elementMatrix)
const scenePoint = screenPoint.matrixTransform(inverseSceneMatrix)
return [scenePoint.x, scenePoint.y] as MarqueePoint2
})
} }
type ElevatorPolygonEntry = { function elementIntersectsPlanPolygon(
elevator: ElevatorNode element: SVGGraphicsElement,
polygon: Point2D[] scene: SVGGElement,
selectionPolygon: MarqueePoint2[],
): boolean {
const polygon = transformElementBoxToScene(element, scene)
if (!polygon) {
return false
} }
type FloorplanRoofEntry = { const [first, second, third] = polygon
roof: RoofNode if (!(first && second && third)) {
segments: Array<{ return false
polygon: Point2D[]
segment: RoofSegmentNode
}>
} }
type FloorplanItemEntry = { const width = Math.hypot(second[0] - first[0], second[1] - first[1])
item: ItemNode const height = Math.hypot(third[0] - second[0], third[1] - second[1])
polygon: Point2D[] if (width <= POINT_HIT_EPSILON || height <= POINT_HIT_EPSILON) {
const end = width >= height ? second : third
return segmentIntersectsPolygon(first, end, selectionPolygon)
} }
type FloorplanStairSegmentEntry = { return polygonsIntersect(polygon, selectionPolygon)
polygon: Point2D[]
segment: StairSegmentNode | AnyNode
} }
type FloorplanStairEntry = { export function collectRegistrySelectionIdsInBounds(
hitPolygons: Point2D[][] scene: SVGGElement,
stair: StairNode bounds: FloorplanSelectionBounds,
segments: FloorplanStairSegmentEntry[] candidateIds = collectSelectableCandidateIds(),
): string[] {
const candidateIdSet = new Set(candidateIds)
const selectionPolygon = boundsPolygon(bounds)
const hitIds = new Set<string>()
for (const entry of scene.querySelectorAll<SVGGElement>(REGISTRY_ENTRY_SELECTOR)) {
const nodeId = entry.dataset.nodeId
if (!nodeId || !candidateIdSet.has(nodeId)) {
continue
} }
type UseFloorplanHitTestingArgs = { const geometry = entry.querySelectorAll<SVGGraphicsElement>(REGISTRY_GEOMETRY_SELECTOR)
ceilingPolygons: CeilingPolygonEntry[] const elements = geometry.length > 0 ? Array.from(geometry) : [entry]
columnPolygons: ColumnPolygonEntry[] if (
displaySlabPolygons: SlabPolygonEntry[] elements.some((element) => elementIntersectsPlanPolygon(element, scene, selectionPolygon))
displayWallPolygons: WallPolygonEntry[] ) {
floorplanElevatorEntries: ElevatorPolygonEntry[] hitIds.add(nodeId)
floorplanItemEntries: FloorplanItemEntry[] }
getFloorplanOpeningHitTolerance: () => number
floorplanRoofEntries: FloorplanRoofEntry[]
floorplanStairEntries: FloorplanStairEntry[]
getFloorplanWallHitTolerance: () => number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
isFloorplanItemContextActive: boolean
openingsPolygons: OpeningPolygonEntry[]
phase: 'site' | 'structure' | 'furnish'
toPoint2D: (point: WallPlanPoint) => Point2D
} }
export function useFloorplanHitTesting({ return candidateIds.filter((id) => hitIds.has(id))
ceilingPolygons, }
columnPolygons,
displaySlabPolygons, export function getRegistryHitIdAtPlanPoint(
displayWallPolygons, scene: SVGGElement,
floorplanElevatorEntries, planPoint: WallPlanPoint,
floorplanItemEntries, candidateIds = collectSelectableCandidateIds(),
getFloorplanOpeningHitTolerance, ): string | null {
floorplanRoofEntries, const sceneMatrix = scene.getScreenCTM()
floorplanStairEntries, const svg = scene.ownerSVGElement
getFloorplanWallHitTolerance, if (!(sceneMatrix && svg)) {
getOpeningCenterLine, return null
isFloorplanItemContextActive, }
openingsPolygons,
phase, const point = svg.createSVGPoint()
toPoint2D, point.x = planPoint[0]
}: UseFloorplanHitTestingArgs) { point.y = planPoint[1]
const screenPoint = point.matrixTransform(sceneMatrix)
const candidateIdSet = new Set(candidateIds)
if (typeof document !== 'undefined' && typeof document.elementsFromPoint === 'function') {
for (const element of document.elementsFromPoint(screenPoint.x, screenPoint.y)) {
const entry = element.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
const nodeId = entry?.dataset.nodeId
if (entry && nodeId && candidateIdSet.has(nodeId) && scene.contains(entry)) {
return nodeId
}
}
}
const hits = collectRegistrySelectionIdsInBounds(
scene,
{
minX: planPoint[0] - POINT_HIT_EPSILON,
maxX: planPoint[0] + POINT_HIT_EPSILON,
minY: planPoint[1] - POINT_HIT_EPSILON,
maxY: planPoint[1] + POINT_HIT_EPSILON,
},
candidateIds,
)
return hits.at(-1) ?? null
}
export function useFloorplanHitTesting({ sceneRef }: FloorplanHitTestingOptions) {
const getFloorplanHitIdAtPoint = useCallback( const getFloorplanHitIdAtPoint = useCallback(
(planPoint: WallPlanPoint) => { (planPoint: WallPlanPoint) => {
const point = toPoint2D(planPoint) const scene = sceneRef.current
return getFloorplanHitNodeId({ return scene ? getRegistryHitIdAtPlanPoint(scene, planPoint) : null
point,
ceilings: ceilingPolygons,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
stairs: floorplanStairEntries,
elevators: floorplanElevatorEntries,
walls: displayWallPolygons,
slabs: displaySlabPolygons,
openingHitTolerance: getFloorplanOpeningHitTolerance(),
wallHitTolerance: getFloorplanWallHitTolerance(),
columns: columnPolygons,
getOpeningCenterLine,
})
}, },
[ [sceneRef],
ceilingPolygons,
columnPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanElevatorEntries,
floorplanRoofEntries,
floorplanStairEntries,
getFloorplanOpeningHitTolerance,
getFloorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
],
) )
const getFloorplanSelectionIdsInBoundsForArea = useCallback( const getFloorplanSelectionIdsInBounds = useCallback(
(bounds: FloorplanSelectionBounds) => (bounds: FloorplanSelectionBounds) => {
getFloorplanSelectionIdsInBounds({ const scene = sceneRef.current
bounds, return scene ? collectRegistrySelectionIdsInBounds(scene, bounds) : []
ceilings: ceilingPolygons, },
phase, [sceneRef],
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
walls: displayWallPolygons,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
slabs: displaySlabPolygons,
columns: columnPolygons,
elevators: floorplanElevatorEntries,
stairs: floorplanStairEntries,
}),
[
ceilingPolygons,
columnPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanElevatorEntries,
floorplanRoofEntries,
floorplanStairEntries,
isFloorplanItemContextActive,
openingsPolygons,
phase,
],
) )
return { return {
getFloorplanHitIdAtPoint, getFloorplanHitIdAtPoint,
getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea, getFloorplanSelectionIdsInBounds,
} }
} }
@@ -17,6 +17,8 @@ import {
useEditingHole, useEditingHole,
useEndpointReshape, useEndpointReshape,
useIsCurveReshape, useIsCurveReshape,
useIsFloorplanDrivenReshape,
useIsToolDrivenReshape,
useMovingNode, useMovingNode,
useReshapingNode, useReshapingNode,
useTangentReshape, useTangentReshape,
@@ -103,6 +105,8 @@ export const ToolManager: React.FC = () => {
const controlPointReshape = useControlPointReshape() const controlPointReshape = useControlPointReshape()
const tangentReshape = useTangentReshape() const tangentReshape = useTangentReshape()
const isCurveReshape = useIsCurveReshape() const isCurveReshape = useIsCurveReshape()
const isToolDrivenReshape = useIsToolDrivenReshape()
const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
const reshapingNode = useReshapingNode() const reshapingNode = useReshapingNode()
// The endpoint affordance tool's `target` is kind-specific // The endpoint affordance tool's `target` is kind-specific
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node + // (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
@@ -173,6 +177,7 @@ export const ToolManager: React.FC = () => {
mode === 'select' && mode === 'select' &&
isSoleSelection && isSoleSelection &&
selectedSlabId !== undefined && selectedSlabId !== undefined &&
!isFloorplanDrivenReshape &&
!editingSlabHoleIsManual !editingSlabHoleIsManual
// Show slab hole editor when editing a hole on the selected slab // Show slab hole editor when editing a hole on the selected slab
@@ -180,6 +185,7 @@ export const ToolManager: React.FC = () => {
selectedSlabId !== undefined && selectedSlabId !== undefined &&
editingHole !== null && editingHole !== null &&
editingHole.nodeId === selectedSlabId && editingHole.nodeId === selectedSlabId &&
!isFloorplanDrivenReshape &&
editingSlabHoleIsManual editingSlabHoleIsManual
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole) // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
@@ -188,13 +194,15 @@ export const ToolManager: React.FC = () => {
mode === 'select' && mode === 'select' &&
isSoleSelection && isSoleSelection &&
selectedCeilingId !== undefined && selectedCeilingId !== undefined &&
!isFloorplanDrivenReshape &&
(!editingHole || editingHole.nodeId !== selectedCeilingId) (!editingHole || editingHole.nodeId !== selectedCeilingId)
// Show ceiling hole editor when editing a hole on the selected ceiling // Show ceiling hole editor when editing a hole on the selected ceiling
const showCeilingHoleEditor = const showCeilingHoleEditor =
selectedCeilingId !== undefined && selectedCeilingId !== undefined &&
editingHole !== null && editingHole !== null &&
editingHole.nodeId === selectedCeilingId editingHole.nodeId === selectedCeilingId &&
!isFloorplanDrivenReshape
// Show zone boundary editor when in structure/select mode with a zone selected // Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab or ceiling to avoid overlapping handles // Hide when editing a slab or ceiling to avoid overlapping handles
@@ -202,6 +210,7 @@ export const ToolManager: React.FC = () => {
phase === 'structure' && phase === 'structure' &&
mode === 'select' && mode === 'select' &&
selectedZoneId !== null && selectedZoneId !== null &&
!isFloorplanDrivenReshape &&
!showSlabBoundaryEditor && !showSlabBoundaryEditor &&
!showCeilingBoundaryEditor !showCeilingBoundaryEditor
@@ -301,7 +310,8 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{endpointTarget && {isToolDrivenReshape &&
endpointTarget &&
reshapingNode && reshapingNode &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool( const RegistryAffordance = getRegistryAffordanceTool(
@@ -314,7 +324,8 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{isCurveReshape && {isToolDrivenReshape &&
isCurveReshape &&
reshapingNode && reshapingNode &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve') const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
@@ -324,7 +335,8 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{controlPointTarget && {isToolDrivenReshape &&
controlPointTarget &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point') const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
return RegistryAffordance ? ( return RegistryAffordance ? (
@@ -333,7 +345,8 @@ export const ToolManager: React.FC = () => {
</Suspense> </Suspense>
) : null ) : null
})()} })()}
{tangentTarget && {isToolDrivenReshape &&
tangentTarget &&
(() => { (() => {
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent') const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
return RegistryAffordance ? ( return RegistryAffordance ? (
@@ -86,7 +86,7 @@ describe('createWallOnCurrentLevel', () => {
useEditor.getState().setSnappingMode('wall', 'lines') useEditor.getState().setSnappingMode('wall', 'lines')
useInteractionScope useInteractionScope
.getState() .getState()
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' }) .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint', driver: 'tool' })
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')]) seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -22,6 +22,7 @@ import { useState } from 'react'
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind' import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import useFloorplanMode from '../../../store/use-floorplan-mode'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button' import { ActionButton } from './action-button'
@@ -63,6 +64,7 @@ export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
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 selectedKind = useEditor((state) => state.lastMeasurementKind) const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind) const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
const constructionDimensionChainMode = useEditor( const constructionDimensionChainMode = useEditor(
@@ -130,6 +132,10 @@ export function MeasurementControl() {
dimensionMode: ConstructionDimensionMode, dimensionMode: ConstructionDimensionMode,
chainMode: ConstructionDimensionChainMode, chainMode: ConstructionDimensionChainMode,
) => { ) => {
if (useFloorplanMode.getState().mode !== 'expert') {
useFloorplanMode.getState().showExpertModeNotice('Construction Dimension')
return
}
setPhase('structure') setPhase('structure')
setStructureLayer('elements') setStructureLayer('elements')
setViewMode('2d') setViewMode('2d')
@@ -218,6 +224,8 @@ export function MeasurementControl() {
) )
})} })}
{floorplanMode === 'expert' ? (
<>
<div className="my-1.5 h-px bg-border/60" /> <div className="my-1.5 h-px bg-border/60" />
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider"> <div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
Floor plan Floor plan
@@ -250,6 +258,8 @@ export function MeasurementControl() {
</button> </button>
) )
})} })}
</>
) : null}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@@ -36,21 +36,15 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react' } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { pasteSelectionAndPickUp } from '../editor/group-actions'
import { import {
buildLevelDuplicateCreateOps, buildLevelDuplicateCreateOps,
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import {
getEditorClipboardSnapshot,
pasteEditorClipboardToLevel,
subscribeEditorClipboard,
} from '../../lib/scene-clipboard'
import { sfxEmitter } from '../../lib/sfx-bus'
import { useLinearDisplay } from '../../lib/use-linear-display' import { useLinearDisplay } from '../../lib/use-linear-display'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { ActionButton } from './controls/action-button' import { ActionButton } from './controls/action-button'
@@ -399,11 +393,6 @@ export function FloatingLevelSelector() {
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null) const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const clipboardSnapshot = useSyncExternalStore(
subscribeEditorClipboard,
getEditorClipboardSnapshot,
getEditorClipboardSnapshot,
)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 4 }, activationConstraint: { distance: 4 },
@@ -522,10 +511,7 @@ export function FloatingLevelSelector() {
) )
const handlePasteToLevel = useCallback((level: LevelNode) => { const handlePasteToLevel = useCallback((level: LevelNode) => {
const result = pasteEditorClipboardToLevel(level.id) void pasteSelectionAndPickUp(level.id)
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
}, []) }, [])
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -627,9 +613,7 @@ export function FloatingLevelSelector() {
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onPaste={ onPaste={() => handlePasteToLevel(level)}
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
@@ -25,6 +25,7 @@ import {
} from './../../../../../components/ui/primitives/dialog' } from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch' import { Switch } from './../../../../../components/ui/primitives/switch'
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor' import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
import useFloorplanMode from './../../../../../store/use-floorplan-mode'
import { AudioSettingsDialog } from './audio-settings-dialog' import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
import { LoadBuildDialog, type PendingImport } from './load-build-dialog' import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
@@ -192,6 +193,7 @@ export function SettingsPanel({
const exportScene = useViewer((state) => state.exportScene) const exportScene = useViewer((state) => state.exportScene)
const shadows = useViewer((state) => state.shadows) const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const floorplanMode = useFloorplanMode((state) => state.mode)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null) const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
const sceneGraphValue = useMemo( const sceneGraphValue = useMemo(
@@ -399,7 +401,10 @@ export function SettingsPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium text-muted-foreground text-xs">Floor plan</div> <div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
<span>Floor plan</span>
<span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
</div>
<Button <Button
className="w-full justify-start gap-2" className="w-full justify-start gap-2"
onClick={() => exportFloorplanPdf('full')} onClick={() => exportFloorplanPdf('full')}
@@ -69,6 +69,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{ {
title: 'Selection', title: 'Selection',
shortcuts: [ shortcuts: [
{
keys: ['Cmd/Ctrl', 'C'],
action: 'Copy the selected objects',
note: 'The copied selection can be pasted into another level, project, or browser tab.',
},
{
keys: ['Cmd/Ctrl', 'X'],
action: 'Cut the selected objects',
note: 'Copies the selection to the clipboard, then removes it from this scene.',
},
{
keys: ['Cmd/Ctrl', 'V'],
action: 'Paste and place copied objects',
note:
'Carries a preview under the cursor. Click to place it, or press Escape to cancel.',
},
{ {
keys: ['Cmd/Ctrl', 'Left click'], keys: ['Cmd/Ctrl', 'Left click'],
action: 'Add or remove an object from multi-selection', action: 'Add or remove an object from multi-selection',
+18 -30
View File
@@ -10,6 +10,11 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import {
cutSelectionToEditorClipboard,
deleteSelection,
pasteSelectionAndPickUp,
} from '../components/editor/group-actions'
import { import {
classifyParticipant, classifyParticipant,
collectParticipants, collectParticipants,
@@ -24,12 +29,10 @@ import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events' import { guideEmitter } from '../lib/guide-events'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { isActive } from '../lib/interaction/scope' import { isActive } from '../lib/interaction/scope'
import { import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
copySelectedNodesToEditorClipboard, import { sfxEmitter } from '../lib/sfx-bus'
pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard'
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
import { toggleWindowOpenState } from '../lib/window-interaction' import { toggleWindowOpenState } from '../lib/window-interaction'
import useDeleteConfirmation from '../store/use-delete-confirmation'
import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor' import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope' import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
@@ -210,6 +213,10 @@ export const useKeyboard = ({
return return
} }
if (useDeleteConfirmation.getState().request) {
return
}
if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') { if (e.key === 'Shift' && !e.repeat && useEditor.getState().mode === 'material-paint') {
// In paint mode Shift cycles the application scope (this surface → // In paint mode Shift cycles the application scope (this surface →
// whole item / all matching / room) — the paint-mode analogue of the // whole item / all matching / room) — the paint-mode analogue of the
@@ -361,13 +368,14 @@ export const useKeyboard = ({
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
copySelectedNodesToEditorClipboard() copySelectedNodesToEditorClipboard()
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return
e.preventDefault()
cutSelectionToEditorClipboard()
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
const result = pasteEditorClipboardToLevel() void pasteSelectionAndPickUp()
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
} else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -621,27 +629,7 @@ export const useKeyboard = ({
} }
} }
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (deleteSelection()) {
if (selectedNodeIds.length > 0) {
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
const BULK_DELETE_THRESHOLD = 10
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
const confirmed = window.confirm(
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
)
if (!confirmed) return
}
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
emitDeleteSFX(node?.type)
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedNodeIds)
return return
} }
+9 -5
View File
@@ -26,6 +26,7 @@ export { default as Editor } from './components/editor'
// surface uses the shorter, shell-friendly names from the unified // surface uses the shorter, shell-friendly names from the unified
// preset-system spec. // preset-system spec.
export { BakeExporter } from './components/editor/bake-exporter' export { BakeExporter } from './components/editor/bake-exporter'
export { BakeThumbnail } from './components/editor/bake-thumbnail'
export { FirstPersonControls } from './components/editor/first-person-controls' export { FirstPersonControls } from './components/editor/first-person-controls'
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
// Embed surface — the editor's real in-canvas affordances, so a host can mount // Embed surface — the editor's real in-canvas affordances, so a host can mount
@@ -350,6 +351,7 @@ export {
type FloorplanRenderPurpose, type FloorplanRenderPurpose,
type FloorplanSchedule, type FloorplanSchedule,
type FloorplanToolContext, type FloorplanToolContext,
type FloorplanToolMode,
floorplanGeometryMetadata, floorplanGeometryMetadata,
getFloorplanNodeExtension, getFloorplanNodeExtension,
readFloorplanContext, readFloorplanContext,
@@ -357,6 +359,12 @@ export {
readFloorplanMetricNotationOverride, readFloorplanMetricNotationOverride,
withFloorplanGeometryMetadata, withFloorplanGeometryMetadata,
} from './lib/floorplan/floorplan-extension' } from './lib/floorplan/floorplan-extension'
export {
DEFAULT_FLOORPLAN_MODE,
FLOORPLAN_MODES,
type FloorplanMode,
isFloorplanToolAvailableInMode,
} from './lib/floorplan/floorplan-mode'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export' export { exportSceneToGlb } from './lib/glb-export'
export { export {
@@ -516,11 +524,7 @@ export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud' export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility' export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility'
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
export { export { default as useFloorplanMode } from './store/use-floorplan-mode'
default as useFloorplanPreflight,
type FloorplanPreflightIssue,
type FloorplanPreflightIssueKind,
} from './store/use-floorplan-preflight'
export { export {
default as useInteractionScope, default as useInteractionScope,
getEditingHole, getEditingHole,
@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
import { import {
normalizeCameraPose, normalizeCameraPose,
planCameraPoseApplication, planCameraPoseApplication,
publishInitialCameraPose,
releaseCameraPoseEventSuppression,
stepCameraPoseInterpolation, stepCameraPoseInterpolation,
withCameraPoseDistance, withCameraPoseDistance,
} from './camera-pose' } from './camera-pose'
@@ -163,6 +165,28 @@ describe('camera pose', () => {
}) })
}) })
test('publishes the initial pose before any camera control update event', () => {
let publishCount = 0
publishInitialCameraPose(() => {
publishCount += 1
})
expect(publishCount).toBe(1)
})
test('publishes the settled pose when programmatic interpolation releases suppression', () => {
const suppression = { current: true }
let publishCount = 0
releaseCameraPoseEventSuppression(suppression, () => {
publishCount += 1
})
expect(suppression.current).toBe(false)
expect(publishCount).toBe(1)
})
test('retargets the bounded interpolation owner to the latest pose', () => { test('retargets the bounded interpolation owner to the latest pose', () => {
const first = stepCameraPoseInterpolation( const first = stepCameraPoseInterpolation(
[0, 0, 0], [0, 0, 0],
+12
View File
@@ -17,6 +17,18 @@ export type CameraPoseInterpolationStep = {
target: [number, number, number] target: [number, number, number]
} }
export function publishInitialCameraPose(publishCurrentPose: () => void): void {
publishCurrentPose()
}
export function releaseCameraPoseEventSuppression(
suppression: { current: boolean },
publishCurrentPose: () => void,
): void {
suppression.current = false
publishCurrentPose()
}
function finiteVectorTuple(value: unknown): [number, number, number] | null { function finiteVectorTuple(value: unknown): [number, number, number] | null {
if ( if (
!( !(
@@ -5,7 +5,7 @@ import {
filterFloorplanAnnotationGeometry, filterFloorplanAnnotationGeometry,
normalizeFloorplanAnnotationVisibility, normalizeFloorplanAnnotationVisibility,
} from './annotation-visibility' } from './annotation-visibility'
import { floorplanGeometryMetadata } from './floorplan-extension' import { floorplanGeometryMetadata, withFloorplanGeometryMetadata } from './floorplan-extension'
describe('floor-plan annotation visibility', () => { describe('floor-plan annotation visibility', () => {
test('fills missing persisted categories with visible defaults', () => { test('fills missing persisted categories with visible defaults', () => {
@@ -136,6 +136,84 @@ describe('floor-plan annotation visibility', () => {
).toBeNull() ).toBeNull()
}) })
test('keeps a contextual dimension when automatic dimensions are hidden', () => {
const contextualDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [2, 0],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.08,
text: '2m',
},
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(contextualDimension, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
contextualDimensions: true,
}),
).toEqual(contextualDimension)
})
test('removes a nested automatic wall string beside a contextual dimension', () => {
const wallBody = {
kind: 'polygon',
points: [
[0, 0],
[0.2, 0],
[0.2, 6],
[0, 6],
],
} satisfies FloorplanGeometry
const automaticDimension = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [0, 18], text: '18m' }],
offsetNormal: [1, 0],
offsetDistance: 0.55,
extensionOvershoot: 0.12,
} satisfies FloorplanGeometry
const contextualDimension = withFloorplanGeometryMetadata(
{
kind: 'dimension',
start: [0, 0],
end: [0, 6],
offsetNormal: [1, 0],
offsetDistance: 0.34,
extensionOvershoot: 0.08,
text: '6m',
} satisfies FloorplanGeometry,
{ annotationRole: 'contextual-dimension' },
)
const geometry = {
kind: 'group',
children: [
{
kind: 'group',
children: [wallBody, automaticDimension],
},
contextualDimension,
],
} satisfies FloorplanGeometry
expect(
filterFloorplanAnnotationGeometry(geometry, {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
automaticDimensions: false,
contextualDimensions: true,
}),
).toEqual({
kind: 'group',
children: [{ kind: 'group', children: [wallBody] }, contextualDimension],
})
})
test('hides structural grids and only the center marks within column geometry', () => { test('hides structural grids and only the center marks within column geometry', () => {
const centerMark = { const centerMark = {
kind: 'line', kind: 'line',
@@ -3,6 +3,7 @@ import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './f
export type FloorplanAnnotationCategory = export type FloorplanAnnotationCategory =
| 'automaticDimensions' | 'automaticDimensions'
| 'contextualDimensions'
| 'manualDimensions' | 'manualDimensions'
| 'measurements' | 'measurements'
| 'openingMarks' | 'openingMarks'
@@ -14,6 +15,7 @@ export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory,
export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = { export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
automaticDimensions: true, automaticDimensions: true,
contextualDimensions: false,
manualDimensions: true, manualDimensions: true,
measurements: true, measurements: true,
openingMarks: true, openingMarks: true,
@@ -22,6 +24,14 @@ export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibil
stairAnnotations: true, stairAnnotations: true,
} }
export function revealFloorplanAnnotationRole(
visibility: FloorplanAnnotationVisibility,
role: FloorplanAnnotationRole,
): FloorplanAnnotationVisibility {
const category = annotationCategoryForRole(role)
return visibility[category] ? visibility : { ...visibility, [category]: true }
}
export function normalizeFloorplanAnnotationVisibility( export function normalizeFloorplanAnnotationVisibility(
value: unknown, value: unknown,
): FloorplanAnnotationVisibility { ): FloorplanAnnotationVisibility {
@@ -32,6 +42,10 @@ export function normalizeFloorplanAnnotationVisibility(
typeof persisted.automaticDimensions === 'boolean' typeof persisted.automaticDimensions === 'boolean'
? persisted.automaticDimensions ? persisted.automaticDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions, : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
contextualDimensions:
typeof persisted.contextualDimensions === 'boolean'
? persisted.contextualDimensions
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.contextualDimensions,
manualDimensions: manualDimensions:
typeof persisted.manualDimensions === 'boolean' typeof persisted.manualDimensions === 'boolean'
? persisted.manualDimensions ? persisted.manualDimensions
@@ -68,7 +82,7 @@ export function filterFloorplanAnnotationGeometry(
if (role && !isAnnotationRoleVisible(role, visibility)) return null if (role && !isAnnotationRoleVisible(role, visibility)) return null
if ( if (
!visibility.automaticDimensions && !visibility.automaticDimensions &&
role !== 'manual-dimension' && (role === undefined || role === 'automatic-dimension') &&
(geometry.kind === 'dimension' || (geometry.kind === 'dimension' ||
geometry.kind === 'dimension-string' || geometry.kind === 'dimension-string' ||
geometry.kind === 'dimension-label' || geometry.kind === 'dimension-label' ||
@@ -82,7 +96,10 @@ export function filterFloorplanAnnotationGeometry(
.map((child) => filterFloorplanAnnotationGeometry(child, visibility, role)) .map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
.filter((child): child is FloorplanGeometry => child !== null) .filter((child): child is FloorplanGeometry => child !== null)
if (children.length === 0) return null if (children.length === 0) return null
if (children.length === geometry.children.length) return geometry const childrenUnchanged =
children.length === geometry.children.length &&
children.every((child, index) => child === geometry.children[index])
if (childrenUnchanged) return geometry
return { ...geometry, children } return { ...geometry, children }
} }
@@ -90,21 +107,27 @@ function isAnnotationRoleVisible(
role: FloorplanAnnotationRole, role: FloorplanAnnotationRole,
visibility: FloorplanAnnotationVisibility, visibility: FloorplanAnnotationVisibility,
): boolean { ): boolean {
return visibility[annotationCategoryForRole(role)]
}
function annotationCategoryForRole(role: FloorplanAnnotationRole): FloorplanAnnotationCategory {
switch (role) { switch (role) {
case 'automatic-dimension': case 'automatic-dimension':
return visibility.automaticDimensions return 'automaticDimensions'
case 'contextual-dimension':
return 'contextualDimensions'
case 'manual-dimension': case 'manual-dimension':
return visibility.manualDimensions return 'manualDimensions'
case 'measurement': case 'measurement':
return visibility.measurements return 'measurements'
case 'opening-mark': case 'opening-mark':
return visibility.openingMarks return 'openingMarks'
case 'structural-grid': case 'structural-grid':
case 'column-center': case 'column-center':
return visibility.structuralGrids return 'structuralGrids'
case 'room-label': case 'room-label':
return visibility.roomLabels return 'roomLabels'
case 'stair-annotation': case 'stair-annotation':
return visibility.stairAnnotations return 'stairAnnotations'
} }
} }
@@ -1,20 +1,12 @@
import { beforeAll, describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { import type { FloorplanGeometry } from '@pascal-app/core'
DrawingSheetNode,
type FloorplanGeometry,
nodeRegistry,
registerNode,
} from '@pascal-app/core'
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer' import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
import { import {
filterFloorplanExportOverlay, filterFloorplanExportOverlay,
fitPlanToBox, fitPlanToBox,
isFloorplanExportAnnotationGeometry, isFloorplanExportAnnotationGeometry,
partitionFloorplanExportOverlay, partitionFloorplanExportOverlay,
pointsPerMeterForDrawingScale,
resolveDrawingSheetDocumentMarkers,
resolveDrawingSheetGeneralNotes,
resolveDrawingSheetKeyedNotes,
resolveFloorplanExportAnnotationVisibility, resolveFloorplanExportAnnotationVisibility,
resolveFloorplanExportNodeGeometry, resolveFloorplanExportNodeGeometry,
resolveFloorplanExportPlacement, resolveFloorplanExportPlacement,
@@ -24,40 +16,31 @@ import {
resolveFloorplanMeasurementSize, resolveFloorplanMeasurementSize,
resolveFloorplanPageLayout, resolveFloorplanPageLayout,
resolveFloorplanScreenUnitsPerPixel, resolveFloorplanScreenUnitsPerPixel,
resolveGraphicScaleLength,
resolveSheetComposition,
resolveSheetExportLayout,
resolveSheetPageSetup,
rotateFloorplanExportBounds, rotateFloorplanExportBounds,
} from './floorplan-export' } from './floorplan-export'
import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension' import { floorplanGeometryMetadata } from './floorplan-extension'
const drawingSheetExtension: FloorplanNodeExtension<DrawingSheetNode> = {
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
node.placedViews.some(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
? node
: null,
}
beforeAll(() => {
if (nodeRegistry.has('drawing-sheet')) return
registerNode({
kind: 'drawing-sheet',
schemaVersion: 1,
schema: DrawingSheetNode,
category: 'analysis',
defaults: () => ({}) as never,
capabilities: {},
extensions: {
'pascal:editor/floorplan': drawingSheetExtension,
},
})
})
describe('filterFloorplanExportOverlay', () => { describe('filterFloorplanExportOverlay', () => {
test('preserves annotation metadata while splitting geometry passes', () => {
const contextualDimension = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
children: [
{
kind: 'dimension',
start: [0, 0],
end: [2, 0],
offsetNormal: [0, 1],
offsetDistance: 0.3,
extensionOvershoot: 0.08,
text: '2m',
},
],
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(contextualDimension).overlay).toMatchObject(contextualDimension)
})
test('preserves value labels and removes editing handles', () => { test('preserves value labels and removes editing handles', () => {
const label = { const label = {
kind: 'dimension-label', kind: 'dimension-label',
@@ -242,6 +225,7 @@ describe('floor plan export policy', () => {
test('exports the same annotation categories that are visible in the live view', () => { test('exports the same annotation categories that are visible in the live view', () => {
const liveVisibility = { const liveVisibility = {
automaticDimensions: true, automaticDimensions: true,
contextualDimensions: false,
manualDimensions: false, manualDimensions: false,
measurements: true, measurements: true,
openingMarks: true, openingMarks: true,
@@ -250,7 +234,27 @@ describe('floor plan export policy', () => {
stairAnnotations: true, stairAnnotations: true,
} }
expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility) expect(resolveFloorplanExportAnnotationVisibility('expert', liveVisibility)).toEqual(
liveVisibility,
)
})
test('exports only model geometry and room labels in Default', () => {
expect(
resolveFloorplanExportAnnotationVisibility(
'default',
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
}) })
test('matches live screen sizing to the fitted export viewport', () => { test('matches live screen sizing to the fitted export viewport', () => {
@@ -298,16 +302,6 @@ describe('floor plan export policy', () => {
}) })
}) })
describe('pointsPerMeterForDrawingScale', () => {
test('converts metric ratios to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4)
})
test('converts imperial architectural scales to plotted points per metre', () => {
expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4)
})
})
describe('resolveFloorplanMeasurementSize', () => { describe('resolveFloorplanMeasurementSize', () => {
test('sizes the hidden SVG in screen pixels before resolving label collisions', () => { test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
expect( expect(
@@ -316,276 +310,10 @@ describe('resolveFloorplanMeasurementSize', () => {
}) })
}) })
describe('resolveSheetExportLayout', () => {
test('reserves a plan viewport, side panel, and title block on one sheet page', () => {
expect(resolveSheetExportLayout(842, 595)).toEqual({
planBox: { x: 36, y: 36, width: 572, height: 463 },
sidePanel: { x: 626, y: 36, width: 180, height: 463 },
titleBlock: { x: 36, y: 517, width: 770, height: 42 },
})
})
})
describe('resolveFloorplanPageLayout', () => { describe('resolveFloorplanPageLayout', () => {
test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => { test('uses the available A4 page area for the fitted plan', () => {
expect(resolveFloorplanPageLayout(842, 595)).toEqual({ expect(resolveFloorplanPageLayout(842, 595)).toEqual({
planBox: { x: 36, y: 64, width: 770, height: 495 }, planBox: { x: 36, y: 64, width: 770, height: 495 },
}) })
}) })
}) })
describe('resolveGraphicScaleLength', () => {
test('chooses a model length that fits the available paper width', () => {
const scale = resolveGraphicScaleLength('1:50', 150)
expect(scale.modelMeters).toBe(2)
expect(scale.widthPt).toBeCloseTo(113.39, 2)
expect(scale.label).toBe('2 m')
})
})
describe('resolveSheetComposition', () => {
test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
placedViews: [
{
id: 'drawing-view_main',
levelId: 'level_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
scale: '1:50',
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
})
expect(
resolveSheetComposition(
{ [sheet.id]: sheet },
'level_main',
'Main Level',
'floor-plan',
'Floor plan',
'1/4"=1\'-0"',
),
).toMatchObject({
sheetNumber: 'A1.1',
sheetTitle: 'Plans',
paperSize: 'arch-b',
orientation: 'landscape',
drawingNumber: '2',
viewTitle: 'Main Floor Plan',
scale: '1:50',
generalNotes: [{ number: 1, text: 'Verify all dimensions.' }],
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
keyedNoteInstances: [],
})
})
test('resolves reusable general note sets before sheet-local notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([
{ number: 1, text: 'Coordinate with structural.' },
{ number: 2, text: 'Verify dimensions.' },
])
})
test('reports duplicate reusable and sheet-local general notes', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }],
})
expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([
{
severity: 'warning',
message:
'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.',
},
])
})
test('derives keyed-note legends from repeated stable instances', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' },
{ id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-1',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'keyed-note-instance_patch-2',
definitionId: 'keyed-note_patch',
placedViewId: 'drawing-view_main',
position: [4, 3],
},
],
keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }],
})
expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({
legend: [{ key: 'A', text: 'Patch existing slab.' }],
instances: [
{ id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 },
{ id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 },
],
warnings: [],
})
})
test('reports keyed-note instances with missing definitions', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
keyedNoteInstances: [
{
id: 'keyed-note-instance_missing',
definitionId: 'keyed-note_missing',
position: [2, 3],
},
],
})
expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([
{
severity: 'warning',
message:
'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.',
},
])
})
test('resolves scoped drawing sheet document markers', () => {
const sheet = DrawingSheetNode.parse({
id: 'drawing-sheet_a101',
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
{
id: 'sheet-marker_other-view',
kind: 'detail-reference',
label: '3',
placedViewId: 'drawing-view_other',
position: [5, 5],
},
],
})
expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: '',
x: 2,
y: 3,
endX: null,
endY: null,
points: [],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
title: '',
sheetReference: '',
drawingReference: '',
revisionId: 'A',
x: 0.5,
y: 0.5,
endX: null,
endY: null,
points: [
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 1, y: 2 },
],
},
])
})
})
describe('resolveSheetPageSetup', () => {
test('resolves supported paper sizes and orientation to page points', () => {
expect(
resolveSheetPageSetup({
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
}),
).toEqual({ width: 1296, height: 864, orientation: 'landscape' })
const a3 = resolveSheetPageSetup({
paperSize: 'a3',
orientation: 'portrait',
customPaperWidth: null,
customPaperHeight: null,
})
expect(a3.width).toBeCloseTo(841.89, 2)
expect(a3.height).toBeCloseTo(1190.55, 2)
})
test('uses custom paper dimensions in inches', () => {
expect(
resolveSheetPageSetup({
paperSize: 'custom',
orientation: 'portrait',
customPaperWidth: 24,
customPaperHeight: 36,
}),
).toEqual({ width: 1728, height: 2592, orientation: 'portrait' })
})
})
@@ -4,10 +4,6 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type ConstructionDrawingType, type ConstructionDrawingType,
type DrawingSheetNode,
type DrawingSheetOrientation,
type DrawingSheetPaperSize,
type DrawingSheetScale,
type FloorplanGeometry, type FloorplanGeometry,
type FloorplanPalette, type FloorplanPalette,
type FloorplanPoint, type FloorplanPoint,
@@ -33,6 +29,7 @@ import {
import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view' import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility' import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
import useFloorplanMode from '../../store/use-floorplan-mode'
import { import {
type FloorplanAnnotationVisibility, type FloorplanAnnotationVisibility,
filterFloorplanAnnotationGeometry, filterFloorplanAnnotationGeometry,
@@ -41,9 +38,15 @@ import { resolveNodeForDrawingType } from './drawing-coordination'
import { import {
type FloorplanMetricNotation, type FloorplanMetricNotation,
type FloorplanSchedule, type FloorplanSchedule,
type FloorplanWallDimensionReference,
getFloorplanNodeExtension, getFloorplanNodeExtension,
readFloorplanGeometryMetadata, readFloorplanGeometryMetadata,
} from './floorplan-extension' } from './floorplan-extension'
import {
type FloorplanMode,
resolveFloorplanAnnotationVisibility,
resolveFloorplanWallDimensionReference,
} from './floorplan-mode'
import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document' import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document'
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer' import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry' import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
@@ -73,11 +76,8 @@ const PLAN_PADDING_RATIO = 0.2
/** PDF page margin + title band, in pt. */ /** PDF page margin + title band, in pt. */
const PAGE_MARGIN_PT = 36 const PAGE_MARGIN_PT = 36
const TITLE_BAND_PT = 28 const TITLE_BAND_PT = 28
const SHEET_GAP_PT = 18 const A4_LANDSCAPE_WIDTH_PT = (297 / 25.4) * 72
const SHEET_SIDE_PANEL_WIDTH_PT = 180 const A4_LANDSCAPE_HEIGHT_PT = (210 / 25.4) * 72
const TITLE_BLOCK_HEIGHT_PT = 42
const POINTS_PER_INCH = 72
const METERS_PER_INCH = 0.0254
const NEUTRAL_PALETTE: FloorplanPalette = { const NEUTRAL_PALETTE: FloorplanPalette = {
selectedStroke: '#334155', selectedStroke: '#334155',
@@ -111,8 +111,16 @@ const NEUTRAL_VIEW_STATE = {
export function resolveFloorplanExportViewState( export function resolveFloorplanExportViewState(
unit: 'metric' | 'imperial', unit: 'metric' | 'imperial',
metricNotation: FloorplanMetricNotation, metricNotation: FloorplanMetricNotation,
wallDimensionReference?: FloorplanWallDimensionReference,
automaticDimensions = true,
) { ) {
return { ...NEUTRAL_VIEW_STATE, unit, metricNotation } return {
...NEUTRAL_VIEW_STATE,
automaticDimensions,
unit,
metricNotation,
wallDimensionReference,
}
} }
type ExportLevel = { id: AnyNodeId; label: string } type ExportLevel = { id: AnyNodeId; label: string }
@@ -123,83 +131,24 @@ type ExportGeometry = {
annotations: FloorplanGeometry | null annotations: FloorplanGeometry | null
} }
type SheetComposition = {
sheetNumber: string
sheetTitle: string
paperSize: DrawingSheetPaperSize
orientation: DrawingSheetOrientation
customPaperWidth: number | null
customPaperHeight: number | null
drawingNumber: string
viewTitle: string
drawingLabel: string
scale: DrawingSheetScale
generalNotes: { number: number; text: string }[]
keyedNoteLegend: { key: string; text: string }[]
keyedNoteInstances: { id: string; key: string; x: number; y: number }[]
documentMarkers: ResolvedDocumentMarker[]
preflightIssues: SheetPreflightIssue[]
}
export type SheetExportLayout = {
planBox: { x: number; y: number; width: number; height: number }
sidePanel: { x: number; y: number; width: number; height: number }
titleBlock: { x: number; y: number; width: number; height: number }
}
export type FloorplanPageLayout = { export type FloorplanPageLayout = {
planBox: { x: number; y: number; width: number; height: number } planBox: { x: number; y: number; width: number; height: number }
} }
type ScheduleDrawResult = {
drawnSchedules: number
overflowSchedules: FloorplanSchedule[]
}
export type SheetPageSetup = {
width: number
height: number
orientation: DrawingSheetOrientation
}
export type SheetPreflightIssue = {
severity: 'warning'
message: string
}
type ResolvedGeneralNotes = {
notes: { number: number; text: string }[]
duplicateWarnings: SheetPreflightIssue[]
}
type ResolvedKeyedNotes = {
legend: { key: string; text: string }[]
instances: { id: string; key: string; x: number; y: number }[]
warnings: SheetPreflightIssue[]
}
type ResolvedDocumentMarker = {
id: string
kind: string
label: string
title: string
sheetReference: string
drawingReference: string
revisionId: string
x: number
y: number
endX: number | null
endY: number | null
points: { x: number; y: number }[]
}
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> { export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const viewer = useViewer.getState() const viewer = useViewer.getState()
const unit = viewer.unit const unit = viewer.unit
const metricNotation = viewer.metricNotation const metricNotation = viewer.metricNotation
const floorplanMode = useFloorplanMode.getState().mode
const expertAnnotationState = useFloorplanAnnotationVisibility.getState()
const annotationVisibility = resolveFloorplanExportAnnotationVisibility( const annotationVisibility = resolveFloorplanExportAnnotationVisibility(
useFloorplanAnnotationVisibility.getState().visibility, floorplanMode,
expertAnnotationState.visibility,
)
const wallDimensionReference = resolveFloorplanWallDimensionReference(
floorplanMode,
expertAnnotationState.wallDimensionReference,
) )
const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth
const drawingType = useDrawingView.getState().drawingType const drawingType = useDrawingView.getState().drawingType
@@ -212,15 +161,9 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
return return
} }
const defaultPageSetup = resolveSheetPageSetup({
paperSize: 'a4',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
})
const { doc, save } = await createFloorplanPdfDocument([ const { doc, save } = await createFloorplanPdfDocument([
defaultPageSetup.width, A4_LANDSCAPE_WIDTH_PT,
defaultPageSetup.height, A4_LANDSCAPE_HEIGHT_PT,
]) ])
const host = document.createElement('div') const host = document.createElement('div')
@@ -239,17 +182,11 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
metricNotation, metricNotation,
annotationVisibility, annotationVisibility,
drawingType, drawingType,
wallDimensionReference,
) )
const schedules = collectFloorplanSchedules(nodes, level.id, unit) const schedules = collectFloorplanSchedules(nodes, level.id, unit)
if (geometries.length === 0 && schedules.length === 0) continue if (geometries.length === 0 && schedules.length === 0) continue
const pageSetup = resolveSheetPageSetup({ const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
paperSize: 'a4',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
})
const layout = resolveFloorplanPageLayout(pageSetup.width, pageSetup.height)
let scheduleOverflow: FloorplanSchedule[] = [...schedules]
if (geometries.length > 0) { if (geometries.length > 0) {
// Preserve the live floor-plan orientation rather than forcing north-up. // Preserve the live floor-plan orientation rather than forcing north-up.
@@ -266,7 +203,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
) )
if (mounted) { if (mounted) {
try { try {
doc.addPage([pageSetup.width, pageSetup.height], pageSetup.orientation) doc.addPage([A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT], 'landscape')
pageCount++ pageCount++
const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel( const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel(
@@ -312,8 +249,8 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
} }
} }
if (scheduleOverflow.length > 0) { if (schedules.length > 0) {
pageCount = drawFloorplanSchedulePages(doc, level.label, scheduleOverflow, pageCount) pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
} }
} }
@@ -359,246 +296,6 @@ export function collectFloorplanSchedules(
return schedules return schedules
} }
export function resolveSheetComposition(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
levelLabel: string,
drawingType: ConstructionDrawingType,
drawingLabel: string,
fallbackScale: DrawingSheetScale,
): SheetComposition {
const sheet = findDrawingSheetForLevel(nodes, levelId, drawingType)
const placedView = sheet?.placedViews.find(
(view) =>
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
)
const generalNotes = sheet
? resolveDrawingSheetGeneralNotes(sheet)
: { notes: [], duplicateWarnings: [] }
const keyedNotes = sheet
? resolveDrawingSheetKeyedNotes(sheet, placedView?.id ?? null)
: { legend: [], instances: [], warnings: [] }
const documentMarkers = sheet
? resolveDrawingSheetDocumentMarkers(sheet, placedView?.id ?? null)
: []
return {
sheetNumber: sheet?.sheetNumber ?? 'A1.0',
sheetTitle: sheet?.sheetTitle ?? drawingLabel,
paperSize: sheet?.paperSize ?? 'a4',
orientation: sheet?.orientation ?? 'landscape',
customPaperWidth: sheet?.customPaperWidth ?? null,
customPaperHeight: sheet?.customPaperHeight ?? null,
drawingNumber: placedView?.drawingNumber ?? '1',
viewTitle: placedView?.title ?? `${levelLabel} ${drawingLabel}`,
drawingLabel,
scale: placedView?.scale ?? fallbackScale,
generalNotes: generalNotes.notes,
keyedNoteLegend: keyedNotes.legend,
keyedNoteInstances: keyedNotes.instances,
documentMarkers,
preflightIssues: [...generalNotes.duplicateWarnings, ...keyedNotes.warnings],
}
}
export function resolveDrawingSheetGeneralNotes(sheet: DrawingSheetNode): ResolvedGeneralNotes {
const generalNoteSets = sheet.generalNoteSets ?? []
const generalNoteSetIds = sheet.generalNoteSetIds ?? []
const sheetNotes = sheet.generalNotes ?? []
const selectedSetIds =
generalNoteSetIds.length > 0
? new Set(generalNoteSetIds)
: new Set(generalNoteSets.map((set) => set.id))
const noteSources = [
...generalNoteSets
.filter((set) => selectedSetIds.has(set.id))
.flatMap((set) => set.notes.map((note) => ({ text: note.text, source: set.name }))),
...sheetNotes.map((note) => ({ text: note.text, source: 'sheet' })),
]
const notes = noteSources.map((note, index) => ({ number: index + 1, text: note.text }))
const duplicateWarnings: SheetPreflightIssue[] = []
const seen = new Map<string, { text: string; count: number; sources: Set<string> }>()
for (const note of noteSources) {
const key = normalizeGeneralNoteText(note.text)
const existing = seen.get(key)
if (existing) {
existing.count += 1
existing.sources.add(note.source)
continue
}
seen.set(key, { text: note.text, count: 1, sources: new Set([note.source]) })
}
for (const duplicate of seen.values()) {
if (duplicate.count < 2) continue
duplicateWarnings.push({
severity: 'warning',
message: `Duplicate general note: "${duplicate.text}" appears in ${[
...duplicate.sources,
].join(' and ')}.`,
})
}
return { notes, duplicateWarnings }
}
function normalizeGeneralNoteText(text: string): string {
return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase()
}
export function resolveDrawingSheetKeyedNotes(
sheet: DrawingSheetNode,
placedViewId: string | null = null,
): ResolvedKeyedNotes {
const definitions = sheet.keyedNoteDefinitions ?? []
const instances = sheet.keyedNoteInstances ?? []
const definitionById = new Map(definitions.map((definition) => [definition.id, definition]))
const scopedInstances = instances.filter(
(instance) => instance.placedViewId === null || instance.placedViewId === placedViewId,
)
const warnings: SheetPreflightIssue[] = []
const usedDefinitions = new Map<string, { key: string; text: string }>()
const resolvedInstances: ResolvedKeyedNotes['instances'] = []
for (const instance of scopedInstances) {
const definition = definitionById.get(instance.definitionId)
if (!definition) {
warnings.push({
severity: 'warning',
message: `Keyed-note symbol ${instance.id} references missing definition ${instance.definitionId}.`,
})
continue
}
usedDefinitions.set(definition.id, { key: definition.key, text: definition.text })
resolvedInstances.push({
id: instance.id,
key: definition.key,
x: instance.position[0],
y: instance.position[1],
})
}
const derivedLegend = [...usedDefinitions.values()].sort((left, right) =>
left.key.localeCompare(right.key, undefined, { numeric: true }),
)
return {
legend: derivedLegend.length > 0 ? derivedLegend : (sheet.keyedNoteLegend ?? []),
instances: resolvedInstances,
warnings,
}
}
export function resolveDrawingSheetDocumentMarkers(
sheet: DrawingSheetNode,
placedViewId: string | null = null,
): ResolvedDocumentMarker[] {
return (sheet.documentMarkers ?? [])
.filter((marker) => marker.placedViewId === null || marker.placedViewId === placedViewId)
.map((marker) => ({
id: marker.id,
kind: marker.kind,
label: marker.label,
title: marker.title,
sheetReference: marker.sheetReference,
drawingReference: marker.drawingReference,
revisionId: marker.revisionId,
x: marker.position[0],
y: marker.position[1],
endX: marker.endPosition?.[0] ?? null,
endY: marker.endPosition?.[1] ?? null,
points: marker.points.map(([x, y]) => ({ x, y })),
}))
}
export function resolveSheetPageSetup(
sheet: Pick<
SheetComposition,
'paperSize' | 'orientation' | 'customPaperWidth' | 'customPaperHeight'
>,
): SheetPageSetup {
const base = paperSizePoints(sheet.paperSize, sheet.customPaperWidth, sheet.customPaperHeight)
const [width, height] =
sheet.orientation === 'landscape'
? [Math.max(base.width, base.height), Math.min(base.width, base.height)]
: [Math.min(base.width, base.height), Math.max(base.width, base.height)]
return { width, height, orientation: sheet.orientation }
}
function paperSizePoints(
paperSize: DrawingSheetPaperSize,
customPaperWidth: number | null,
customPaperHeight: number | null,
): { width: number; height: number } {
switch (paperSize) {
case 'letter':
return inchesToPoints(8.5, 11)
case 'tabloid':
return inchesToPoints(11, 17)
case 'arch-a':
return inchesToPoints(9, 12)
case 'arch-b':
return inchesToPoints(12, 18)
case 'arch-c':
return inchesToPoints(18, 24)
case 'a3':
return millimetersToPoints(297, 420)
case 'custom':
return inchesToPoints(customPaperWidth ?? 18, customPaperHeight ?? 12)
case 'a4':
return millimetersToPoints(210, 297)
}
}
function inchesToPoints(width: number, height: number): { width: number; height: number } {
return { width: width * POINTS_PER_INCH, height: height * POINTS_PER_INCH }
}
function millimetersToPoints(width: number, height: number): { width: number; height: number } {
return inchesToPoints(width / 25.4, height / 25.4)
}
function findDrawingSheetForLevel(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
drawingType: ConstructionDrawingType,
): DrawingSheetNode | null {
for (const node of Object.values(nodes)) {
const resolveDrawingSheet = getFloorplanNodeExtension(
nodeRegistry.get(node.type),
)?.resolveDrawingSheet
const sheet = resolveDrawingSheet?.({ node: node as never, levelId, drawingType })
if (sheet) return sheet
}
return null
}
export function resolveSheetExportLayout(pageWidth: number, pageHeight: number): SheetExportLayout {
const contentX = PAGE_MARGIN_PT
const contentY = PAGE_MARGIN_PT
const contentWidth = pageWidth - PAGE_MARGIN_PT * 2
const contentHeight = pageHeight - PAGE_MARGIN_PT * 2
const titleBlock = {
x: contentX,
y: contentY + contentHeight - TITLE_BLOCK_HEIGHT_PT,
width: contentWidth,
height: TITLE_BLOCK_HEIGHT_PT,
}
const upperHeight = contentHeight - TITLE_BLOCK_HEIGHT_PT - SHEET_GAP_PT
const sidePanel = {
x: contentX + contentWidth - SHEET_SIDE_PANEL_WIDTH_PT,
y: contentY,
width: SHEET_SIDE_PANEL_WIDTH_PT,
height: upperHeight,
}
return {
planBox: {
x: contentX,
y: contentY,
width: contentWidth - SHEET_SIDE_PANEL_WIDTH_PT - SHEET_GAP_PT,
height: upperHeight,
},
sidePanel,
titleBlock,
}
}
export function resolveFloorplanPageLayout( export function resolveFloorplanPageLayout(
pageWidth: number, pageWidth: number,
pageHeight: number, pageHeight: number,
@@ -748,398 +445,6 @@ function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: num
return `${truncated}...` return `${truncated}...`
} }
function drawSheetChrome(
doc: FloorplanPdfDocument,
layout: SheetExportLayout,
composition: SheetComposition,
schedules: readonly FloorplanSchedule[],
): ScheduleDrawResult {
doc.setDrawColor('#0f172a')
doc.setLineWidth(0.6)
doc.rect(
PAGE_MARGIN_PT,
PAGE_MARGIN_PT,
doc.internal.pageSize.getWidth() - PAGE_MARGIN_PT * 2,
doc.internal.pageSize.getHeight() - PAGE_MARGIN_PT * 2,
)
doc.setDrawColor('#cbd5e1')
doc.setLineWidth(0.4)
doc.rect(layout.planBox.x, layout.planBox.y, layout.planBox.width, layout.planBox.height)
doc.rect(layout.sidePanel.x, layout.sidePanel.y, layout.sidePanel.width, layout.sidePanel.height)
doc.rect(
layout.titleBlock.x,
layout.titleBlock.y,
layout.titleBlock.width,
layout.titleBlock.height,
)
drawSheetTitleBlock(doc, layout, composition)
drawNorthArrow(doc, layout.planBox.x + layout.planBox.width - 26, layout.planBox.y + 38)
drawGraphicScale(doc, layout.planBox.x + 18, layout.planBox.y + layout.planBox.height - 22, {
scale: composition.scale,
maxWidth: Math.min(150, layout.planBox.width * 0.3),
})
drawSheetDocumentMarkers(doc, composition)
drawKeyedNoteSymbols(doc, composition)
return drawSheetSidePanel(doc, layout.sidePanel, composition, schedules)
}
function drawSheetDocumentMarkers(doc: FloorplanPdfDocument, composition: SheetComposition): void {
if (composition.documentMarkers.length === 0) return
doc.setDrawColor('#111827')
doc.setTextColor('#111827')
doc.setLineWidth(0.7)
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
for (const marker of composition.documentMarkers) {
const x = marker.x * 72
const y = marker.y * 72
const end =
marker.endX !== null && marker.endY !== null
? { x: marker.endX * 72, y: marker.endY * 72 }
: null
switch (marker.kind) {
case 'wall-tag':
case 'glazing-tag':
case 'assembly-tag':
drawTagMarker(doc, marker, x, y)
break
case 'section-callout':
case 'elevation-callout':
case 'detail-reference':
drawCalloutMarker(doc, marker, x, y, end)
break
case 'delta-marker':
drawDeltaMarker(doc, marker, x, y)
break
case 'revision-cloud':
drawRevisionCloudMarker(doc, marker, x, y)
break
}
}
}
function drawTagMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const width = Math.max(20, marker.label.length * 5 + 10)
const height = 14
if (marker.kind === 'glazing-tag') {
doc.roundedRect(x - width / 2, y - height / 2, width, height, 2, 2)
} else if (marker.kind === 'assembly-tag') {
doc.rect(x - width / 2, y - height / 2, width, height)
} else {
doc.circle(x, y, Math.max(7, width / 2))
}
doc.text(marker.label, x, y + 2.4, { align: 'center' })
}
function drawCalloutMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
end: { x: number; y: number } | null,
) {
if (end) doc.line(x, y, end.x, end.y)
doc.circle(x, y, 8)
doc.line(x - 8, y, x + 8, y)
doc.text(marker.label, x, y - 1.8, { align: 'center' })
const reference = [marker.drawingReference, marker.sheetReference].filter(Boolean).join('/')
if (reference) {
doc.setFont('helvetica', 'normal')
doc.text(reference, x, y + 6, { align: 'center' })
doc.setFont('helvetica', 'bold')
}
}
function drawDeltaMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const radius = 8
const points = [
[x, y - radius],
[x + radius * 0.87, y + radius / 2],
[x - radius * 0.87, y + radius / 2],
] as const
doc.triangle(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1])
doc.text(marker.revisionId || marker.label, x, y + 3, { align: 'center' })
}
function drawRevisionCloudMarker(
doc: FloorplanPdfDocument,
marker: ResolvedDocumentMarker,
x: number,
y: number,
) {
const points: [number, number][] | null =
marker.points.length >= 3 ? marker.points.map((point) => [point.x * 72, point.y * 72]) : null
if (points) {
for (let index = 0; index < points.length; index += 1) {
const current = points[index]!
const next = points[(index + 1) % points.length]!
const [x1, y1] = current
const [x2, y2] = next
doc.line(x1, y1, x2, y2)
}
} else {
doc.roundedRect(x - 28, y - 16, 56, 32, 8, 8)
}
if (marker.revisionId) drawDeltaMarker(doc, marker, x, y)
}
function drawKeyedNoteSymbols(doc: FloorplanPdfDocument, composition: SheetComposition): void {
if (composition.keyedNoteInstances.length === 0) return
doc.setDrawColor('#111827')
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
for (const instance of composition.keyedNoteInstances) {
const x = instance.x * 72
const y = instance.y * 72
doc.circle(x, y, 6)
doc.text(instance.key, x, y + 2.4, { align: 'center' })
}
}
function drawSheetTitleBlock(
doc: FloorplanPdfDocument,
layout: SheetExportLayout,
composition: SheetComposition,
) {
const title = layout.titleBlock
const sheetNumberWidth = 86
const drawingRefWidth = 72
doc.setDrawColor('#cbd5e1')
doc.line(
title.x + title.width - sheetNumberWidth,
title.y,
title.x + title.width - sheetNumberWidth,
title.y + title.height,
)
doc.line(
title.x + title.width - sheetNumberWidth - drawingRefWidth,
title.y,
title.x + title.width - sheetNumberWidth - drawingRefWidth,
title.y + title.height,
)
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.text(composition.viewTitle.toLocaleUpperCase(), title.x + 10, title.y + 16)
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.text(`Scale: ${formatDrawingScaleLabel(composition.scale)}`, title.x + 10, title.y + 30)
doc.text(
`Drawing: ${composition.drawingNumber}`,
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
title.y + 16,
)
doc.text(
composition.drawingLabel,
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
title.y + 30,
)
doc.setFont('helvetica', 'bold')
doc.setFontSize(15)
doc.text(composition.sheetNumber, title.x + title.width - sheetNumberWidth + 10, title.y + 25)
doc.setFontSize(7)
doc.text(
composition.sheetTitle.toLocaleUpperCase(),
title.x + title.width - sheetNumberWidth + 10,
title.y + 36,
{
maxWidth: sheetNumberWidth - 20,
},
)
}
function drawNorthArrow(doc: FloorplanPdfDocument, x: number, y: number) {
doc.setDrawColor('#111827')
doc.setFillColor('#111827')
doc.setLineWidth(0.6)
doc.triangle(x, y - 24, x - 6, y - 5, x + 6, y - 5, 'F')
doc.line(x, y - 5, x, y + 14)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.text('N', x, y - 28, { align: 'center' })
}
export function resolveGraphicScaleLength(
scale: DrawingSheetScale,
maxWidthPt: number,
): { modelMeters: number; widthPt: number; label: string } {
const pointsPerMeter = pointsPerMeterForDrawingScale(scale)
const maxMeters = Math.max(0.1, maxWidthPt / pointsPerMeter)
const candidates = [50, 20, 10, 5, 2, 1, 0.5, 0.25]
const modelMeters = candidates.find((candidate) => candidate <= maxMeters) ?? 0.1
return {
modelMeters,
widthPt: modelMeters * pointsPerMeter,
label: `${modelMeters >= 1 ? modelMeters : modelMeters * 1000}${modelMeters >= 1 ? ' m' : ' mm'}`,
}
}
function drawGraphicScale(
doc: FloorplanPdfDocument,
x: number,
y: number,
options: { scale: DrawingSheetScale; maxWidth: number },
) {
const resolved = resolveGraphicScaleLength(options.scale, options.maxWidth)
const half = resolved.widthPt / 2
doc.setDrawColor('#111827')
doc.setFillColor('#111827')
doc.setLineWidth(0.6)
doc.rect(x, y, half, 5, 'F')
doc.rect(x + half, y, half, 5)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.text('0', x, y + 15, { align: 'center' })
doc.text(resolved.label, x + resolved.widthPt, y + 15, { align: 'center' })
doc.text(formatDrawingScaleLabel(options.scale), x + resolved.widthPt / 2, y - 4, {
align: 'center',
})
}
function drawSheetSidePanel(
doc: FloorplanPdfDocument,
panel: SheetExportLayout['sidePanel'],
composition: SheetComposition,
schedules: readonly FloorplanSchedule[],
): ScheduleDrawResult {
let y = panel.y + 12
const left = panel.x + 8
const width = panel.width - 16
const bottom = panel.y + panel.height - 8
y = drawSheetNotes(doc, 'GENERAL NOTES', composition.generalNotes, left, y, width, bottom)
y = drawKeyedNoteLegend(doc, composition, left, y + 8, width, bottom)
return drawInlineSchedules(doc, schedules, left, y + 8, width, bottom)
}
function drawSheetNotes(
doc: FloorplanPdfDocument,
title: string,
notes: readonly { number: number; text: string }[],
x: number,
y: number,
width: number,
bottom: number,
) {
if (notes.length === 0) return y
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text(title, x, y)
y += 9
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
for (const note of notes) {
const lines = doc.splitTextToSize(`${note.number}. ${note.text}`, width)
if (y + lines.length * 8 > bottom) break
doc.text(lines, x, y)
y += lines.length * 8 + 3
}
return y
}
function drawKeyedNoteLegend(
doc: FloorplanPdfDocument,
composition: SheetComposition,
x: number,
y: number,
width: number,
bottom: number,
) {
if (composition.keyedNoteLegend.length === 0) return y
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text('KEYED NOTES', x, y)
y += 9
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
for (const note of composition.keyedNoteLegend) {
const lines = doc.splitTextToSize(`${note.key}. ${note.text}`, width)
if (y + lines.length * 8 > bottom) break
doc.text(lines, x, y)
y += lines.length * 8 + 3
}
return y
}
function drawInlineSchedules(
doc: FloorplanPdfDocument,
schedules: readonly FloorplanSchedule[],
x: number,
y: number,
width: number,
bottom: number,
): ScheduleDrawResult {
const overflowSchedules: FloorplanSchedule[] = []
let drawnSchedules = 0
for (const schedule of schedules) {
const rowHeight = 12
const tableHeight = 18 + rowHeight * Math.min(schedule.rows.length, 6)
if (y + tableHeight > bottom) {
overflowSchedules.push(schedule)
continue
}
drawnSchedules++
doc.setTextColor('#111827')
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.text(schedule.title.toLocaleUpperCase(), x, y)
y += 10
const widths = scheduleColumnWidths(schedule, width)
doc.setFillColor('#334155')
doc.rect(x, y, width, rowHeight, 'F')
doc.setTextColor('#ffffff')
doc.setFontSize(6)
let colX = x
schedule.columns.forEach((column, index) => {
doc.text(column.label, colX + 2, y + 8, { maxWidth: Math.max(0, (widths[index] ?? 0) - 4) })
colX += widths[index] ?? 0
})
y += rowHeight
doc.setFont('helvetica', 'normal')
doc.setTextColor('#111827')
const inlineRows = schedule.rows.slice(0, 6)
for (const row of inlineRows) {
colX = x
schedule.columns.forEach((column, index) => {
const colWidth = widths[index] ?? 0
doc.text(
truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, colWidth - 4)),
colX + 2,
y + 8,
)
colX += colWidth
})
doc.setDrawColor('#cbd5e1')
doc.rect(x, y, width, rowHeight)
y += rowHeight
}
if (schedule.rows.length > inlineRows.length) {
overflowSchedules.push({
...schedule,
title: `${schedule.title} Continued`,
rows: schedule.rows.slice(inlineRows.length),
})
}
y += 12
}
return { drawnSchedules, overflowSchedules }
}
type MountedFloorplan = { type MountedFloorplan = {
svg: SVGSVGElement svg: SVGSVGElement
annotationLabelShifts: readonly FloorplanPoint[] annotationLabelShifts: readonly FloorplanPoint[]
@@ -1241,9 +546,10 @@ export function resolveFloorplanScreenUnitsPerPixel(
} }
export function resolveFloorplanExportAnnotationVisibility( export function resolveFloorplanExportAnnotationVisibility(
mode: FloorplanMode,
liveVisibility: FloorplanAnnotationVisibility, liveVisibility: FloorplanAnnotationVisibility,
): FloorplanAnnotationVisibility { ): FloorplanAnnotationVisibility {
return { ...liveVisibility } return resolveFloorplanAnnotationVisibility(mode, liveVisibility, { target: 'export' })
} }
export function resolveFloorplanExportRotationDeg( export function resolveFloorplanExportRotationDeg(
@@ -1257,39 +563,6 @@ export function resolveFloorplanExportRotationDeg(
return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI
} }
export function pointsPerMeterForDrawingScale(scale: DrawingSheetScale): number {
if (scale.startsWith('1:')) {
const denominator = Number.parseFloat(scale.slice(2))
if (Number.isFinite(denominator) && denominator > 0) {
return POINTS_PER_INCH / METERS_PER_INCH / denominator
}
}
const imperial = scale.match(/^(.+)"=1'-0"$/)
if (imperial) {
const paperInchesPerFoot = parseImperialPaperInches(imperial[1] ?? '')
if (paperInchesPerFoot > 0) {
return (paperInchesPerFoot / 12) * (POINTS_PER_INCH / METERS_PER_INCH)
}
}
return pointsPerMeterForDrawingScale('1/4"=1\'-0"')
}
function parseImperialPaperInches(value: string): number {
const trimmed = value.trim()
if (trimmed.includes('/')) {
const [numerator, denominator] = trimmed.split('/').map((part) => Number.parseFloat(part))
return numerator && denominator ? numerator / denominator : 0
}
const parsed = Number.parseFloat(trimmed)
return Number.isFinite(parsed) ? parsed : 0
}
function formatDrawingScaleLabel(scale: DrawingSheetScale): string {
return scale.replace('=', ' = ')
}
async function mountFloorplanSvg( async function mountFloorplanSvg(
parent: HTMLElement, parent: HTMLElement,
geometries: ExportGeometry[], geometries: ExportGeometry[],
@@ -1456,6 +729,7 @@ function collectFloorplanGeometry(
metricNotation: FloorplanMetricNotation, metricNotation: FloorplanMetricNotation,
annotationVisibility: FloorplanAnnotationVisibility, annotationVisibility: FloorplanAnnotationVisibility,
drawingType: ConstructionDrawingType, drawingType: ConstructionDrawingType,
wallDimensionReference: FloorplanWallDimensionReference,
): ExportGeometry[] { ): ExportGeometry[] {
const noLiveOverrides = new Map<string, LiveNodeOverrides>() const noLiveOverrides = new Map<string, LiveNodeOverrides>()
const levelNodeIdsByType = new Map<string, AnyNodeId[]>() const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
@@ -1521,7 +795,12 @@ function collectFloorplanGeometry(
const baseContext = buildContext( const baseContext = buildContext(
node, node,
nodes, nodes,
resolveFloorplanExportViewState(unit, metricNotation), resolveFloorplanExportViewState(
unit,
metricNotation,
wallDimensionReference,
annotationVisibility.automaticDimensions,
),
levelData, levelData,
) )
const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
@@ -34,4 +34,10 @@ describe('floor-plan context extensions', () => {
expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces') expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces') expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
}) })
test('lets presentation code suppress automatic annotation construction', () => {
expect(readFloorplanContext(context()).automaticDimensions).toBe(true)
const extensions = createFloorplanContextExtensions({ automaticDimensions: false })
expect(readFloorplanContext(context(extensions)).automaticDimensions).toBe(false)
})
}) })
@@ -2,7 +2,6 @@ import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
ConstructionDrawingType, ConstructionDrawingType,
DrawingSheetNode,
FloorplanGeometry, FloorplanGeometry,
GeometryContext, GeometryContext,
NodeDefinition, NodeDefinition,
@@ -16,10 +15,12 @@ export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
export type FloorplanRenderPurpose = 'edit' | 'document' export type FloorplanRenderPurpose = 'edit' | 'document'
export type FloorplanMetricNotation = 'meters' | 'millimeters' export type FloorplanMetricNotation = 'meters' | 'millimeters'
export type FloorplanToolMode = 'default' | 'expert'
export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces' export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces' export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
export type FloorplanAnnotationRole = export type FloorplanAnnotationRole =
| 'automatic-dimension' | 'automatic-dimension'
| 'contextual-dimension'
| 'manual-dimension' | 'manual-dimension'
| 'measurement' | 'measurement'
| 'opening-mark' | 'opening-mark'
@@ -56,15 +57,13 @@ export type FloorplanToolContext = {
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = { export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }> tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
availableModes?: readonly FloorplanToolMode[]
preferredView?: '2d' | '3d' preferredView?: '2d' | '3d'
referencedSelectionAnnotationRole?: FloorplanAnnotationRole
contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
actionMenu?: { actionMenu?: {
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
} }
resolveDrawingSheet?: (args: {
node: N
levelId: AnyNodeId
drawingType: ConstructionDrawingType
}) => DrawingSheetNode | null
schedule?: (args: { schedule?: (args: {
siblings: ReadonlyArray<N> siblings: ReadonlyArray<N>
nodes: Readonly<Record<string, AnyNode>> nodes: Readonly<Record<string, AnyNode>>
@@ -82,9 +81,11 @@ export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
type FloorplanGeometryMetadata = { type FloorplanGeometryMetadata = {
annotationRole?: FloorplanAnnotationRole annotationRole?: FloorplanAnnotationRole
annotationObstacle?: 'bounds' | 'outline' annotationObstacle?: 'bounds' | 'outline'
renderPass?: 'overlay'
} }
type FloorplanContextExtension = { type FloorplanContextExtension = {
automaticDimensions: boolean
purpose: FloorplanRenderPurpose purpose: FloorplanRenderPurpose
metricNotation: FloorplanMetricNotation metricNotation: FloorplanMetricNotation
wallDimensionReference: FloorplanWallDimensionReference wallDimensionReference: FloorplanWallDimensionReference
@@ -137,6 +138,7 @@ export function createFloorplanContextExtensions(
): Readonly<Record<string, unknown>> { ): Readonly<Record<string, unknown>> {
return { return {
[FLOORPLAN_CONTEXT_EXTENSION_KEY]: { [FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
automaticDimensions: values.automaticDimensions !== false,
purpose: values.purpose === 'document' ? 'document' : 'edit', purpose: values.purpose === 'document' ? 'document' : 'edit',
metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters', metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference( wallDimensionReference: normalizeFloorplanWallDimensionReference(
@@ -151,6 +153,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
if (value && typeof value === 'object' && !Array.isArray(value)) { if (value && typeof value === 'object' && !Array.isArray(value)) {
const extension = value as Partial<FloorplanContextExtension> const extension = value as Partial<FloorplanContextExtension>
return { return {
automaticDimensions: extension.automaticDimensions !== false,
purpose: extension.purpose === 'document' ? 'document' : 'edit', purpose: extension.purpose === 'document' ? 'document' : 'edit',
metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters', metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
wallDimensionReference: normalizeFloorplanWallDimensionReference( wallDimensionReference: normalizeFloorplanWallDimensionReference(
@@ -159,6 +162,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
} }
} }
return { return {
automaticDimensions: true,
purpose: 'edit', purpose: 'edit',
metricNotation: 'meters', metricNotation: 'meters',
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE, wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test'
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
import {
isFloorplanToolAvailableInMode,
normalizeFloorplanMode,
normalizeFloorplanModesByProject,
resolveFloorplanAnnotationVisibility,
resolveFloorplanWallDimensionReference,
} from './floorplan-mode'
describe('floor-plan mode', () => {
test('defaults unknown and missing values to Default', () => {
expect(normalizeFloorplanMode(undefined)).toBe('default')
expect(normalizeFloorplanMode('legacy')).toBe('default')
expect(normalizeFloorplanMode('expert')).toBe('expert')
})
test('keeps only valid per-project preferences', () => {
expect(
normalizeFloorplanModesByProject({
alpha: 'expert',
beta: 'default',
invalid: 'legacy',
empty: null,
}),
).toEqual({ alpha: 'expert', beta: 'default' })
})
test('limits tools only when their registry extension declares available modes', () => {
expect(isFloorplanToolAvailableInMode(undefined, 'default')).toBe(true)
expect(isFloorplanToolAvailableInMode(['expert'], 'default')).toBe(false)
expect(isFloorplanToolAvailableInMode(['expert'], 'expert')).toBe(true)
expect(isFloorplanToolAvailableInMode(['default', 'expert'], 'default')).toBe(true)
})
test('preserves the existing annotation profile in Expert', () => {
const expertVisibility = {
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
openingMarks: false,
roomLabels: false,
}
expect(
resolveFloorplanAnnotationVisibility('expert', expertVisibility, {
selected: false,
target: 'editor',
}),
).toBe(expertVisibility)
})
test('keeps Default clean until an item is selected', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: false,
target: 'editor',
}),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: true,
target: 'editor',
}),
).toMatchObject({
automaticDimensions: false,
contextualDimensions: true,
manualDimensions: true,
measurements: true,
})
})
test('reveals a measurement when a referenced object is selected', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
referencedAnnotationRole: 'measurement',
target: 'editor',
}).measurements,
).toBe(true)
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
referencedAnnotationRole: 'manual-dimension',
target: 'editor',
}).manualDimensions,
).toBe(true)
})
test('exports Default without selection chrome or technical annotations', () => {
expect(
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
selected: true,
target: 'export',
}),
).toEqual({
automaticDimensions: false,
contextualDimensions: false,
manualDimensions: false,
measurements: false,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
})
})
test('uses centerline dimensions only in Default', () => {
expect(resolveFloorplanWallDimensionReference('default', 'finished-faces')).toBe('centerline')
expect(resolveFloorplanWallDimensionReference('expert', 'finished-faces')).toBe(
'finished-faces',
)
})
})
@@ -0,0 +1,73 @@
import {
type FloorplanAnnotationVisibility,
revealFloorplanAnnotationRole,
} from './annotation-visibility'
import type {
FloorplanAnnotationRole,
FloorplanToolMode,
FloorplanWallDimensionReference,
} from './floorplan-extension'
export const FLOORPLAN_MODES = ['default', 'expert'] as const
export type FloorplanMode = (typeof FLOORPLAN_MODES)[number]
export const DEFAULT_FLOORPLAN_MODE: FloorplanMode = 'default'
export type FloorplanPresentationContext = {
referencedAnnotationRole?: FloorplanAnnotationRole
selected?: boolean
target: 'editor' | 'export'
}
export function normalizeFloorplanMode(value: unknown): FloorplanMode {
return value === 'expert' ? 'expert' : DEFAULT_FLOORPLAN_MODE
}
export function normalizeFloorplanModesByProject(value: unknown): Record<string, FloorplanMode> {
if (!value || typeof value !== 'object') return {}
const modes: Record<string, FloorplanMode> = {}
for (const [projectId, mode] of Object.entries(value)) {
if (!projectId || (mode !== 'default' && mode !== 'expert')) continue
modes[projectId] = mode
}
return modes
}
export function isFloorplanToolAvailableInMode(
availableModes: readonly FloorplanToolMode[] | undefined,
mode: FloorplanMode,
): boolean {
return availableModes === undefined || availableModes.includes(mode)
}
export function resolveFloorplanAnnotationVisibility(
mode: FloorplanMode,
expertVisibility: FloorplanAnnotationVisibility,
context: FloorplanPresentationContext,
): FloorplanAnnotationVisibility {
if (mode === 'expert') return expertVisibility
const interactive = context.target === 'editor'
const selected = interactive && context.selected === true
const visibility: FloorplanAnnotationVisibility = {
automaticDimensions: false,
contextualDimensions: selected,
manualDimensions: selected,
measurements: selected,
openingMarks: false,
structuralGrids: false,
roomLabels: true,
stairAnnotations: false,
}
return interactive && context.referencedAnnotationRole
? revealFloorplanAnnotationRole(visibility, context.referencedAnnotationRole)
: visibility
}
export function resolveFloorplanWallDimensionReference(
mode: FloorplanMode,
expertReference: FloorplanWallDimensionReference,
): FloorplanWallDimensionReference {
return mode === 'default' ? 'centerline' : expertReference
}
@@ -1,271 +0,0 @@
import type {
CeilingNode,
ColumnNode,
DoorNode,
ElevatorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import {
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
} from './geometry'
import type { FloorplanSelectionBounds } from './types'
type OpeningNode = WindowNode | DoorNode
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
}
type ItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type StairEntry = {
hitPolygons: Point2D[][]
stair: StairNode
segments: Array<{ polygon: Point2D[] }>
}
type WallEntry = {
wall: WallNode
polygon: Point2D[]
}
type SlabEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
}
type CeilingEntry = {
ceiling: CeilingNode
polygon: Point2D[]
holes: Point2D[][]
}
type ColumnEntry = {
column: ColumnNode
polygon: Point2D[]
}
type ElevatorEntry = {
elevator: ElevatorNode
polygon: Point2D[]
}
type RoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanSelectionToolContext = {
point: Point2D
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
openings: OpeningPolygonEntry[]
stairs: StairEntry[]
walls: WallEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
elevators: ElevatorEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
}
function getItemHitId(context: FloorplanSelectionToolContext) {
if (!context.isItemContextActive) {
return null
}
const itemHit = context.items.find(({ polygon }) => isPointInsidePolygon(context.point, polygon))
return itemHit?.item.id ?? null
}
function getStairHitPolygons(stair: StairEntry) {
return stair.hitPolygons.length > 0
? stair.hitPolygons
: stair.segments.map(({ polygon }) => polygon)
}
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
if (context.phase === 'structure') {
const openingHit = context.openings.find(({ polygon }) => {
if (isPointInsidePolygon(context.point, polygon)) {
return true
}
const centerLine = context.getOpeningCenterLine(polygon)
if (!centerLine) {
return false
}
return (
getDistanceToWallSegment(
context.point,
[centerLine.start.x, centerLine.start.y],
[centerLine.end.x, centerLine.end.y],
) <= context.openingHitTolerance
)
})
if (openingHit) {
return openingHit.opening.id
}
const stairHit = context.stairs.find((stair) =>
getStairHitPolygons(stair).some((polygon) => isPointInsidePolygon(context.point, polygon)),
)
if (stairHit) {
return stairHit.stair.id
}
const elevatorHit = context.elevators.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (elevatorHit) {
return elevatorHit.elevator.id
}
const columnHit = context.columns.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (columnHit) {
return columnHit.column.id
}
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
getDistanceToWallSegment(context.point, wall.start, wall.end) <= context.wallHitTolerance,
)
if (wallHit) {
return wallHit.wall.id
}
const roofHit = context.roofs.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
)
if (roofHit) {
return roofHit.roof.id
}
const ceilingHit = context.ceilings.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (ceilingHit) {
return ceilingHit.ceiling.id
}
const slabHit = context.slabs.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (slabHit) {
return slabHit.slab.id
}
}
return getItemHitId(context)
}
type FloorplanSelectionBoundsContext = {
bounds: FloorplanSelectionBounds
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
walls: WallEntry[]
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
elevators: ElevatorEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
export function getFloorplanSelectionIdsInBounds({
bounds,
phase,
isItemContextActive,
items,
walls,
openings,
slabs,
ceilings,
columns,
elevators,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
const itemIds = isItemContextActive
? items
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ item }) => item.id)
: []
if (phase !== 'structure') {
return itemIds
}
const wallIds = walls
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ wall }) => wall.id)
const openingIds = openings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ opening }) => opening.id)
const slabIds = slabs
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id)
const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id)
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
const elevatorIds = elevators
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ elevator }) => elevator.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
doesPolygonIntersectSelectionBounds(polygon, bounds),
),
)
.map(({ stair }) => stair.id)
const roofIds = roofs
.filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
)
.map(({ roof }) => roof.id)
return Array.from(
new Set([
...itemIds,
...wallIds,
...openingIds,
...slabIds,
...ceilingIds,
...columnIds,
...elevatorIds,
...stairIds,
...roofIds,
]),
)
}
@@ -13,7 +13,12 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { z } from 'zod' import { z } from 'zod'
import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement' import {
commitFreshPlacementSubtree,
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
prepareFreshPlacementRootDuplicate,
} from './fresh-planar-placement'
type RafFn = (cb: (time: number) => void) => number type RafFn = (cb: (time: number) => void) => number
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
@@ -212,6 +217,33 @@ describe('commitFreshPlacementSubtree', () => {
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([]) expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
}) })
test('uses the subtree contract for childless variants and never aliases root-only children', () => {
registerCabinetClonePrepTestKind()
const childlessCabinet = {
...shelf(),
type: 'cabinet',
children: [],
metadata: { isTransient: true, label: 'source' },
} as AnyNode
expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)
const source = {
...shelf(),
children: ['item_original' as AnyNodeId],
metadata: { isTransient: true, label: 'source' },
} as AnyNode
const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
children: AnyNodeId[]
id?: AnyNodeId
metadata?: Record<string, unknown>
}
expect(duplicate.id).toBeUndefined()
expect(duplicate.children).toEqual([])
expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
})
test('commits a duplicated cabinet draft without deleting the original modules', () => { test('commits a duplicated cabinet draft without deleting the original modules', () => {
seedCabinetRun() seedCabinetRun()
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
@@ -27,10 +27,27 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
} }
export function duplicatesAsFreshSubtree(node: AnyNode): boolean { export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
const children = (node as { children?: unknown }).children return duplicableConfigFor(node)?.subtree === true
return ( }
duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
) /**
* Prepares a non-subtree duplicate without retaining ownership of the
* original node's children. Subtree-capable kinds take the path above and
* receive fresh descendant IDs; every other kind duplicates only its root.
*/
export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
id?: AnyNodeId
children?: unknown
metadata?: unknown
}
delete duplicate.id
if (Array.isArray(duplicate.children)) duplicate.children = []
duplicate.metadata = {
...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
isNew: true,
}
return duplicate as unknown as AnyNode
} }
/** /**
@@ -17,7 +17,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [
{ kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' }, { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' },
{ kind: 'handle-drag', nodeId: 'w1', handle: 'height' }, { kind: 'handle-drag', nodeId: 'w1', handle: 'height' },
{ kind: 'drafting', tool: 'wall' }, { kind: 'drafting', tool: 'wall' },
{ kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 }, { kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 },
{ kind: 'box-select' }, { kind: 'box-select' },
{ kind: 'painting' }, { kind: 'painting' },
] ]
+33 -8
View File
@@ -13,6 +13,7 @@
import type { AnyNode } from '@pascal-app/core' import type { AnyNode } from '@pascal-app/core'
export type InteractionView = '2d' | '3d' export type InteractionView = '2d' | '3d'
export type ReshapeDriver = 'tool' | 'floorplan'
// Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one
// node, one in-flight reshape. Grouping them as sub-states of `reshaping` // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
@@ -47,6 +48,8 @@ export type InteractionScope =
kind: 'reshaping' kind: 'reshaping'
nodeId: string nodeId: string
reshape: ReshapeKind reshape: ReshapeKind
// Floorplan affordances own their commit and must not mount a second 3D reshape tool.
driver: ReshapeDriver
holeIndex?: number holeIndex?: number
endpoint?: 'start' | 'end' endpoint?: 'start' | 'end'
index?: number index?: number
@@ -126,12 +129,14 @@ export function editingHoleInfo(
export function holeEditScope(target: { export function holeEditScope(target: {
nodeId: string nodeId: string
holeIndex: number holeIndex: number
driver?: ReshapeDriver
}): ActiveInteractionScope { }): ActiveInteractionScope {
return { return {
kind: 'reshaping', kind: 'reshaping',
nodeId: target.nodeId, nodeId: target.nodeId,
reshape: 'hole', reshape: 'hole',
holeIndex: target.holeIndex, holeIndex: target.holeIndex,
driver: target.driver ?? 'tool',
} }
} }
@@ -142,6 +147,14 @@ export function isCurveReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.reshape === 'curve' return scope.kind === 'reshaping' && scope.reshape === 'curve'
} }
export function isToolDrivenReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.driver === 'tool'
}
export function isFloorplanDrivenReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.driver === 'floorplan'
}
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node // The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for // itself (consumers fetch the node from the scene by `nodeId`; it is stable for
// the duration of the drag). // the duration of the drag).
@@ -181,31 +194,43 @@ export function reshapingNodeId(scope: InteractionScope): string | null {
} }
// Builders so producers don't re-spell the discriminator at every call site. // Builders so producers don't re-spell the discriminator at every call site.
export function curveReshapeScope(nodeId: string): ActiveInteractionScope { export function curveReshapeScope(
return { kind: 'reshaping', nodeId, reshape: 'curve' } nodeId: string,
driver: ReshapeDriver = 'tool',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'curve', driver }
} }
export function endpointReshapeScope( export function endpointReshapeScope(
nodeId: string, nodeId: string,
endpoint: 'start' | 'end', endpoint: 'start' | 'end',
driver: ReshapeDriver = 'tool',
): ActiveInteractionScope { ): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver }
} }
export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope { export function controlPointReshapeScope(
return { kind: 'reshaping', nodeId, reshape: 'control-point', index } nodeId: string,
index: number,
driver: ReshapeDriver = 'tool',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver }
} }
export function tangentReshapeScope( export function tangentReshapeScope(
nodeId: string, nodeId: string,
index: number, index: number,
side: 'in' | 'out', side: 'in' | 'out',
driver: ReshapeDriver = 'tool',
): ActiveInteractionScope { ): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side } return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver }
} }
// Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping
// HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen. // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen.
export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope { export function boundaryReshapeScope(
return { kind: 'reshaping', nodeId, reshape: 'boundary' } nodeId: string,
driver: ReshapeDriver = 'tool',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'boundary', driver }
} }
+130 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from 'bun:test' import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
@@ -8,14 +8,18 @@ import {
type CabinetNode as CabinetNodeType, type CabinetNode as CabinetNodeType,
type LevelNode, type LevelNode,
MeasurementNode, MeasurementNode,
SceneMaterial,
type SceneMaterialId,
useScene, useScene,
WallNode, WallNode,
WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { import {
copySelectedNodesToEditorClipboard, copySelectedNodesToEditorClipboard,
getEditorClipboardSnapshot, getEditorClipboardSnapshot,
pasteEditorClipboardToLevel, pasteEditorClipboardToLevel,
pasteSystemEditorClipboardToLevel,
} from './scene-clipboard' } from './scene-clipboard'
const sourceLevelId = 'level_clipboard-source' as LevelNode['id'] const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
@@ -77,6 +81,7 @@ function seedCabinetRun() {
[leftModule.id]: leftModule as AnyNode, [leftModule.id]: leftModule as AnyNode,
[rightModule.id]: rightModule as AnyNode, [rightModule.id]: rightModule as AnyNode,
}, },
materials: {},
rootNodeIds: [sourceLevel.id, targetLevel.id], rootNodeIds: [sourceLevel.id, targetLevel.id],
} as never) } as never)
useViewer.getState().setSelection({ useViewer.getState().setSelection({
@@ -193,4 +198,128 @@ describe('scene clipboard', () => {
expect(Array.isArray(anchor)).toBe(false) expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id) if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
}) })
test('detaches a standalone copied opening so its move tool can rehost it', () => {
const wall = WallNode.parse({
id: 'wall_clipboard-window-host',
parentId: sourceLevelId,
start: [0, 0],
end: [3, 0],
})
const window = WindowNode.parse({
id: 'window_clipboard-standalone',
parentId: wall.id,
wallId: wall.id,
position: [1.5, 1.2, 0],
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
[wall.id]: { ...wall, children: [window.id] },
[window.id]: window,
},
}))
expect(copySelectedNodesToEditorClipboard([window.id])).toBe(true)
const result = pasteEditorClipboardToLevel(targetLevelId)
expect(result?.pastedIds).toHaveLength(1)
const pastedWindow = result?.pastedIds[0]
? useScene.getState().nodes[result.pastedIds[0]]
: undefined
expect(pastedWindow?.type).toBe('window')
if (pastedWindow?.type === 'window') {
expect(pastedWindow.parentId).toBe(targetLevelId)
expect(pastedWindow.wallId).toBeUndefined()
expect(pastedWindow.roofSegmentId).toBeUndefined()
}
})
test('round-trips nodes and custom scene materials through the browser clipboard', async () => {
let systemClipboardText = ''
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText: async () => systemClipboardText,
writeText: async (text: string) => {
systemClipboardText = text
},
},
})
const materialId = 'mat_clipboard-blue' as SceneMaterialId
const material = SceneMaterial.parse({
id: materialId,
name: 'Clipboard blue',
material: { properties: { color: '#2266dd' } },
})
const wall = WallNode.parse({
id: 'wall_clipboard-material',
parentId: sourceLevelId,
start: [0, 0],
end: [3, 0],
slots: { exterior: `scene:${materialId}` },
})
useScene.setState((state) => ({
materials: { [materialId]: material },
nodes: {
...state.nodes,
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id]),
[wall.id]: wall,
},
}))
expect(copySelectedNodesToEditorClipboard([wall.id])).toBe(true)
expect(systemClipboardText).toContain('pascal.scene-nodes')
useScene.setState({
materials: {},
nodes: {
[targetLevelId]: makeLevel(targetLevelId),
},
rootNodeIds: [targetLevelId],
} as never)
useViewer.getState().setSelection({ levelId: targetLevelId, selectedIds: [] })
const result = await pasteSystemEditorClipboardToLevel()
expect(result?.pastedIds).toHaveLength(1)
expect(result?.createdMaterialIds).toEqual([materialId])
expect(useScene.getState().materials[materialId]).toEqual(material)
const pastedWall = Object.values(useScene.getState().nodes).find((node) => node.type === 'wall')
expect(pastedWall?.type).toBe('wall')
if (pastedWall?.type === 'wall') {
expect(pastedWall.slots?.exterior).toBe(`scene:${materialId}`)
}
})
test('waits for an in-flight copy before reading the browser clipboard', async () => {
let systemClipboardText = 'older clipboard contents'
let finishWrite!: () => void
const readText = mock(async () => systemClipboardText)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
readText,
writeText: (text: string) =>
new Promise<void>((resolve) => {
finishWrite = () => {
systemClipboardText = text
resolve()
}
}),
},
})
expect(copySelectedNodesToEditorClipboard([runId])).toBe(true)
const paste = pasteSystemEditorClipboardToLevel(targetLevelId)
await Promise.resolve()
expect(readText).not.toHaveBeenCalled()
finishWrite()
const result = await paste
expect(readText).toHaveBeenCalledTimes(1)
expect(result?.pastedIds).toHaveLength(1)
})
}) })

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