@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
@@ -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,8 +217,9 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
183
217
  annotationVisibility,
184
218
  drawingType,
185
219
  wallDimensionReference,
220
+ installedPlugins,
186
221
  )
187
- const schedules = collectFloorplanSchedules(nodes, level.id, unit)
222
+ const schedules = collectFloorplanSchedules(nodes, level.id, unit, scope)
188
223
  if (geometries.length === 0 && schedules.length === 0) continue
189
224
  const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
190
225
 
@@ -270,6 +305,7 @@ export function collectFloorplanSchedules(
270
305
  nodes: Record<string, AnyNode>,
271
306
  levelId: AnyNodeId,
272
307
  unit: 'metric' | 'imperial',
308
+ scope: FloorplanExportScope = 'full',
273
309
  ): FloorplanSchedule[] {
274
310
  const siblingsByType = new Map<string, AnyNode[]>()
275
311
  const visit = (id: AnyNodeId) => {
@@ -289,6 +325,9 @@ export function collectFloorplanSchedules(
289
325
  for (const [kind, definition] of nodeRegistry.entries()) {
290
326
  const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
291
327
  if (!scheduleContribution) continue
328
+ // Same scope gate as geometry: a structure-only PDF must not list rooms
329
+ // or other site-category contributors whose plan geometry was excluded.
330
+ if (!isFloorplanNodeInExportScope(definition, scope)) continue
292
331
  const siblings = siblingsByType.get(kind) ?? []
293
332
  const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
294
333
  if (schedule && schedule.rows.length > 0) schedules.push(schedule)
@@ -721,7 +760,7 @@ function applyFloorplanViewport(
721
760
  mounted.svg.insertBefore(background, mounted.svg.firstChild)
722
761
  }
723
762
 
724
- function collectFloorplanGeometry(
763
+ export function collectFloorplanGeometry(
725
764
  nodes: Record<string, AnyNode>,
726
765
  levelId: AnyNodeId,
727
766
  scope: FloorplanExportScope,
@@ -730,10 +769,11 @@ function collectFloorplanGeometry(
730
769
  annotationVisibility: FloorplanAnnotationVisibility,
731
770
  drawingType: ConstructionDrawingType,
732
771
  wallDimensionReference: FloorplanWallDimensionReference,
772
+ installedPlugins: readonly string[],
733
773
  ): ExportGeometry[] {
734
774
  const noLiveOverrides = new Map<string, LiveNodeOverrides>()
735
775
  const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
736
- const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = []
776
+ const entries: ExportGeometryEntry[] = []
737
777
 
738
778
  const visit = (id: AnyNodeId) => {
739
779
  const node = nodes[id]
@@ -747,7 +787,7 @@ function collectFloorplanGeometry(
747
787
  if (
748
788
  def?.floorplan &&
749
789
  isFloorplanNodeVisible(node) &&
750
- (scope === 'full' || def.category === 'structure')
790
+ isFloorplanNodeInExportScope(def, scope)
751
791
  ) {
752
792
  const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
753
793
  if (drawingNode) entries.push({ id, node: drawingNode })
@@ -758,31 +798,74 @@ function collectFloorplanGeometry(
758
798
  visit(levelId)
759
799
 
760
800
  const activeLevelNode = nodes[levelId]
801
+ const collectedIds = new Set(entries.map((entry) => entry.id))
761
802
  if (activeLevelNode) {
762
- const collectedIds = new Set(entries.map((entry) => entry.id))
763
803
  for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) {
764
804
  const definition = nodeRegistry.get(linked.node.type)
765
- if (
766
- isFloorplanNodeVisible(linked.node) &&
767
- (scope === 'full' || definition?.category === 'structure')
768
- ) {
805
+ if (isFloorplanNodeVisible(linked.node) && isFloorplanNodeInExportScope(definition, scope)) {
769
806
  const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType)
770
807
  if (drawingNode) {
771
808
  entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode })
809
+ collectedIds.add(linked.id)
772
810
  }
773
811
  }
774
812
  }
775
813
  }
776
814
 
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))
815
+ const buildingId = resolveBuildingForLevel(levelId, nodes as Record<AnyNodeId, AnyNode>)
816
+ const buildingNode = buildingId ? nodes[buildingId] : undefined
817
+ const siteNode =
818
+ buildingNode?.type === 'building' && buildingNode.parentId
819
+ ? nodes[buildingNode.parentId as AnyNodeId]
820
+ : undefined
821
+ if (siteNode?.type === 'site' && buildingNode?.type === 'building') {
822
+ const siteScopedNodes = collectDirectFloorplanScopeNodes(nodes, siteNode, 'site')
823
+ const outputTransform = siteToFloorplanTransform(
824
+ buildingNode.position,
825
+ buildingNode.rotation[1],
826
+ )
827
+ for (const node of siteScopedNodes) {
828
+ if (collectedIds.has(node.id) || !isNodeKindEnabled(node.type, installedPlugins)) continue
829
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
830
+ if (!drawingNode) continue
831
+ const definition = nodeRegistry.get(drawingNode.type)
832
+ if (
833
+ !definition?.floorplan ||
834
+ !isFloorplanNodeInExportScope(definition, scope) ||
835
+ !isFloorplanHierarchyVisible(drawingNode, nodes, noLiveOverrides, siteNode.id)
836
+ ) {
837
+ continue
838
+ }
839
+ const childIds = 'children' in node ? node.children : undefined
840
+ const children = Array.isArray(childIds)
841
+ ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
842
+ : []
843
+ const siblings = siteScopedNodes.filter(
844
+ (candidate) => candidate.id !== node.id && candidate.type === node.type,
845
+ )
846
+ entries.push({
847
+ id: node.id,
848
+ node: drawingNode,
849
+ context: { children, siblings, parent: siteNode, outputTransform },
850
+ scopeRank: -1,
851
+ })
852
+ collectedIds.add(node.id)
853
+ }
854
+ }
855
+
856
+ // Document order is paint order. Site context sits below level
857
+ // architecture, then each scope retains the live layer's z-order.
858
+ entries.sort(
859
+ (a, b) =>
860
+ (a.scopeRank ?? 0) - (b.scopeRank ?? 0) ||
861
+ floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type),
862
+ )
780
863
 
781
864
  // One-shot per-type cache for `computeFloorplanLevelData`; value type is
782
865
  // module-private to the registry layer, so let it infer.
783
866
  const levelDataCache = new Map()
784
867
  const out: ExportGeometry[] = []
785
- for (const { id, node, parentOverride } of entries) {
868
+ for (const { id, node, parentOverride, context } of entries) {
786
869
  const builder = nodeRegistry.get(node.type)?.floorplan
787
870
  if (!builder) continue
788
871
  const levelData = getFloorplanLevelData(
@@ -803,9 +886,25 @@ function collectFloorplanGeometry(
803
886
  ),
804
887
  levelData,
805
888
  )
806
- const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
807
- const geometry = builder(node, ctx)
808
- if (!geometry) continue
889
+ const ctx = context
890
+ ? {
891
+ ...baseContext,
892
+ children: context.children,
893
+ siblings: context.siblings,
894
+ parent: context.parent,
895
+ }
896
+ : parentOverride
897
+ ? { ...baseContext, parent: parentOverride }
898
+ : baseContext
899
+ const builtGeometry = builder(node, ctx)
900
+ if (!builtGeometry) continue
901
+ const geometry = context
902
+ ? {
903
+ kind: 'group' as const,
904
+ children: [builtGeometry],
905
+ transform: context.outputTransform,
906
+ }
907
+ : builtGeometry
809
908
  const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility)
810
909
  if (!visibleGeometry) continue
811
910
  const { base, overlay } = splitFloorplanOverlay(visibleGeometry)
@@ -926,9 +1025,10 @@ function combineGeometryList(
926
1025
  * Levels to export, ordered bottom-to-top. The active building (the building
927
1026
  * owning the selected level, or the first one found) contributes all of its
928
1027
  * level children; if there is no building wrapper we fall back to the single
929
- * resolved level.
1028
+ * resolved level. Roof support levels are excluded: they are not occupied
1029
+ * stories, so they do not get a floor-plan page.
930
1030
  */
931
- function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
1031
+ export function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
932
1032
  const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
933
1033
  const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
934
1034
  if (!activeLevelId) return []
@@ -943,6 +1043,7 @@ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
943
1043
  levelNodes = node ? [node] : []
944
1044
  }
945
1045
 
1046
+ levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof')
946
1047
  levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
947
1048
  return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
948
1049
  }
@@ -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> {
@@ -14,6 +14,8 @@ export type FloorplanViewState = {
14
14
  highlighted: boolean
15
15
  hovered: boolean
16
16
  moving: boolean
17
+ focusedUnitId?: string
18
+ focusedUnitMemberIds?: readonly string[]
17
19
  palette: FloorplanPalette | undefined
18
20
  }
19
21
 
@@ -52,6 +54,7 @@ export function buildFloorplanContext(
52
54
  siblings,
53
55
  parent,
54
56
  levelData,
57
+ sceneNodes: nodes,
55
58
  extensions: createFloorplanContextExtensions({
56
59
  automaticDimensions: viewState.automaticDimensions,
57
60
  metricNotation: viewState.metricNotation ?? 'meters',
@@ -65,6 +68,8 @@ export function buildFloorplanContext(
65
68
  highlighted: viewState.highlighted,
66
69
  hovered: viewState.hovered,
67
70
  moving: viewState.moving,
71
+ focusedUnitId: viewState.focusedUnitId,
72
+ focusedUnitMemberIds: viewState.focusedUnitMemberIds,
68
73
  palette: viewState.palette,
69
74
  }
70
75
  : undefined,
@@ -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
+ }
@@ -8,6 +8,11 @@ import {
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
10
  import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
11
+ import {
12
+ surfaceAttachmentId,
13
+ surfaceAttachmentUpdates,
14
+ updateSurfaceNode,
15
+ } from './surface-attachment'
11
16
 
12
17
  function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
13
18
  return {
@@ -131,16 +136,19 @@ export function commitFreshPlacementSubtree(
131
136
  parentId,
132
137
  })
133
138
 
139
+ const surfaceId = surfaceAttachmentId(subtree.root)
134
140
  const temporal = useScene.temporal.getState()
135
141
  const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
136
142
  if (wasTracking) temporal.pause()
143
+ updateSurfaceNode(rootId, {}, null)
137
144
  useScene.getState().deleteNode(rootId)
138
145
  temporal.resume()
139
- useScene
140
- .getState()
141
- .createNodes(
142
- cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
143
- )
146
+ useScene.getState().applyNodeChanges({
147
+ create: cloned.nodes.map((node, index) =>
148
+ index === 0 && parentId ? { node, parentId } : { node },
149
+ ),
150
+ update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId),
151
+ })
144
152
  if (!wasTracking) temporal.pause()
145
153
 
146
154
  return cloned.rootId