@pascal-app/editor 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -0,0 +1,121 @@
1
+ import { initializeGpuRenderer } from '@pascal-app/viewer'
2
+ import type * as THREE from 'three'
3
+ import { texture as textureNode, uv } from 'three/tsl'
4
+ import { CanvasTexture, NodeMaterial, QuadMesh, WebGPURenderer } from 'three/webgpu'
5
+
6
+ /**
7
+ * GPU decompressor handed to three's GLTF/USDZ exporters for compressed (KTX2)
8
+ * textures. One instance lives for the duration of an export and is disposed
9
+ * with it.
10
+ */
11
+ export type ExportTextureUtils = {
12
+ decompress(texture: THREE.Texture, maxTextureSize?: number): Promise<THREE.Texture>
13
+ dispose(): Promise<void>
14
+ }
15
+
16
+ // The WebGL probe context initializeGpuRenderer opens on this canvas is never
17
+ // released, so one canvas serves every export instead of one per export.
18
+ let probeCanvas: HTMLCanvasElement | null | undefined
19
+
20
+ async function initializeDecompressRenderer(): Promise<WebGPURenderer> {
21
+ probeCanvas ??= typeof document === 'undefined' ? null : document.createElement('canvas')
22
+ const result = await initializeGpuRenderer({
23
+ probeCanvas,
24
+ createRenderer: (parameters) =>
25
+ new WebGPURenderer(parameters as ConstructorParameters<typeof WebGPURenderer>[0]),
26
+ })
27
+ if (result.status !== 'ready') {
28
+ throw new Error('Could not start a GPU renderer to decompress textures for export.', {
29
+ cause: result.error,
30
+ })
31
+ }
32
+ return result.renderer
33
+ }
34
+
35
+ function releaseDevice(renderer: WebGPURenderer) {
36
+ // initializeGpuRenderer hands three a caller-owned device, which
37
+ // WebGPUBackend.dispose() deliberately leaves alive.
38
+ const backend = renderer.backend as { device?: { destroy?: () => void } | null }
39
+ try {
40
+ backend.device?.destroy?.()
41
+ } catch {}
42
+ }
43
+
44
+ /**
45
+ * Mirrors three's `WebGPUTextureUtils.decompress` but keeps a single renderer
46
+ * for the whole export. The stock helper creates, initialises and disposes a
47
+ * WebGPURenderer — adapter and device request included, with no timeout — for
48
+ * every compressed texture it is handed, so a furnished scene paid that cost
49
+ * dozens of times and hung outright when one device request stalled. Handing
50
+ * it a renderer is not an option: it resizes whatever renderer it receives
51
+ * (the live viewer's canvas) and, in r186, throws on the first call that
52
+ * supplies one.
53
+ */
54
+ export function createExportTextureUtils(
55
+ initialize: () => Promise<WebGPURenderer> = initializeDecompressRenderer,
56
+ ): ExportTextureUtils {
57
+ let pending: Promise<WebGPURenderer> | null = null
58
+ let disposed = false
59
+ const quad = new QuadMesh()
60
+
61
+ return {
62
+ async decompress(texture, maxTextureSize = Number.POSITIVE_INFINITY) {
63
+ if (disposed) {
64
+ throw new Error('The export was abandoned before this texture was decompressed.')
65
+ }
66
+ pending ??= initialize()
67
+ const renderer = await pending
68
+
69
+ // Everything below the await is synchronous, which is what lets
70
+ // overlapping calls share `quad` and the renderer's colour space.
71
+ const image = texture.image as { width: number; height: number }
72
+ const width = Math.min(image.width, maxTextureSize)
73
+ const height = Math.min(image.height, maxTextureSize)
74
+
75
+ const material = new NodeMaterial()
76
+ material.fragmentNode = textureNode(texture, uv().flipY())
77
+ const previousColorSpace = renderer.outputColorSpace
78
+ renderer.setSize(width, height)
79
+ renderer.outputColorSpace = texture.colorSpace
80
+ quad.material = material
81
+ try {
82
+ quad.render(renderer)
83
+ } finally {
84
+ renderer.outputColorSpace = previousColorSpace
85
+ material.dispose()
86
+ }
87
+
88
+ const canvas = document.createElement('canvas')
89
+ canvas.width = width
90
+ canvas.height = height
91
+ const context = canvas.getContext('2d')
92
+ if (!context) {
93
+ throw new Error('Could not create a 2D canvas to read back a decompressed texture.')
94
+ }
95
+ context.drawImage(renderer.domElement, 0, 0, width, height)
96
+
97
+ const readable = new CanvasTexture(canvas)
98
+ readable.minFilter = texture.minFilter
99
+ readable.magFilter = texture.magFilter
100
+ readable.wrapS = texture.wrapS
101
+ readable.wrapT = texture.wrapT
102
+ readable.colorSpace = texture.colorSpace
103
+ readable.name = texture.name
104
+ return readable
105
+ },
106
+
107
+ async dispose() {
108
+ disposed = true
109
+ const started = pending
110
+ pending = null
111
+ if (!started) return
112
+ const renderer = await started.catch(() => null)
113
+ if (!renderer) return
114
+ try {
115
+ await renderer.dispose()
116
+ } finally {
117
+ releaseDevice(renderer)
118
+ }
119
+ },
120
+ }
121
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import { uniform } from 'three/tsl'
4
+ import { cloneExportUserData } from './export-user-data'
5
+
6
+ describe('cloneExportUserData', () => {
7
+ test('keeps plain data and deep-copies it', () => {
8
+ const source = {
9
+ pascalId: 'wall-1',
10
+ pascalSwingLeaf: { axis: 'y', openRotationY: 1.2 },
11
+ tags: ['a', 'b'],
12
+ offsets: new Float32Array([1, 2, 3]),
13
+ }
14
+ const copy = cloneExportUserData(source)
15
+ expect(copy).toEqual(source)
16
+ expect(copy.pascalSwingLeaf).not.toBe(source.pascalSwingLeaf)
17
+ expect(copy.tags).not.toBe(source.tags)
18
+ expect(copy.offsets).not.toBe(source.offsets)
19
+ })
20
+
21
+ test('drops functions and live three resources instead of throwing', () => {
22
+ const material = new THREE.MeshStandardMaterial()
23
+ material.addEventListener('dispose', () => {})
24
+ const source = {
25
+ pascalId: 'pool-1',
26
+ waterEffect: { material, uniforms: { time: uniform(0) }, strength: 0.5 },
27
+ mesh: new THREE.Mesh(new THREE.BoxGeometry(), material),
28
+ geometry: new THREE.BufferGeometry(),
29
+ texture: new THREE.Texture(),
30
+ onDispose: () => {},
31
+ list: [material, 'kept'],
32
+ }
33
+ expect(() => structuredClone(source)).toThrow()
34
+ expect(cloneExportUserData(source)).toEqual({
35
+ pascalId: 'pool-1',
36
+ waterEffect: { uniforms: {}, strength: 0.5 },
37
+ list: [undefined, 'kept'],
38
+ })
39
+ })
40
+
41
+ test('tolerates cycles', () => {
42
+ const source: Record<string, unknown> = { pascalId: 'x' }
43
+ source.self = source
44
+ const copy = cloneExportUserData(source)
45
+ expect(copy.pascalId).toBe('x')
46
+ expect(copy.self).toBe(copy)
47
+ })
48
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Copy `userData` for an export clone. `structuredClone` throws the moment it
3
+ * meets a function, and live scenes keep runtime resources in userData (a
4
+ * plugin's effect material, TSL uniforms, a mesh reference) whose dispose
5
+ * listeners are functions — so a plain `structuredClone(userData)` aborts the
6
+ * whole export. Exports only ever read plain data back out of userData, so
7
+ * runtime resources and functions are dropped instead of cloned.
8
+ */
9
+
10
+ type RuntimeResource = {
11
+ isObject3D?: boolean
12
+ isMaterial?: boolean
13
+ isTexture?: boolean
14
+ isBufferGeometry?: boolean
15
+ isNode?: boolean
16
+ }
17
+
18
+ function isRuntimeResource(value: object): boolean {
19
+ const flags = value as RuntimeResource
20
+ return Boolean(
21
+ flags.isObject3D ||
22
+ flags.isMaterial ||
23
+ flags.isTexture ||
24
+ flags.isBufferGeometry ||
25
+ flags.isNode,
26
+ )
27
+ }
28
+
29
+ function cloneValue(value: unknown, seen: Map<object, unknown>): unknown {
30
+ if (typeof value === 'function') return undefined
31
+ if (value === null || typeof value !== 'object') return value
32
+ if (seen.has(value)) return seen.get(value)
33
+ if (isRuntimeResource(value)) return undefined
34
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
35
+ const copy = structuredClone(value)
36
+ seen.set(value, copy)
37
+ return copy
38
+ }
39
+ if (value instanceof Date || value instanceof RegExp) return structuredClone(value)
40
+ if (Array.isArray(value)) {
41
+ const copy: unknown[] = []
42
+ seen.set(value, copy)
43
+ for (const entry of value) copy.push(cloneValue(entry, seen))
44
+ return copy
45
+ }
46
+ if (value instanceof Map) {
47
+ const copy = new Map<unknown, unknown>()
48
+ seen.set(value, copy)
49
+ for (const [key, entry] of value) copy.set(cloneValue(key, seen), cloneValue(entry, seen))
50
+ return copy
51
+ }
52
+ if (value instanceof Set) {
53
+ const copy = new Set<unknown>()
54
+ seen.set(value, copy)
55
+ for (const entry of value) copy.add(cloneValue(entry, seen))
56
+ return copy
57
+ }
58
+ const copy: Record<string, unknown> = {}
59
+ seen.set(value, copy)
60
+ for (const [key, entry] of Object.entries(value)) {
61
+ const cloned = cloneValue(entry, seen)
62
+ if (cloned !== undefined) copy[key] = cloned
63
+ }
64
+ return copy
65
+ }
66
+
67
+ export function cloneExportUserData(userData: Record<string, unknown>): Record<string, unknown> {
68
+ return cloneValue(userData, new Map()) as Record<string, unknown>
69
+ }
@@ -1,25 +1,30 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
4
  type AnyNodeDefinition,
5
+ BuildingNode,
5
6
  type FloorplanGeometry,
6
7
  type GeometryContext,
8
+ LevelNode,
7
9
  loadPlugin,
8
10
  type NodeCategory,
9
11
  nodeRegistry,
10
12
  registerNode,
11
13
  } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
12
15
  import PDFDocument from 'pdfkit'
13
16
  import { z } from 'zod'
14
17
  import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
15
18
  import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
16
19
  import {
17
20
  collectFloorplanGeometry,
21
+ collectFloorplanSchedules,
18
22
  filterFloorplanExportOverlay,
19
23
  fitPlanToBox,
20
24
  isFloorplanExportAnnotationGeometry,
21
25
  isFloorplanNodeInExportScope,
22
26
  partitionFloorplanExportOverlay,
27
+ resolveExportLevels,
23
28
  resolveFloorplanExportAnnotationVisibility,
24
29
  resolveFloorplanExportNodeGeometry,
25
30
  resolveFloorplanExportPlacement,
@@ -424,6 +429,81 @@ describe('isFloorplanNodeInExportScope', () => {
424
429
  })
425
430
  })
426
431
 
432
+ describe('collectFloorplanSchedules', () => {
433
+ test('omits non-structure schedule contributors under structure scope', () => {
434
+ const restoreRegistry = nodeRegistry._snapshot()
435
+ const structureKind = 'test:structure-schedule'
436
+ const siteKind = 'test:site-schedule'
437
+ const levelId = 'level_schedules' as AnyNode['id']
438
+ const structureNodeId = 'structure_scheduled' as AnyNode['id']
439
+ const siteNodeId = 'site_scheduled' as AnyNode['id']
440
+
441
+ const scheduleFor = (title: string) => ({
442
+ id: title.toLowerCase(),
443
+ title,
444
+ columns: [{ key: 'id', label: 'ID' }],
445
+ rows: [{ id: 'row', cells: { id: '1' } }],
446
+ })
447
+
448
+ try {
449
+ nodeRegistry._reset()
450
+ registerNode({
451
+ kind: structureKind,
452
+ schemaVersion: 1,
453
+ schema: z.object({ type: z.literal(structureKind) }) as never,
454
+ category: 'structure',
455
+ defaults: () => ({}) as never,
456
+ capabilities: {},
457
+ extensions: {
458
+ 'pascal:editor/floorplan': {
459
+ schedule: () => scheduleFor('Doors'),
460
+ },
461
+ },
462
+ } as AnyNodeDefinition)
463
+ registerNode({
464
+ kind: siteKind,
465
+ schemaVersion: 1,
466
+ schema: z.object({ type: z.literal(siteKind) }) as never,
467
+ category: 'site',
468
+ defaults: () => ({}) as never,
469
+ capabilities: {},
470
+ extensions: {
471
+ 'pascal:editor/floorplan': {
472
+ schedule: () => scheduleFor('Rooms'),
473
+ },
474
+ },
475
+ } as AnyNodeDefinition)
476
+
477
+ const nodes = {
478
+ [levelId]: {
479
+ id: levelId,
480
+ type: 'level',
481
+ visible: true,
482
+ children: [structureNodeId, siteNodeId],
483
+ },
484
+ [structureNodeId]: {
485
+ id: structureNodeId,
486
+ type: structureKind,
487
+ visible: true,
488
+ },
489
+ [siteNodeId]: {
490
+ id: siteNodeId,
491
+ type: siteKind,
492
+ visible: true,
493
+ },
494
+ } as unknown as Record<string, AnyNode>
495
+
496
+ const full = collectFloorplanSchedules(nodes, levelId, 'metric', 'full')
497
+ expect(full.map((schedule) => schedule.title).sort()).toEqual(['Doors', 'Rooms'])
498
+
499
+ const structure = collectFloorplanSchedules(nodes, levelId, 'metric', 'structure')
500
+ expect(structure.map((schedule) => schedule.title)).toEqual(['Doors'])
501
+ } finally {
502
+ restoreRegistry()
503
+ }
504
+ })
505
+ })
506
+
427
507
  describe('collectFloorplanGeometry', () => {
428
508
  test('collects the active Site once below architecture with semantic context and projection', async () => {
429
509
  const restoreRegistry = nodeRegistry._snapshot()
@@ -694,7 +774,9 @@ describe('collectFloorplanGeometry', () => {
694
774
  'finished-faces',
695
775
  [enabledPluginId],
696
776
  )
697
- expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
777
+ // A hidden Site hides only its own ground and boundary; the site-scoped
778
+ // nodes on it keep their own flag (see `hidesDescendants`).
779
+ expect(hiddenSite.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
698
780
 
699
781
  const structure = collectFloorplanGeometry(
700
782
  nodes,
@@ -713,3 +795,61 @@ describe('collectFloorplanGeometry', () => {
713
795
  }
714
796
  })
715
797
  })
798
+
799
+ describe('resolveExportLevels', () => {
800
+ const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 })
801
+ const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 })
802
+ const roof = LevelNode.parse({
803
+ id: 'level_roof',
804
+ parentId: 'building_a',
805
+ level: 2,
806
+ metadata: { role: 'roof', referenceLevelId: upper.id },
807
+ })
808
+ const attic = LevelNode.parse({
809
+ id: 'level_attic',
810
+ parentId: 'building_a',
811
+ level: 3,
812
+ metadata: { role: 'attic' },
813
+ })
814
+ const building = BuildingNode.parse({
815
+ id: 'building_a',
816
+ children: [ground.id, upper.id, roof.id, attic.id],
817
+ })
818
+ const nodes: Record<string, AnyNode> = Object.fromEntries(
819
+ [building, ground, upper, roof, attic].map((node) => [node.id, node]),
820
+ )
821
+
822
+ // The viewer store is a process-wide singleton, so an earlier test file can
823
+ // leak a selection into these tests; restore it instead of leaving ours.
824
+ const previousSelection = useViewer.getState().selection
825
+
826
+ const selectLevel = (levelId: string | null) => {
827
+ useViewer.setState({
828
+ selection: { ...previousSelection, buildingId: building.id, levelId },
829
+ } as never)
830
+ }
831
+
832
+ afterEach(() => {
833
+ useViewer.setState({ selection: previousSelection } as never)
834
+ })
835
+
836
+ test('skips a dedicated roof support level', () => {
837
+ selectLevel(ground.id)
838
+
839
+ expect(resolveExportLevels(nodes)).toEqual([
840
+ { id: ground.id, label: 'Level 0' },
841
+ { id: upper.id, label: 'Level 1' },
842
+ { id: attic.id, label: 'Level 3' },
843
+ ])
844
+ })
845
+
846
+ test('skips the roof level when it is the selected level', () => {
847
+ selectLevel(roof.id)
848
+
849
+ expect(resolveExportLevels(nodes)).toEqual([
850
+ { id: ground.id, label: 'Level 0' },
851
+ { id: upper.id, label: 'Level 1' },
852
+ { id: attic.id, label: 'Level 3' },
853
+ ])
854
+ })
855
+ })
@@ -219,7 +219,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
219
219
  wallDimensionReference,
220
220
  installedPlugins,
221
221
  )
222
- const schedules = collectFloorplanSchedules(nodes, level.id, unit)
222
+ const schedules = collectFloorplanSchedules(nodes, level.id, unit, scope)
223
223
  if (geometries.length === 0 && schedules.length === 0) continue
224
224
  const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
225
225
 
@@ -305,6 +305,7 @@ export function collectFloorplanSchedules(
305
305
  nodes: Record<string, AnyNode>,
306
306
  levelId: AnyNodeId,
307
307
  unit: 'metric' | 'imperial',
308
+ scope: FloorplanExportScope = 'full',
308
309
  ): FloorplanSchedule[] {
309
310
  const siblingsByType = new Map<string, AnyNode[]>()
310
311
  const visit = (id: AnyNodeId) => {
@@ -324,6 +325,9 @@ export function collectFloorplanSchedules(
324
325
  for (const [kind, definition] of nodeRegistry.entries()) {
325
326
  const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
326
327
  if (!scheduleContribution) continue
328
+ // Same scope gate as geometry: a structure-only PDF must not list rooms
329
+ // or other site-category contributors whose plan geometry was excluded.
330
+ if (!isFloorplanNodeInExportScope(definition, scope)) continue
327
331
  const siblings = siblingsByType.get(kind) ?? []
328
332
  const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
329
333
  if (schedule && schedule.rows.length > 0) schedules.push(schedule)
@@ -1021,9 +1025,10 @@ function combineGeometryList(
1021
1025
  * Levels to export, ordered bottom-to-top. The active building (the building
1022
1026
  * owning the selected level, or the first one found) contributes all of its
1023
1027
  * level children; if there is no building wrapper we fall back to the single
1024
- * resolved level.
1028
+ * resolved level. Roof support levels are excluded: they are not occupied
1029
+ * stories, so they do not get a floor-plan page.
1025
1030
  */
1026
- function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1031
+ export function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1027
1032
  const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
1028
1033
  const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
1029
1034
  if (!activeLevelId) return []
@@ -1038,6 +1043,7 @@ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1038
1043
  levelNodes = node ? [node] : []
1039
1044
  }
1040
1045
 
1046
+ levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof')
1041
1047
  levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
1042
1048
  return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
1043
1049
  }
@@ -57,13 +57,29 @@ export type FloorplanToolContext = {
57
57
 
58
58
  export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
59
59
  tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
60
+ directDrag?: boolean
60
61
  availableModes?: readonly FloorplanToolMode[]
61
62
  preferredView?: '2d' | '3d'
62
63
  referencedSelectionAnnotationRole?: FloorplanAnnotationRole
63
64
  contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
64
65
  actionMenu?: {
65
66
  canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
67
+ /** Buttons the kind adds to the selection's action menu (2D and 3D); each decides its own visibility. */
68
+ actions?: () => Promise<{ default: ComponentType }>
66
69
  }
70
+ /**
71
+ * Plan layers mounted while a node of this kind is in a `reshaping` scope of
72
+ * that name (the 2D sibling of `def.affordanceTools[reshape]`).
73
+ */
74
+ reshapeLayers?: Record<string, () => Promise<{ default: ComponentType<FloorplanToolContext> }>>
75
+ /**
76
+ * Nodes the plan treats as one with this node when toggling it out of a
77
+ * selection (a slab and its ceiling share an outline and a click).
78
+ */
79
+ selectionCounterparts?: (args: {
80
+ node: N
81
+ nodes: Readonly<Record<AnyNodeId, AnyNode>>
82
+ }) => AnyNodeId[]
67
83
  schedule?: (args: {
68
84
  siblings: ReadonlyArray<N>
69
85
  nodes: Readonly<Record<string, AnyNode>>
@@ -14,6 +14,8 @@ export type FloorplanViewState = {
14
14
  highlighted: boolean
15
15
  hovered: boolean
16
16
  moving: boolean
17
+ focusedUnitId?: string
18
+ focusedUnitMemberIds?: readonly string[]
17
19
  palette: FloorplanPalette | undefined
18
20
  }
19
21
 
@@ -66,6 +68,8 @@ export function buildFloorplanContext(
66
68
  highlighted: viewState.highlighted,
67
69
  hovered: viewState.hovered,
68
70
  moving: viewState.moving,
71
+ focusedUnitId: viewState.focusedUnitId,
72
+ focusedUnitMemberIds: viewState.focusedUnitMemberIds,
69
73
  palette: viewState.palette,
70
74
  }
71
75
  : undefined,