@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,12 +1,30 @@
1
- import { describe, expect, test } from 'bun:test'
2
- import type { FloorplanGeometry } from '@pascal-app/core'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ BuildingNode,
6
+ type FloorplanGeometry,
7
+ type GeometryContext,
8
+ LevelNode,
9
+ loadPlugin,
10
+ type NodeCategory,
11
+ nodeRegistry,
12
+ registerNode,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import PDFDocument from 'pdfkit'
16
+ import { z } from 'zod'
3
17
  import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
4
18
  import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
5
19
  import {
20
+ collectFloorplanGeometry,
21
+ collectFloorplanSchedules,
6
22
  filterFloorplanExportOverlay,
7
23
  fitPlanToBox,
8
24
  isFloorplanExportAnnotationGeometry,
25
+ isFloorplanNodeInExportScope,
9
26
  partitionFloorplanExportOverlay,
27
+ resolveExportLevels,
10
28
  resolveFloorplanExportAnnotationVisibility,
11
29
  resolveFloorplanExportNodeGeometry,
12
30
  resolveFloorplanExportPlacement,
@@ -19,6 +37,63 @@ import {
19
37
  rotateFloorplanExportBounds,
20
38
  } from './floorplan-export'
21
39
  import { floorplanGeometryMetadata } from './floorplan-extension'
40
+ import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
41
+ import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
42
+
43
+ type GroupGeometry = Extract<FloorplanGeometry, { kind: 'group' }>
44
+ type GroupTransform = NonNullable<GroupGeometry['transform']>
45
+
46
+ function flattenGeometry(geometry: FloorplanGeometry | null): FloorplanGeometry[] {
47
+ if (!geometry) return []
48
+ if (geometry.kind !== 'group') return [geometry]
49
+ return [geometry, ...geometry.children.flatMap(flattenGeometry)]
50
+ }
51
+
52
+ function applyGeometryTransforms(
53
+ point: readonly [number, number],
54
+ transforms: readonly GroupTransform[],
55
+ ): [number, number] {
56
+ let x = point[0]
57
+ let y = point[1]
58
+ for (let index = transforms.length - 1; index >= 0; index -= 1) {
59
+ const transform = transforms[index]!
60
+ if (transform.rotate !== undefined) {
61
+ const cos = Math.cos(transform.rotate)
62
+ const sin = Math.sin(transform.rotate)
63
+ const rotatedX = x * cos - y * sin
64
+ y = x * sin + y * cos
65
+ x = rotatedX
66
+ }
67
+ if (transform.translate) {
68
+ x += transform.translate[0]
69
+ y += transform.translate[1]
70
+ }
71
+ }
72
+ return [x, y]
73
+ }
74
+
75
+ function projectedGeometryPoint(
76
+ geometry: FloorplanGeometry,
77
+ target: 'circle' | 'image',
78
+ transforms: readonly GroupTransform[] = [],
79
+ ): [number, number] | null {
80
+ if (geometry.kind === target) {
81
+ const point =
82
+ geometry.kind === 'circle'
83
+ ? ([geometry.cx, geometry.cy] as const)
84
+ : geometry.kind === 'image'
85
+ ? geometry.center
86
+ : null
87
+ return point ? applyGeometryTransforms(point, transforms) : null
88
+ }
89
+ if (geometry.kind !== 'group') return null
90
+ const nestedTransforms = geometry.transform ? [...transforms, geometry.transform] : transforms
91
+ for (const child of geometry.children) {
92
+ const point = projectedGeometryPoint(child, target, nestedTransforms)
93
+ if (point) return point
94
+ }
95
+ return null
96
+ }
22
97
 
23
98
  describe('filterFloorplanExportOverlay', () => {
24
99
  test('preserves annotation metadata while splitting geometry passes', () => {
@@ -317,3 +392,462 @@ describe('resolveFloorplanPageLayout', () => {
317
392
  })
318
393
  })
319
394
  })
395
+
396
+ describe('isFloorplanNodeInExportScope', () => {
397
+ const definition = (category?: NodeCategory) => ({ category })
398
+
399
+ test('includes structure-category nodes under structure and full', () => {
400
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'structure')).toBe(true)
401
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'full')).toBe(true)
402
+ })
403
+
404
+ test('excludes utility-category nodes under structure', () => {
405
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'full')).toBe(true)
406
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'structure')).toBe(false)
407
+ })
408
+
409
+ test('includes furnish-category nodes only under full', () => {
410
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'full')).toBe(true)
411
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'structure')).toBe(false)
412
+ })
413
+
414
+ test('includes analysis and site-category nodes only under full', () => {
415
+ for (const category of ['analysis', 'site'] as const) {
416
+ expect(isFloorplanNodeInExportScope(definition(category), 'full')).toBe(true)
417
+ expect(isFloorplanNodeInExportScope(definition(category), 'structure')).toBe(false)
418
+ }
419
+ })
420
+
421
+ test('excludes nodes with no category except under full', () => {
422
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'full')).toBe(true)
423
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'structure')).toBe(false)
424
+ })
425
+
426
+ test('handles an undefined definition like a no-category node', () => {
427
+ expect(isFloorplanNodeInExportScope(undefined, 'full')).toBe(true)
428
+ expect(isFloorplanNodeInExportScope(undefined, 'structure')).toBe(false)
429
+ })
430
+ })
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
+
507
+ describe('collectFloorplanGeometry', () => {
508
+ test('collects the active Site once below architecture with semantic context and projection', async () => {
509
+ const restoreRegistry = nodeRegistry._snapshot()
510
+ const activeSiteId = 'site_active'
511
+ const otherSiteId = 'site_other'
512
+ const enabledPluginId = 'test:site-pdf-enabled'
513
+ const disabledPluginId = 'test:site-pdf-disabled'
514
+ const enabledKind = 'test:site-pdf-overlay'
515
+ const disabledKind = 'test:site-pdf-disabled-overlay'
516
+ const architectureKind = 'test:level-architecture'
517
+ const semanticChildId = 'site_overlay_detail'
518
+ const referencedNodeId = 'site_reference'
519
+ const inlinePng =
520
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
521
+
522
+ const siteDefinition = (
523
+ kind: string,
524
+ floorplan: (node: Record<string, unknown>, context: GeometryContext) => FloorplanGeometry,
525
+ ): AnyNodeDefinition =>
526
+ ({
527
+ kind,
528
+ schemaVersion: 1,
529
+ schema: z.object({ type: z.literal(kind) }) as never,
530
+ category: 'utility',
531
+ defaults: () => ({}) as never,
532
+ capabilities: {},
533
+ floorplanScope: 'site',
534
+ floorplan,
535
+ }) as AnyNodeDefinition
536
+
537
+ const enabledDefinition = siteDefinition(enabledKind, (siteOverlay, context) => ({
538
+ kind: 'group',
539
+ children: [
540
+ {
541
+ kind: 'rect',
542
+ x: 10,
543
+ y: 3,
544
+ width: context.children[0]?.id === semanticChildId ? 3 : -3,
545
+ height: context.siblings.some(({ id }) => id === 'site_overlay_hidden') ? 4 : -4,
546
+ fill:
547
+ siteOverlay.id === 'site_overlay' &&
548
+ context.parent?.id === activeSiteId &&
549
+ context.resolve(referencedNodeId)?.id === referencedNodeId
550
+ ? '#102030'
551
+ : '#ff0000',
552
+ },
553
+ {
554
+ kind: 'path',
555
+ d: 'M0,0H4V4H0ZM1,1H3V3H1Z',
556
+ fill: '#3f6b2f',
557
+ fillRule: 'evenodd',
558
+ },
559
+ {
560
+ kind: 'image',
561
+ url: inlinePng,
562
+ center: [10, 3],
563
+ width: 1,
564
+ height: 1,
565
+ },
566
+ { kind: 'circle', cx: 12, cy: 5, r: 0.5 },
567
+ ],
568
+ }))
569
+
570
+ try {
571
+ nodeRegistry._reset()
572
+ registerNode({
573
+ kind: architectureKind,
574
+ schemaVersion: 1,
575
+ schema: z.object({ type: z.literal(architectureKind) }) as never,
576
+ category: 'structure',
577
+ defaults: () => ({}) as never,
578
+ capabilities: {},
579
+ floorplan: () => ({
580
+ kind: 'polygon',
581
+ points: [
582
+ [0, 0],
583
+ [4, 0],
584
+ [4, 4],
585
+ ],
586
+ }),
587
+ } as AnyNodeDefinition)
588
+ await loadPlugin({
589
+ id: enabledPluginId,
590
+ apiVersion: 1,
591
+ nodes: [enabledDefinition],
592
+ })
593
+ await loadPlugin({
594
+ id: disabledPluginId,
595
+ apiVersion: 1,
596
+ nodes: [siteDefinition(disabledKind, () => ({ kind: 'circle', cx: 0, cy: 0, r: 1 }))],
597
+ })
598
+
599
+ const activeSite = {
600
+ id: activeSiteId,
601
+ type: 'site',
602
+ parentId: null,
603
+ visible: true,
604
+ children: [
605
+ 'building_active',
606
+ 'site_overlay',
607
+ 'site_overlay_hidden',
608
+ 'site_overlay_disabled',
609
+ ],
610
+ } as unknown as AnyNode
611
+ const activeBuilding = {
612
+ id: 'building_active',
613
+ type: 'building',
614
+ parentId: activeSiteId,
615
+ children: ['level_active', 'level_upper'],
616
+ position: [10, 0, 5],
617
+ rotation: [0, Math.PI / 2, 0],
618
+ } as unknown as AnyNode
619
+ const activeLevel = {
620
+ id: 'level_active',
621
+ type: 'level',
622
+ parentId: activeBuilding.id,
623
+ children: ['level_architecture'],
624
+ } as unknown as AnyNode
625
+ const upperLevel = {
626
+ id: 'level_upper',
627
+ type: 'level',
628
+ parentId: activeBuilding.id,
629
+ children: ['level_upper_architecture'],
630
+ } as unknown as AnyNode
631
+ const nodes = {
632
+ [activeSite.id]: activeSite,
633
+ [activeBuilding.id]: activeBuilding,
634
+ [activeLevel.id]: activeLevel,
635
+ [upperLevel.id]: upperLevel,
636
+ level_upper_architecture: {
637
+ id: 'level_upper_architecture',
638
+ type: architectureKind,
639
+ parentId: upperLevel.id,
640
+ visible: true,
641
+ } as unknown as AnyNode,
642
+ level_architecture: {
643
+ id: 'level_architecture',
644
+ type: architectureKind,
645
+ parentId: activeLevel.id,
646
+ visible: true,
647
+ } as unknown as AnyNode,
648
+ site_overlay: {
649
+ id: 'site_overlay',
650
+ type: enabledKind,
651
+ parentId: null,
652
+ children: [semanticChildId],
653
+ visible: true,
654
+ } as unknown as AnyNode,
655
+ [semanticChildId]: {
656
+ id: semanticChildId,
657
+ type: 'test:site-detail',
658
+ parentId: 'site_overlay',
659
+ } as unknown as AnyNode,
660
+ [referencedNodeId]: {
661
+ id: referencedNodeId,
662
+ type: 'test:site-reference',
663
+ parentId: activeSiteId,
664
+ } as unknown as AnyNode,
665
+ site_overlay_hidden: {
666
+ id: 'site_overlay_hidden',
667
+ type: enabledKind,
668
+ parentId: activeSiteId,
669
+ visible: false,
670
+ } as unknown as AnyNode,
671
+ site_overlay_disabled: {
672
+ id: 'site_overlay_disabled',
673
+ type: disabledKind,
674
+ parentId: activeSiteId,
675
+ visible: true,
676
+ } as unknown as AnyNode,
677
+ [otherSiteId]: {
678
+ id: otherSiteId,
679
+ type: 'site',
680
+ parentId: null,
681
+ children: ['site_overlay_other'],
682
+ } as unknown as AnyNode,
683
+ site_overlay_other: {
684
+ id: 'site_overlay_other',
685
+ type: enabledKind,
686
+ parentId: otherSiteId,
687
+ visible: true,
688
+ } as unknown as AnyNode,
689
+ }
690
+
691
+ const full = collectFloorplanGeometry(
692
+ nodes,
693
+ activeLevel.id,
694
+ 'full',
695
+ 'metric',
696
+ 'meters',
697
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
698
+ 'floor-plan',
699
+ 'finished-faces',
700
+ [enabledPluginId],
701
+ )
702
+
703
+ expect(full.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
704
+ const siteModel = full[0]?.model
705
+ if (!siteModel) throw new Error('Expected Site geometry')
706
+ const siteParts = flattenGeometry(siteModel)
707
+ expect(siteParts).toEqual(
708
+ expect.arrayContaining([
709
+ expect.objectContaining({
710
+ kind: 'rect',
711
+ width: 3,
712
+ height: 4,
713
+ fill: '#102030',
714
+ }),
715
+ expect.objectContaining({ kind: 'path', fillRule: 'evenodd' }),
716
+ expect.objectContaining({ kind: 'image', url: inlinePng }),
717
+ ]),
718
+ )
719
+ expect(projectedGeometryPoint(siteModel, 'image')).toEqual([
720
+ expect.closeTo(2),
721
+ expect.closeTo(0),
722
+ ])
723
+ expect(projectedGeometryPoint(siteModel, 'circle')).toEqual([
724
+ expect.closeTo(0),
725
+ expect.closeTo(2),
726
+ ])
727
+
728
+ const rawPdf = new PDFDocument({ autoFirstPage: false, compress: false })
729
+ const chunks: Buffer[] = []
730
+ rawPdf.on('data', (chunk: Buffer) => chunks.push(chunk))
731
+ const completedPdf = Promise.withResolvers<string>()
732
+ rawPdf.on('end', () => completedPdf.resolve(Buffer.concat(chunks).toString('latin1')))
733
+ const pdf = new FloorplanPdfDocument(rawPdf, [200, 200])
734
+ pdf.addPage()
735
+ for (const { model } of full) {
736
+ if (!model) continue
737
+ await renderFloorplanGeometryToPdfKit(pdf, model, {
738
+ annotationLayer: false,
739
+ placement: { x: 20, y: 20, width: 100, height: 100 },
740
+ rotationDeg: 0,
741
+ viewport: { x: -4, y: -4, width: 20, height: 20 },
742
+ })
743
+ }
744
+ rawPdf.end()
745
+ const renderedPdf = await completedPdf.promise
746
+ expect(renderedPdf).toMatch(/f\*/)
747
+ expect(renderedPdf).toContain('/Subtype /Image')
748
+
749
+ const upper = collectFloorplanGeometry(
750
+ nodes,
751
+ upperLevel.id,
752
+ 'full',
753
+ 'metric',
754
+ 'meters',
755
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
756
+ 'floor-plan',
757
+ 'finished-faces',
758
+ [enabledPluginId],
759
+ )
760
+ expect(upper.map(({ id }) => id)).toEqual(['site_overlay', 'level_upper_architecture'])
761
+
762
+ const hiddenSiteNodes = {
763
+ ...nodes,
764
+ [activeSite.id]: { ...activeSite, visible: false } as AnyNode,
765
+ }
766
+ const hiddenSite = collectFloorplanGeometry(
767
+ hiddenSiteNodes,
768
+ activeLevel.id,
769
+ 'full',
770
+ 'metric',
771
+ 'meters',
772
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
773
+ 'floor-plan',
774
+ 'finished-faces',
775
+ [enabledPluginId],
776
+ )
777
+ expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
778
+
779
+ const structure = collectFloorplanGeometry(
780
+ nodes,
781
+ activeLevel.id,
782
+ 'structure',
783
+ 'metric',
784
+ 'meters',
785
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
786
+ 'floor-plan',
787
+ 'finished-faces',
788
+ [enabledPluginId],
789
+ )
790
+ expect(structure.map(({ id }) => id)).toEqual(['level_architecture'])
791
+ } finally {
792
+ restoreRegistry()
793
+ }
794
+ })
795
+ })
796
+
797
+ describe('resolveExportLevels', () => {
798
+ const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 })
799
+ const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 })
800
+ const roof = LevelNode.parse({
801
+ id: 'level_roof',
802
+ parentId: 'building_a',
803
+ level: 2,
804
+ metadata: { role: 'roof', referenceLevelId: upper.id },
805
+ })
806
+ const attic = LevelNode.parse({
807
+ id: 'level_attic',
808
+ parentId: 'building_a',
809
+ level: 3,
810
+ metadata: { role: 'attic' },
811
+ })
812
+ const building = BuildingNode.parse({
813
+ id: 'building_a',
814
+ children: [ground.id, upper.id, roof.id, attic.id],
815
+ })
816
+ const nodes: Record<string, AnyNode> = Object.fromEntries(
817
+ [building, ground, upper, roof, attic].map((node) => [node.id, node]),
818
+ )
819
+
820
+ // The viewer store is a process-wide singleton, so an earlier test file can
821
+ // leak a selection into these tests; restore it instead of leaving ours.
822
+ const previousSelection = useViewer.getState().selection
823
+
824
+ const selectLevel = (levelId: string | null) => {
825
+ useViewer.setState({
826
+ selection: { ...previousSelection, buildingId: building.id, levelId },
827
+ } as never)
828
+ }
829
+
830
+ afterEach(() => {
831
+ useViewer.setState({ selection: previousSelection } as never)
832
+ })
833
+
834
+ test('skips a dedicated roof support level', () => {
835
+ selectLevel(ground.id)
836
+
837
+ expect(resolveExportLevels(nodes)).toEqual([
838
+ { id: ground.id, label: 'Level 0' },
839
+ { id: upper.id, label: 'Level 1' },
840
+ { id: attic.id, label: 'Level 3' },
841
+ ])
842
+ })
843
+
844
+ test('skips the roof level when it is the selected level', () => {
845
+ selectLevel(roof.id)
846
+
847
+ expect(resolveExportLevels(nodes)).toEqual([
848
+ { id: ground.id, label: 'Level 0' },
849
+ { id: upper.id, label: 'Level 1' },
850
+ { id: attic.id, label: 'Level 3' },
851
+ ])
852
+ })
853
+ })