@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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> {
@@ -191,6 +191,7 @@ function paintStyledGeometry(
191
191
  const opacity = geometry.opacity ?? 1
192
192
  const fillOpacity = (geometry.fillOpacity ?? 1) * opacity
193
193
  const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity
194
+ const fillRule = geometry.fillRule === 'evenodd' ? 'even-odd' : 'non-zero'
194
195
 
195
196
  if (fill) raw.fillColor(fill).fillOpacity(fillOpacity)
196
197
  if (stroke) {
@@ -206,8 +207,8 @@ function paintStyledGeometry(
206
207
  )
207
208
  }
208
209
 
209
- if (fill && stroke) raw.fillAndStroke(fill, stroke)
210
- else if (fill) raw.fill(fill)
210
+ if (fill && stroke) raw.fillAndStroke(fill, stroke, fillRule)
211
+ else if (fill) raw.fill(fill, fillRule)
211
212
  else if (stroke) raw.stroke(stroke)
212
213
  }
213
214
 
@@ -620,29 +621,36 @@ async function drawImage(
620
621
  doc: FloorplanPdfDocument,
621
622
  geometry: Extract<FloorplanGeometry, { kind: 'image' }>,
622
623
  ): Promise<void> {
623
- try {
624
- const url = await loadAssetUrl(geometry.url)
625
- if (!url) return
626
- const response = await fetch(url)
627
- if (!response.ok) return
628
- const dataUrl = await blobToDataUrl(await response.blob())
629
- const raw = doc.raw
630
- raw.save().translate(geometry.center[0], geometry.center[1])
631
- if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI)
632
- raw.opacity(geometry.opacity ?? 1)
633
- const options =
634
- geometry.preserveAspectRatio === 'none'
635
- ? { width: geometry.width, height: geometry.height }
636
- : {
637
- fit: [geometry.width, geometry.height] as [number, number],
638
- align: 'center' as const,
639
- valign: 'center' as const,
640
- }
641
- raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options)
642
- raw.restore()
643
- } catch {
644
- return
624
+ const source = geometry.url.startsWith('data:') ? geometry.url : await loadAssetUrl(geometry.url)
625
+ if (!source) {
626
+ throw new Error('[floorplan-export] Could not resolve a floorplan image')
627
+ }
628
+
629
+ let dataUrl = source
630
+ if (!source.startsWith('data:')) {
631
+ const response = await fetch(source)
632
+ if (!response.ok) {
633
+ throw new Error(
634
+ `[floorplan-export] Could not load a floorplan image (HTTP ${response.status})`,
635
+ )
636
+ }
637
+ dataUrl = await blobToDataUrl(await response.blob())
645
638
  }
639
+
640
+ const raw = doc.raw
641
+ raw.save().translate(geometry.center[0], geometry.center[1])
642
+ if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI)
643
+ raw.opacity(geometry.opacity ?? 1)
644
+ const options =
645
+ geometry.preserveAspectRatio === 'none'
646
+ ? { width: geometry.width, height: geometry.height }
647
+ : {
648
+ fit: [geometry.width, geometry.height] as [number, number],
649
+ align: 'center' as const,
650
+ valign: 'center' as const,
651
+ }
652
+ raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options)
653
+ raw.restore()
646
654
  }
647
655
 
648
656
  function blobToDataUrl(blob: Blob): Promise<string> {
@@ -52,6 +52,7 @@ export function buildFloorplanContext(
52
52
  siblings,
53
53
  parent,
54
54
  levelData,
55
+ sceneNodes: nodes,
55
56
  extensions: createFloorplanContextExtensions({
56
57
  automaticDimensions: viewState.automaticDimensions,
57
58
  metricNotation: viewState.metricNotation ?? 'meters',
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolveGenericFloorplanGridEventPoint } from './floorplan-grid-event-point'
3
+
4
+ const snapHalf = ([x, z]: [number, number]): [number, number] => [
5
+ Math.round(x / 0.5) * 0.5,
6
+ Math.round(z / 0.5) * 0.5,
7
+ ]
8
+
9
+ describe('resolveGenericFloorplanGridEventPoint', () => {
10
+ test('passes the raw pointer to registry tools so attachment can win before grid', () => {
11
+ expect(
12
+ resolveGenericFloorplanGridEventPoint({
13
+ point: [0.73, 0.32],
14
+ registryToolOwnsSnapping: true,
15
+ snap: snapHalf,
16
+ }),
17
+ ).toEqual([0.73, 0.32])
18
+ })
19
+
20
+ test('keeps the floorplan snap for interactions without a registry-owned resolver', () => {
21
+ expect(
22
+ resolveGenericFloorplanGridEventPoint({
23
+ point: [0.73, 0.32],
24
+ registryToolOwnsSnapping: false,
25
+ snap: snapHalf,
26
+ }),
27
+ ).toEqual([0.5, 0.5])
28
+ })
29
+ })
@@ -0,0 +1,13 @@
1
+ export type FloorplanGridEventPoint = [number, number]
2
+
3
+ export function resolveGenericFloorplanGridEventPoint({
4
+ point,
5
+ registryToolOwnsSnapping,
6
+ snap,
7
+ }: {
8
+ point: FloorplanGridEventPoint
9
+ registryToolOwnsSnapping: boolean
10
+ snap: (point: FloorplanGridEventPoint) => FloorplanGridEventPoint
11
+ }): FloorplanGridEventPoint {
12
+ return registryToolOwnsSnapping ? point : snap(point)
13
+ }