@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,11 +1,24 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import type { FloorplanGeometry } from '@pascal-app/core'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type FloorplanGeometry,
6
+ type GeometryContext,
7
+ loadPlugin,
8
+ type NodeCategory,
9
+ nodeRegistry,
10
+ registerNode,
11
+ } from '@pascal-app/core'
12
+ import PDFDocument from 'pdfkit'
13
+ import { z } from 'zod'
3
14
  import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
4
15
  import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
5
16
  import {
17
+ collectFloorplanGeometry,
6
18
  filterFloorplanExportOverlay,
7
19
  fitPlanToBox,
8
20
  isFloorplanExportAnnotationGeometry,
21
+ isFloorplanNodeInExportScope,
9
22
  partitionFloorplanExportOverlay,
10
23
  resolveFloorplanExportAnnotationVisibility,
11
24
  resolveFloorplanExportNodeGeometry,
@@ -19,6 +32,63 @@ import {
19
32
  rotateFloorplanExportBounds,
20
33
  } from './floorplan-export'
21
34
  import { floorplanGeometryMetadata } from './floorplan-extension'
35
+ import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
36
+ import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
37
+
38
+ type GroupGeometry = Extract<FloorplanGeometry, { kind: 'group' }>
39
+ type GroupTransform = NonNullable<GroupGeometry['transform']>
40
+
41
+ function flattenGeometry(geometry: FloorplanGeometry | null): FloorplanGeometry[] {
42
+ if (!geometry) return []
43
+ if (geometry.kind !== 'group') return [geometry]
44
+ return [geometry, ...geometry.children.flatMap(flattenGeometry)]
45
+ }
46
+
47
+ function applyGeometryTransforms(
48
+ point: readonly [number, number],
49
+ transforms: readonly GroupTransform[],
50
+ ): [number, number] {
51
+ let x = point[0]
52
+ let y = point[1]
53
+ for (let index = transforms.length - 1; index >= 0; index -= 1) {
54
+ const transform = transforms[index]!
55
+ if (transform.rotate !== undefined) {
56
+ const cos = Math.cos(transform.rotate)
57
+ const sin = Math.sin(transform.rotate)
58
+ const rotatedX = x * cos - y * sin
59
+ y = x * sin + y * cos
60
+ x = rotatedX
61
+ }
62
+ if (transform.translate) {
63
+ x += transform.translate[0]
64
+ y += transform.translate[1]
65
+ }
66
+ }
67
+ return [x, y]
68
+ }
69
+
70
+ function projectedGeometryPoint(
71
+ geometry: FloorplanGeometry,
72
+ target: 'circle' | 'image',
73
+ transforms: readonly GroupTransform[] = [],
74
+ ): [number, number] | null {
75
+ if (geometry.kind === target) {
76
+ const point =
77
+ geometry.kind === 'circle'
78
+ ? ([geometry.cx, geometry.cy] as const)
79
+ : geometry.kind === 'image'
80
+ ? geometry.center
81
+ : null
82
+ return point ? applyGeometryTransforms(point, transforms) : null
83
+ }
84
+ if (geometry.kind !== 'group') return null
85
+ const nestedTransforms = geometry.transform ? [...transforms, geometry.transform] : transforms
86
+ for (const child of geometry.children) {
87
+ const point = projectedGeometryPoint(child, target, nestedTransforms)
88
+ if (point) return point
89
+ }
90
+ return null
91
+ }
22
92
 
23
93
  describe('filterFloorplanExportOverlay', () => {
24
94
  test('preserves annotation metadata while splitting geometry passes', () => {
@@ -317,3 +387,329 @@ describe('resolveFloorplanPageLayout', () => {
317
387
  })
318
388
  })
319
389
  })
390
+
391
+ describe('isFloorplanNodeInExportScope', () => {
392
+ const definition = (category?: NodeCategory) => ({ category })
393
+
394
+ test('includes structure-category nodes under structure and full', () => {
395
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'structure')).toBe(true)
396
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'full')).toBe(true)
397
+ })
398
+
399
+ test('excludes utility-category nodes under structure', () => {
400
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'full')).toBe(true)
401
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'structure')).toBe(false)
402
+ })
403
+
404
+ test('includes furnish-category nodes only under full', () => {
405
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'full')).toBe(true)
406
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'structure')).toBe(false)
407
+ })
408
+
409
+ test('includes analysis and site-category nodes only under full', () => {
410
+ for (const category of ['analysis', 'site'] as const) {
411
+ expect(isFloorplanNodeInExportScope(definition(category), 'full')).toBe(true)
412
+ expect(isFloorplanNodeInExportScope(definition(category), 'structure')).toBe(false)
413
+ }
414
+ })
415
+
416
+ test('excludes nodes with no category except under full', () => {
417
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'full')).toBe(true)
418
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'structure')).toBe(false)
419
+ })
420
+
421
+ test('handles an undefined definition like a no-category node', () => {
422
+ expect(isFloorplanNodeInExportScope(undefined, 'full')).toBe(true)
423
+ expect(isFloorplanNodeInExportScope(undefined, 'structure')).toBe(false)
424
+ })
425
+ })
426
+
427
+ describe('collectFloorplanGeometry', () => {
428
+ test('collects the active Site once below architecture with semantic context and projection', async () => {
429
+ const restoreRegistry = nodeRegistry._snapshot()
430
+ const activeSiteId = 'site_active'
431
+ const otherSiteId = 'site_other'
432
+ const enabledPluginId = 'test:site-pdf-enabled'
433
+ const disabledPluginId = 'test:site-pdf-disabled'
434
+ const enabledKind = 'test:site-pdf-overlay'
435
+ const disabledKind = 'test:site-pdf-disabled-overlay'
436
+ const architectureKind = 'test:level-architecture'
437
+ const semanticChildId = 'site_overlay_detail'
438
+ const referencedNodeId = 'site_reference'
439
+ const inlinePng =
440
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
441
+
442
+ const siteDefinition = (
443
+ kind: string,
444
+ floorplan: (node: Record<string, unknown>, context: GeometryContext) => FloorplanGeometry,
445
+ ): AnyNodeDefinition =>
446
+ ({
447
+ kind,
448
+ schemaVersion: 1,
449
+ schema: z.object({ type: z.literal(kind) }) as never,
450
+ category: 'utility',
451
+ defaults: () => ({}) as never,
452
+ capabilities: {},
453
+ floorplanScope: 'site',
454
+ floorplan,
455
+ }) as AnyNodeDefinition
456
+
457
+ const enabledDefinition = siteDefinition(enabledKind, (siteOverlay, context) => ({
458
+ kind: 'group',
459
+ children: [
460
+ {
461
+ kind: 'rect',
462
+ x: 10,
463
+ y: 3,
464
+ width: context.children[0]?.id === semanticChildId ? 3 : -3,
465
+ height: context.siblings.some(({ id }) => id === 'site_overlay_hidden') ? 4 : -4,
466
+ fill:
467
+ siteOverlay.id === 'site_overlay' &&
468
+ context.parent?.id === activeSiteId &&
469
+ context.resolve(referencedNodeId)?.id === referencedNodeId
470
+ ? '#102030'
471
+ : '#ff0000',
472
+ },
473
+ {
474
+ kind: 'path',
475
+ d: 'M0,0H4V4H0ZM1,1H3V3H1Z',
476
+ fill: '#3f6b2f',
477
+ fillRule: 'evenodd',
478
+ },
479
+ {
480
+ kind: 'image',
481
+ url: inlinePng,
482
+ center: [10, 3],
483
+ width: 1,
484
+ height: 1,
485
+ },
486
+ { kind: 'circle', cx: 12, cy: 5, r: 0.5 },
487
+ ],
488
+ }))
489
+
490
+ try {
491
+ nodeRegistry._reset()
492
+ registerNode({
493
+ kind: architectureKind,
494
+ schemaVersion: 1,
495
+ schema: z.object({ type: z.literal(architectureKind) }) as never,
496
+ category: 'structure',
497
+ defaults: () => ({}) as never,
498
+ capabilities: {},
499
+ floorplan: () => ({
500
+ kind: 'polygon',
501
+ points: [
502
+ [0, 0],
503
+ [4, 0],
504
+ [4, 4],
505
+ ],
506
+ }),
507
+ } as AnyNodeDefinition)
508
+ await loadPlugin({
509
+ id: enabledPluginId,
510
+ apiVersion: 1,
511
+ nodes: [enabledDefinition],
512
+ })
513
+ await loadPlugin({
514
+ id: disabledPluginId,
515
+ apiVersion: 1,
516
+ nodes: [siteDefinition(disabledKind, () => ({ kind: 'circle', cx: 0, cy: 0, r: 1 }))],
517
+ })
518
+
519
+ const activeSite = {
520
+ id: activeSiteId,
521
+ type: 'site',
522
+ parentId: null,
523
+ visible: true,
524
+ children: [
525
+ 'building_active',
526
+ 'site_overlay',
527
+ 'site_overlay_hidden',
528
+ 'site_overlay_disabled',
529
+ ],
530
+ } as unknown as AnyNode
531
+ const activeBuilding = {
532
+ id: 'building_active',
533
+ type: 'building',
534
+ parentId: activeSiteId,
535
+ children: ['level_active', 'level_upper'],
536
+ position: [10, 0, 5],
537
+ rotation: [0, Math.PI / 2, 0],
538
+ } as unknown as AnyNode
539
+ const activeLevel = {
540
+ id: 'level_active',
541
+ type: 'level',
542
+ parentId: activeBuilding.id,
543
+ children: ['level_architecture'],
544
+ } as unknown as AnyNode
545
+ const upperLevel = {
546
+ id: 'level_upper',
547
+ type: 'level',
548
+ parentId: activeBuilding.id,
549
+ children: ['level_upper_architecture'],
550
+ } as unknown as AnyNode
551
+ const nodes = {
552
+ [activeSite.id]: activeSite,
553
+ [activeBuilding.id]: activeBuilding,
554
+ [activeLevel.id]: activeLevel,
555
+ [upperLevel.id]: upperLevel,
556
+ level_upper_architecture: {
557
+ id: 'level_upper_architecture',
558
+ type: architectureKind,
559
+ parentId: upperLevel.id,
560
+ visible: true,
561
+ } as unknown as AnyNode,
562
+ level_architecture: {
563
+ id: 'level_architecture',
564
+ type: architectureKind,
565
+ parentId: activeLevel.id,
566
+ visible: true,
567
+ } as unknown as AnyNode,
568
+ site_overlay: {
569
+ id: 'site_overlay',
570
+ type: enabledKind,
571
+ parentId: null,
572
+ children: [semanticChildId],
573
+ visible: true,
574
+ } as unknown as AnyNode,
575
+ [semanticChildId]: {
576
+ id: semanticChildId,
577
+ type: 'test:site-detail',
578
+ parentId: 'site_overlay',
579
+ } as unknown as AnyNode,
580
+ [referencedNodeId]: {
581
+ id: referencedNodeId,
582
+ type: 'test:site-reference',
583
+ parentId: activeSiteId,
584
+ } as unknown as AnyNode,
585
+ site_overlay_hidden: {
586
+ id: 'site_overlay_hidden',
587
+ type: enabledKind,
588
+ parentId: activeSiteId,
589
+ visible: false,
590
+ } as unknown as AnyNode,
591
+ site_overlay_disabled: {
592
+ id: 'site_overlay_disabled',
593
+ type: disabledKind,
594
+ parentId: activeSiteId,
595
+ visible: true,
596
+ } as unknown as AnyNode,
597
+ [otherSiteId]: {
598
+ id: otherSiteId,
599
+ type: 'site',
600
+ parentId: null,
601
+ children: ['site_overlay_other'],
602
+ } as unknown as AnyNode,
603
+ site_overlay_other: {
604
+ id: 'site_overlay_other',
605
+ type: enabledKind,
606
+ parentId: otherSiteId,
607
+ visible: true,
608
+ } as unknown as AnyNode,
609
+ }
610
+
611
+ const full = collectFloorplanGeometry(
612
+ nodes,
613
+ activeLevel.id,
614
+ 'full',
615
+ 'metric',
616
+ 'meters',
617
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
618
+ 'floor-plan',
619
+ 'finished-faces',
620
+ [enabledPluginId],
621
+ )
622
+
623
+ expect(full.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
624
+ const siteModel = full[0]?.model
625
+ if (!siteModel) throw new Error('Expected Site geometry')
626
+ const siteParts = flattenGeometry(siteModel)
627
+ expect(siteParts).toEqual(
628
+ expect.arrayContaining([
629
+ expect.objectContaining({
630
+ kind: 'rect',
631
+ width: 3,
632
+ height: 4,
633
+ fill: '#102030',
634
+ }),
635
+ expect.objectContaining({ kind: 'path', fillRule: 'evenodd' }),
636
+ expect.objectContaining({ kind: 'image', url: inlinePng }),
637
+ ]),
638
+ )
639
+ expect(projectedGeometryPoint(siteModel, 'image')).toEqual([
640
+ expect.closeTo(2),
641
+ expect.closeTo(0),
642
+ ])
643
+ expect(projectedGeometryPoint(siteModel, 'circle')).toEqual([
644
+ expect.closeTo(0),
645
+ expect.closeTo(2),
646
+ ])
647
+
648
+ const rawPdf = new PDFDocument({ autoFirstPage: false, compress: false })
649
+ const chunks: Buffer[] = []
650
+ rawPdf.on('data', (chunk: Buffer) => chunks.push(chunk))
651
+ const completedPdf = Promise.withResolvers<string>()
652
+ rawPdf.on('end', () => completedPdf.resolve(Buffer.concat(chunks).toString('latin1')))
653
+ const pdf = new FloorplanPdfDocument(rawPdf, [200, 200])
654
+ pdf.addPage()
655
+ for (const { model } of full) {
656
+ if (!model) continue
657
+ await renderFloorplanGeometryToPdfKit(pdf, model, {
658
+ annotationLayer: false,
659
+ placement: { x: 20, y: 20, width: 100, height: 100 },
660
+ rotationDeg: 0,
661
+ viewport: { x: -4, y: -4, width: 20, height: 20 },
662
+ })
663
+ }
664
+ rawPdf.end()
665
+ const renderedPdf = await completedPdf.promise
666
+ expect(renderedPdf).toMatch(/f\*/)
667
+ expect(renderedPdf).toContain('/Subtype /Image')
668
+
669
+ const upper = collectFloorplanGeometry(
670
+ nodes,
671
+ upperLevel.id,
672
+ 'full',
673
+ 'metric',
674
+ 'meters',
675
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
676
+ 'floor-plan',
677
+ 'finished-faces',
678
+ [enabledPluginId],
679
+ )
680
+ expect(upper.map(({ id }) => id)).toEqual(['site_overlay', 'level_upper_architecture'])
681
+
682
+ const hiddenSiteNodes = {
683
+ ...nodes,
684
+ [activeSite.id]: { ...activeSite, visible: false } as AnyNode,
685
+ }
686
+ const hiddenSite = collectFloorplanGeometry(
687
+ hiddenSiteNodes,
688
+ activeLevel.id,
689
+ 'full',
690
+ 'metric',
691
+ 'meters',
692
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
693
+ 'floor-plan',
694
+ 'finished-faces',
695
+ [enabledPluginId],
696
+ )
697
+ expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
698
+
699
+ const structure = collectFloorplanGeometry(
700
+ nodes,
701
+ activeLevel.id,
702
+ 'structure',
703
+ 'metric',
704
+ 'meters',
705
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
706
+ 'floor-plan',
707
+ 'finished-faces',
708
+ [enabledPluginId],
709
+ )
710
+ expect(structure.map(({ id }) => id)).toEqual(['level_architecture'])
711
+ } finally {
712
+ restoreRegistry()
713
+ }
714
+ })
715
+ })
@@ -7,7 +7,9 @@ import {
7
7
  type FloorplanGeometry,
8
8
  type FloorplanPalette,
9
9
  type FloorplanPoint,
10
+ isNodeKindEnabled,
10
11
  type LiveNodeOverrides,
12
+ type NodeCategory,
11
13
  nodeRegistry,
12
14
  resolveBuildingForLevel,
13
15
  useScene,
@@ -20,10 +22,13 @@ import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/rende
20
22
  import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer'
21
23
  import {
22
24
  buildContext,
25
+ collectDirectFloorplanScopeNodes,
23
26
  collectFloorplanLinkedLevelNodes,
24
27
  floorplanLayerRank,
25
28
  getFloorplanLevelData,
29
+ isFloorplanHierarchyVisible,
26
30
  isFloorplanNodeVisible,
31
+ siteToFloorplanTransform,
27
32
  splitFloorplanOverlay,
28
33
  } from '../../components/editor-2d/renderers/floorplan-registry-layer'
29
34
  import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
@@ -69,6 +74,20 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
69
74
  */
70
75
  export type FloorplanExportScope = 'full' | 'structure'
71
76
 
77
+ /**
78
+ * Whether a node belongs in the given export scope. `'full'` short-circuits
79
+ * and admits every node; `'structure'` admits only `structure`-category
80
+ * nodes. An `undefined` definition (unregistered node type) behaves like a
81
+ * node with no category.
82
+ */
83
+ export function isFloorplanNodeInExportScope(
84
+ definition: { category?: NodeCategory } | undefined,
85
+ scope: FloorplanExportScope,
86
+ ): boolean {
87
+ if (scope === 'full') return true
88
+ return definition?.category === 'structure'
89
+ }
90
+
72
91
  const SVG_NS = 'http://www.w3.org/2000/svg'
73
92
  /** Minimum and proportional margin around the structural drawing bounds. */
74
93
  const MIN_PLAN_PADDING_M = 1
@@ -131,12 +150,27 @@ type ExportGeometry = {
131
150
  annotations: FloorplanGeometry | null
132
151
  }
133
152
 
153
+ type ExportGeometryContext = {
154
+ children: AnyNode[]
155
+ siblings: AnyNode[]
156
+ parent: AnyNode
157
+ outputTransform: { translate: FloorplanPoint; rotate: number }
158
+ }
159
+
160
+ type ExportGeometryEntry = {
161
+ id: AnyNodeId
162
+ node: AnyNode
163
+ parentOverride?: AnyNode
164
+ context?: ExportGeometryContext
165
+ scopeRank?: number
166
+ }
167
+
134
168
  export type FloorplanPageLayout = {
135
169
  planBox: { x: number; y: number; width: number; height: number }
136
170
  }
137
171
 
138
172
  export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
139
- const nodes = useScene.getState().nodes
173
+ const { nodes, installedPlugins } = useScene.getState()
140
174
  const viewer = useViewer.getState()
141
175
  const unit = viewer.unit
142
176
  const metricNotation = viewer.metricNotation
@@ -183,6 +217,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
183
217
  annotationVisibility,
184
218
  drawingType,
185
219
  wallDimensionReference,
220
+ installedPlugins,
186
221
  )
187
222
  const schedules = collectFloorplanSchedules(nodes, level.id, unit)
188
223
  if (geometries.length === 0 && schedules.length === 0) continue
@@ -721,7 +756,7 @@ function applyFloorplanViewport(
721
756
  mounted.svg.insertBefore(background, mounted.svg.firstChild)
722
757
  }
723
758
 
724
- function collectFloorplanGeometry(
759
+ export function collectFloorplanGeometry(
725
760
  nodes: Record<string, AnyNode>,
726
761
  levelId: AnyNodeId,
727
762
  scope: FloorplanExportScope,
@@ -730,10 +765,11 @@ function collectFloorplanGeometry(
730
765
  annotationVisibility: FloorplanAnnotationVisibility,
731
766
  drawingType: ConstructionDrawingType,
732
767
  wallDimensionReference: FloorplanWallDimensionReference,
768
+ installedPlugins: readonly string[],
733
769
  ): ExportGeometry[] {
734
770
  const noLiveOverrides = new Map<string, LiveNodeOverrides>()
735
771
  const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
736
- const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = []
772
+ const entries: ExportGeometryEntry[] = []
737
773
 
738
774
  const visit = (id: AnyNodeId) => {
739
775
  const node = nodes[id]
@@ -747,7 +783,7 @@ function collectFloorplanGeometry(
747
783
  if (
748
784
  def?.floorplan &&
749
785
  isFloorplanNodeVisible(node) &&
750
- (scope === 'full' || def.category === 'structure')
786
+ isFloorplanNodeInExportScope(def, scope)
751
787
  ) {
752
788
  const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
753
789
  if (drawingNode) entries.push({ id, node: drawingNode })
@@ -758,31 +794,74 @@ function collectFloorplanGeometry(
758
794
  visit(levelId)
759
795
 
760
796
  const activeLevelNode = nodes[levelId]
797
+ const collectedIds = new Set(entries.map((entry) => entry.id))
761
798
  if (activeLevelNode) {
762
- const collectedIds = new Set(entries.map((entry) => entry.id))
763
799
  for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) {
764
800
  const definition = nodeRegistry.get(linked.node.type)
765
- if (
766
- isFloorplanNodeVisible(linked.node) &&
767
- (scope === 'full' || definition?.category === 'structure')
768
- ) {
801
+ if (isFloorplanNodeVisible(linked.node) && isFloorplanNodeInExportScope(definition, scope)) {
769
802
  const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType)
770
803
  if (drawingNode) {
771
804
  entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode })
805
+ collectedIds.add(linked.id)
772
806
  }
773
807
  }
774
808
  }
775
809
  }
776
810
 
777
- // Document order is paint order sort the same way the live layer does so
778
- // zones sit under walls/slabs/furniture rather than on top of them.
779
- entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
811
+ const buildingId = resolveBuildingForLevel(levelId, nodes as Record<AnyNodeId, AnyNode>)
812
+ const buildingNode = buildingId ? nodes[buildingId] : undefined
813
+ const siteNode =
814
+ buildingNode?.type === 'building' && buildingNode.parentId
815
+ ? nodes[buildingNode.parentId as AnyNodeId]
816
+ : undefined
817
+ if (siteNode?.type === 'site' && buildingNode?.type === 'building') {
818
+ const siteScopedNodes = collectDirectFloorplanScopeNodes(nodes, siteNode, 'site')
819
+ const outputTransform = siteToFloorplanTransform(
820
+ buildingNode.position,
821
+ buildingNode.rotation[1],
822
+ )
823
+ for (const node of siteScopedNodes) {
824
+ if (collectedIds.has(node.id) || !isNodeKindEnabled(node.type, installedPlugins)) continue
825
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
826
+ if (!drawingNode) continue
827
+ const definition = nodeRegistry.get(drawingNode.type)
828
+ if (
829
+ !definition?.floorplan ||
830
+ !isFloorplanNodeInExportScope(definition, scope) ||
831
+ !isFloorplanHierarchyVisible(drawingNode, nodes, noLiveOverrides, siteNode.id)
832
+ ) {
833
+ continue
834
+ }
835
+ const childIds = 'children' in node ? node.children : undefined
836
+ const children = Array.isArray(childIds)
837
+ ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
838
+ : []
839
+ const siblings = siteScopedNodes.filter(
840
+ (candidate) => candidate.id !== node.id && candidate.type === node.type,
841
+ )
842
+ entries.push({
843
+ id: node.id,
844
+ node: drawingNode,
845
+ context: { children, siblings, parent: siteNode, outputTransform },
846
+ scopeRank: -1,
847
+ })
848
+ collectedIds.add(node.id)
849
+ }
850
+ }
851
+
852
+ // Document order is paint order. Site context sits below level
853
+ // architecture, then each scope retains the live layer's z-order.
854
+ entries.sort(
855
+ (a, b) =>
856
+ (a.scopeRank ?? 0) - (b.scopeRank ?? 0) ||
857
+ floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type),
858
+ )
780
859
 
781
860
  // One-shot per-type cache for `computeFloorplanLevelData`; value type is
782
861
  // module-private to the registry layer, so let it infer.
783
862
  const levelDataCache = new Map()
784
863
  const out: ExportGeometry[] = []
785
- for (const { id, node, parentOverride } of entries) {
864
+ for (const { id, node, parentOverride, context } of entries) {
786
865
  const builder = nodeRegistry.get(node.type)?.floorplan
787
866
  if (!builder) continue
788
867
  const levelData = getFloorplanLevelData(
@@ -803,9 +882,25 @@ function collectFloorplanGeometry(
803
882
  ),
804
883
  levelData,
805
884
  )
806
- const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
807
- const geometry = builder(node, ctx)
808
- if (!geometry) continue
885
+ const ctx = context
886
+ ? {
887
+ ...baseContext,
888
+ children: context.children,
889
+ siblings: context.siblings,
890
+ parent: context.parent,
891
+ }
892
+ : parentOverride
893
+ ? { ...baseContext, parent: parentOverride }
894
+ : baseContext
895
+ const builtGeometry = builder(node, ctx)
896
+ if (!builtGeometry) continue
897
+ const geometry = context
898
+ ? {
899
+ kind: 'group' as const,
900
+ children: [builtGeometry],
901
+ transform: context.outputTransform,
902
+ }
903
+ : builtGeometry
809
904
  const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility)
810
905
  if (!visibleGeometry) continue
811
906
  const { base, overlay } = splitFloorplanOverlay(visibleGeometry)
@@ -85,6 +85,32 @@ describe('renderFloorplanGeometryToPdfKit', () => {
85
85
  expect([...new Set(baseFonts)]).toEqual(['Courier'])
86
86
  expect([...new Set(fontSizes)]).toEqual(['1.6'])
87
87
  })
88
+ test('uses even-odd fill for compound plugin paths', async () => {
89
+ const geometry = {
90
+ kind: 'path',
91
+ d: 'M0,0H4V4H0ZM1,1H3V3H1Z',
92
+ fill: '#3f6b2f',
93
+ fillRule: 'evenodd',
94
+ } satisfies FloorplanGeometry
95
+
96
+ const pdf = await renderTestPdf(geometry)
97
+
98
+ expect(pdf).toMatch(/f\*/)
99
+ })
100
+
101
+ test('writes a data-url PNG image without resolving it as an asset', async () => {
102
+ const geometry = {
103
+ kind: 'image',
104
+ url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
105
+ center: [2, 2],
106
+ width: 1,
107
+ height: 1,
108
+ } satisfies FloorplanGeometry
109
+
110
+ const pdf = await renderTestPdf(geometry)
111
+
112
+ expect(pdf).toContain('/Subtype /Image')
113
+ })
88
114
  })
89
115
 
90
116
  async function renderTestPdf(geometry: FloorplanGeometry, rotationDeg = 0): Promise<string> {