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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,23 +1,32 @@
1
1
  import {
2
2
  type AnyNode,
3
+ type AnyNodeId,
3
4
  bakePolicyOf,
4
5
  type DoorNode,
5
6
  emitter,
7
+ findLevelAncestorId,
8
+ type GeometryContext,
6
9
  getLevelDisplayName,
10
+ isNodeKindEnabled,
7
11
  isOperationDoorType,
8
12
  itemClipRegistry,
9
13
  type LevelNode,
14
+ levelBaseElevationAt,
10
15
  nodeRegistry,
11
16
  sceneRegistry,
17
+ useScene,
12
18
  type WindowNode,
13
19
  type ZoneNode,
14
20
  } from '@pascal-app/core'
15
21
  import {
16
22
  getPascalTextureRef,
23
+ isViewerPresentationTextureBorrowed,
17
24
  poseDoorMovingParts,
18
25
  poseWindowMovingParts,
19
26
  SCENE_LAYER,
20
27
  snapLevelsToTruePositions,
28
+ type ViewerPresentationContribution,
29
+ viewerPresentationRegistry,
21
30
  } from '@pascal-app/viewer'
22
31
  import type { Object3D } from 'three'
23
32
  import * as THREE from 'three'
@@ -26,7 +35,16 @@ import {
26
35
  type GLTFExporterPlugin,
27
36
  type GLTFWriter,
28
37
  } from 'three/examples/jsm/exporters/GLTFExporter.js'
29
- import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
38
+ import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
39
+ import { cloneExportUserData } from './export-user-data'
40
+ import {
41
+ type CompressedTextureDecompressor,
42
+ decompressCanonicalNormalMaps,
43
+ disposeExportResources,
44
+ GLASS_OPACITY_USERDATA,
45
+ normalizePortableScene,
46
+ normalizeViewerArtifactMaterials,
47
+ } from './portable-export'
30
48
 
31
49
  /**
32
50
  * Two TRS samples (closed vs open) differing by less than this are treated as
@@ -44,10 +62,72 @@ type SwingLeafMarker = { axis: 'y'; openRotationY: number }
44
62
  export type GlbExport = {
45
63
  scene: THREE.Object3D
46
64
  animations: THREE.AnimationClip[]
65
+ warnings: string[]
66
+ dispose: () => void
47
67
  }
48
68
 
49
69
  export type GlbExportOptions = {
50
70
  textures?: 'embed' | 'reference'
71
+ onlyVisible?: boolean
72
+ /** Omit these node kinds and their rendered subtrees before baking. */
73
+ excludedNodeTypes?: readonly string[]
74
+ /** Selected static viewer-presentation contributions; omitted means none. */
75
+ includedPresentationIds?: readonly string[]
76
+ /** Portable materials/geometry normalisation vs the baked viewer artifact. */
77
+ purpose?: 'portable' | 'viewer'
78
+ /**
79
+ * Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
80
+ * downloads (Blender turns them into actions); USDZ and print pass `none`
81
+ * because those formats freeze geometry and cannot play them.
82
+ */
83
+ animations?: 'keep' | 'none'
84
+ /** Called for actual lossy portable conversions discovered during preparation. */
85
+ onWarning?: (warning: string) => void
86
+ /** Reject retained node kinds whose export geometry can only be baked asynchronously. */
87
+ requireSynchronousBake?: boolean
88
+ /** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
89
+ decompressTexture?: CompressedTextureDecompressor
90
+ /**
91
+ * Shared GPU decompressor for every compressed texture in the export. The
92
+ * export entry points create one per export and dispose it; supplying your
93
+ * own keeps ownership with you.
94
+ */
95
+ textureUtils?: ExportTextureUtils
96
+ /**
97
+ * Wall-clock deadline for the whole export (preparation and serialisation),
98
+ * after which it rejects instead of leaving the caller waiting. Defaults to
99
+ * `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
100
+ */
101
+ timeoutMs?: number
102
+ }
103
+
104
+ export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
105
+
106
+ /**
107
+ * three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
108
+ * callbacks that carry no error path: a failed read or a callback the browser
109
+ * never invokes calls neither `onDone` nor `onError`, so without a deadline the
110
+ * returned promise stays pending forever and the export UI is stuck.
111
+ */
112
+ export function withExportDeadline<Result>(
113
+ promise: Promise<Result>,
114
+ timeoutMs: number,
115
+ format: string,
116
+ onTimeout?: () => void,
117
+ ): Promise<Result> {
118
+ if (!Number.isFinite(timeoutMs)) return promise
119
+ let timer: ReturnType<typeof setTimeout> | undefined
120
+ const deadline = new Promise<never>((_, reject) => {
121
+ timer = setTimeout(() => {
122
+ onTimeout?.()
123
+ reject(
124
+ new Error(
125
+ `${format} export timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} s. Try again with “Visible nodes only” to shrink the scene, or reload the page if it keeps failing.`,
126
+ ),
127
+ )
128
+ }, timeoutMs)
129
+ })
130
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
51
131
  }
52
132
 
53
133
  /** Resolve after the next couple of animation frames, giving React/R3F time to
@@ -117,127 +197,667 @@ export async function exportSceneToGlb(
117
197
  options: GlbExportOptions = {},
118
198
  ): Promise<ArrayBuffer> {
119
199
  const textureMode = options.textures ?? 'embed'
200
+ const textureUtils = options.textureUtils ?? createExportTextureUtils()
201
+ // Preparation awaits plugin bake hooks and presentation builders, so the
202
+ // deadline has to cover it as well as the serialisation.
203
+ const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
204
+ ...options,
205
+ textureUtils,
206
+ animations: options.animations ?? 'keep',
207
+ })
208
+ // Assigned inside the raced closure, so a plain `let` narrows to `never` in
209
+ // the `finally`.
210
+ const run: { prepared: GlbExport | null; abandoned: boolean } = {
211
+ prepared: null,
212
+ abandoned: false,
213
+ }
214
+ try {
215
+ return await withExportDeadline(
216
+ (async () => {
217
+ run.prepared = await preparation
218
+ // The race has already rejected; don't serialise a file nobody will get.
219
+ if (run.abandoned) throw new Error('GLB export abandoned')
220
+ for (const warning of run.prepared.warnings) options.onWarning?.(warning)
221
+ return serializePreparedSceneToGlb(run.prepared, {
222
+ textures: textureMode,
223
+ onlyVisible: options.onlyVisible,
224
+ textureUtils,
225
+ })
226
+ })(),
227
+ options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
228
+ 'GLB',
229
+ () => {
230
+ run.abandoned = true
231
+ },
232
+ )
233
+ } finally {
234
+ if (run.prepared) run.prepared.dispose()
235
+ else preparation.then((late) => late.dispose()).catch(() => {})
236
+ if (!options.textureUtils) await textureUtils.dispose()
237
+ }
238
+ }
239
+ /**
240
+ * Capture the live renderer synchronously, restore editor presentation, then do
241
+ * async offscreen material/presentation work against only the owned clone.
242
+ */
243
+ export async function preparePortableSceneFromViewer(
244
+ sceneGroup: Object3D,
245
+ nodes: Record<string, AnyNode>,
246
+ options: GlbExportOptions = {},
247
+ ): Promise<GlbExport> {
120
248
  emitter.emit('thumbnail:before-capture', undefined)
121
- // Snap levels to their true stacked positions (like thumbnail capture) so the
122
- // export always reflects the clean stacked building, regardless of the live
123
- // levelMode (exploded/solo) or an unsettled level lerp that could otherwise
124
- // bake a level at a stray offset.
125
249
  const restoreLevels = snapLevelsToTruePositions()
126
- let prepared: ReturnType<typeof prepareSceneForExport>
250
+ let preparation: SceneExportPreparation
127
251
  try {
128
- prepared =
129
- textureMode === 'reference'
130
- ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
131
- : prepareSceneForExport(sceneGroup, nodes)
252
+ preparation = startSceneExportPreparation(sceneGroup, nodes, {
253
+ ...options,
254
+ purpose: options.purpose ?? 'portable',
255
+ })
132
256
  } finally {
133
257
  restoreLevels()
134
258
  emitter.emit('thumbnail:after-capture', undefined)
135
259
  }
136
- const { scene: exportScene, animations } = prepared
260
+ return completeSceneExportPreparation(preparation)
261
+ }
137
262
 
263
+ /** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
264
+ export function serializePreparedSceneToGlb(
265
+ prepared: GlbExport,
266
+ options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
267
+ textureUtils: ExportTextureUtils
268
+ },
269
+ ): Promise<ArrayBuffer> {
138
270
  const exporter = new GLTFExporter()
139
- if (textureMode === 'reference') exporter.register(textureReferencePlugin)
140
- // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
141
- // those directly. WebGPUTextureUtils blits each one to RGBA on its own
142
- // offscreen renderer (passing the live renderer would resize/draw over the
143
- // editor canvas), letting the exporter embed standard textures.
144
- exporter.setTextureUtils(WebGPUTextureUtils)
271
+ if ((options.textures ?? 'embed') === 'reference') {
272
+ exporter.register(textureReferencePlugin)
273
+ }
274
+ exporter.setTextureUtils(options.textureUtils)
145
275
 
146
276
  return new Promise<ArrayBuffer>((resolve, reject) => {
147
277
  exporter.parse(
148
- exportScene,
278
+ prepared.scene,
149
279
  (gltf) => {
150
- resolve(gltf as ArrayBuffer)
280
+ // A failed final FileReader hands GLTFExporter's onDone `null`, which
281
+ // `new Blob([null])` would happily turn into a four-byte download.
282
+ if (gltf instanceof ArrayBuffer) resolve(gltf)
283
+ else reject(new Error('GLB export produced no data'))
151
284
  },
152
285
  (error) => {
153
- reject(error)
286
+ reject(error instanceof Error ? error : new Error(String(error)))
287
+ },
288
+ {
289
+ binary: true,
290
+ animations: prepared.animations,
291
+ onlyVisible: options.onlyVisible ?? true,
154
292
  },
155
- { binary: true, animations },
156
293
  )
157
294
  })
158
295
  }
159
296
 
297
+ type RegistryEntry = readonly [id: string, original: THREE.Object3D]
298
+
299
+ type SelectedPresentation = {
300
+ contribution: ViewerPresentationContribution
301
+ configuration: unknown
302
+ }
303
+
304
+ type SceneExportPreparation = {
305
+ scene: THREE.Object3D
306
+ nodes: Record<string, AnyNode>
307
+ options: GlbExportOptions
308
+ registryEntries: RegistryEntry[]
309
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>
310
+ builders: Map<
311
+ string,
312
+ {
313
+ sync: ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) | undefined
314
+ async: ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>) | undefined
315
+ }
316
+ >
317
+ geometryContext: Pick<GeometryContext, 'materials' | 'levelData'>
318
+ presentations: SelectedPresentation[]
319
+ }
320
+
160
321
  /**
161
- * Build an engine-agnostic export tree from the live scene graph. The result is
162
- * a standalone three.js scene plus glTF animation clips, ready for
163
- * `GLTFExporter` — it carries no Pascal runtime dependency.
164
- *
165
- * - Clones the source so live objects are never mutated.
166
- * - Converts WebGPU NodeMaterials to classic glTF-standard materials.
167
- * `GLTFExporter` only recognises `isMeshStandardMaterial` /
168
- * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
169
- * `isNodeMaterial` instead, so without this every surface exports as a blank
170
- * default material.
171
- * - Bakes open motions into glTF animation clips via kind-owned registry
172
- * hooks, plus the legacy door/window build-once + pose-at-t primitives
173
- * (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows).
174
- * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
175
- * survive the bake with no in-memory registry, and strips all other userData
176
- * so editor/runtime ephemera never leak into glTF extras.
322
+ * Build the legacy synchronous artifact used by geometry-only/print exports.
323
+ * The default remains viewer-purpose so existing internal baked-viewer clips
324
+ * are unchanged; portable downloads use the async entry point below.
177
325
  */
178
326
  export function prepareSceneForExport(
179
327
  source: THREE.Object3D,
180
328
  nodes: Record<string, AnyNode>,
181
329
  options: GlbExportOptions = {},
182
330
  ): GlbExport {
183
- const scene = source.clone(true)
184
- const cloneByOriginal = pairClones(source, scene)
185
-
186
- // Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy
187
- // reference assets stored elsewhere and aren't part of the compiled building.
188
- // Drop them from the artifact entirely — `/viewer` re-adds them from the scene
189
- // graph, gated by the project's public-visibility flags, so they never bloat
190
- // the shared GLB nor slip past those flags into a static public file.
191
- // (`'replace'` kinds are *kept*: static for portability, the viewer swaps them
192
- // for a live render.)
193
- for (const [id, original] of sceneRegistry.nodes) {
331
+ const preparation = startSceneExportPreparation(source, nodes, {
332
+ ...options,
333
+ purpose: options.purpose ?? 'viewer',
334
+ })
335
+ try {
336
+ replaceBakeGeometrySync(preparation)
337
+ return finishSceneExportPreparation(preparation)
338
+ } catch (error) {
339
+ disposeExportResources(preparation.scene)
340
+ throw error
341
+ }
342
+ }
343
+
344
+ /** Capture once, await async bake hooks, then normalize a static portable tree. */
345
+ export async function prepareSceneForExportAsync(
346
+ source: THREE.Object3D,
347
+ nodes: Record<string, AnyNode>,
348
+ options: GlbExportOptions = {},
349
+ ): Promise<GlbExport> {
350
+ const preparation = startSceneExportPreparation(source, nodes, {
351
+ ...options,
352
+ purpose: options.purpose ?? 'portable',
353
+ })
354
+ return completeSceneExportPreparation(preparation)
355
+ }
356
+
357
+ function startSceneExportPreparation(
358
+ source: THREE.Object3D,
359
+ inputNodes: Record<string, AnyNode>,
360
+ options: GlbExportOptions,
361
+ ): SceneExportPreparation {
362
+ const nodes = structuredClone(inputNodes)
363
+ const registryEntries = Array.from(sceneRegistry.nodes.entries())
364
+ const excludedNodeTypes = new Set(options.excludedNodeTypes)
365
+ const excludedObjects = new Set<THREE.Object3D>()
366
+ const builders: SceneExportPreparation['builders'] = new Map()
367
+ const sceneState = useScene.getState()
368
+
369
+ for (const [id, original] of registryEntries) {
194
370
  const node = nodes[id]
195
- if (node && bakePolicyOf(node.type) === 'strip') {
196
- cloneByOriginal.get(original)?.removeFromParent()
371
+ if (!node) continue
372
+ const installedPlugins = sceneState.hasExplicitPluginInstallState
373
+ ? sceneState.installedPlugins
374
+ : undefined
375
+ if (!isNodeKindEnabled(node.type, installedPlugins)) {
376
+ excludedObjects.add(original)
377
+ continue
378
+ }
379
+ const definition = nodeRegistry.get(node.type)
380
+ builders.set(id, {
381
+ sync: definition?.bakeGeometry as
382
+ | ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D)
383
+ | undefined,
384
+ async: definition?.bakeGeometryAsync as
385
+ | ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>)
386
+ | undefined,
387
+ })
388
+ if (bakePolicyOf(node.type) === 'strip' || excludedNodeTypes.has(node.type)) {
389
+ excludedObjects.add(original)
390
+ }
391
+ }
392
+
393
+ const selectedIds = new Set(options.includedPresentationIds ?? [])
394
+ const registeredPresentations = viewerPresentationRegistry.getSnapshot()
395
+ const presentations: SelectedPresentation[] = []
396
+ for (const id of selectedIds) {
397
+ const contribution = registeredPresentations.find((entry) => entry.id === id)
398
+ if (!contribution?.staticExport) {
399
+ throw new Error(`Static viewer presentation "${id}" is not registered`)
400
+ }
401
+ if (contribution.pluginId && !sceneState.installedPlugins.includes(contribution.pluginId)) {
402
+ throw new Error(`Static viewer presentation "${id}" belongs to an uninstalled plugin`)
403
+ }
404
+ presentations.push({
405
+ contribution,
406
+ configuration: contribution.configuration?.getSnapshot(),
407
+ })
408
+ }
409
+
410
+ const cloneByOriginal = new Map<THREE.Object3D, THREE.Object3D>()
411
+ const scene = cloneSceneForExport(source, excludedObjects, cloneByOriginal)
412
+ cloneSkinnedSkeletons(cloneByOriginal)
413
+ if (options.onlyVisible ?? true) {
414
+ pruneHiddenSceneNodes(cloneByOriginal, nodes, registryEntries)
415
+ }
416
+
417
+ return {
418
+ scene,
419
+ nodes,
420
+ options,
421
+ registryEntries,
422
+ cloneByOriginal,
423
+ builders,
424
+ geometryContext: {
425
+ levelData: undefined,
426
+ materials: structuredClone(sceneState.materials),
427
+ },
428
+ presentations,
429
+ }
430
+ }
431
+
432
+ /** Own a throwaway decompressor only when the caller supplied neither form. */
433
+ function resolveNormalMapDecompressor(options: GlbExportOptions): {
434
+ decompress: CompressedTextureDecompressor
435
+ dispose: () => Promise<void>
436
+ } {
437
+ if (options.decompressTexture) {
438
+ return { decompress: options.decompressTexture, dispose: async () => {} }
439
+ }
440
+ const utils = options.textureUtils ?? createExportTextureUtils()
441
+ return {
442
+ decompress: (texture) => utils.decompress(texture),
443
+ dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
444
+ }
445
+ }
446
+
447
+ async function completeSceneExportPreparation(
448
+ preparation: SceneExportPreparation,
449
+ ): Promise<GlbExport> {
450
+ const decompressor = resolveNormalMapDecompressor(preparation.options)
451
+ try {
452
+ await replaceBakeGeometryAsync(preparation)
453
+ await appendSelectedPresentations(preparation)
454
+ const prepared = finishSceneExportPreparation(preparation)
455
+ const { options } = preparation
456
+ const byReference = (options.textures ?? 'embed') === 'reference'
457
+ const normalizeOptions = {
458
+ preserveNormalMap: (texture: THREE.Texture) =>
459
+ byReference && getPascalTextureRef(texture) !== null,
197
460
  }
461
+ await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
462
+ prepared.warnings.push(
463
+ ...(options.purpose === 'viewer'
464
+ ? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
465
+ : normalizePortableScene(prepared.scene, normalizeOptions)),
466
+ )
467
+ return prepared
468
+ } catch (error) {
469
+ disposeExportResources(preparation.scene)
470
+ throw error
471
+ } finally {
472
+ await decompressor.dispose()
198
473
  }
474
+ }
199
475
 
200
- // Object3Ds that carry node identity — never strip these even when they sit on
201
- // a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
202
- // are stripped, but its identity node stays to carry the polygon that /viewer
203
- // reconstructs the room from.
476
+ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbExport {
477
+ const { scene, cloneByOriginal, nodes, options, registryEntries } = preparation
204
478
  const identityNodes = new Set<THREE.Object3D>()
205
- for (const original of sceneRegistry.nodes.values()) {
479
+ for (const [, original] of registryEntries) {
206
480
  const clone = cloneByOriginal.get(original)
207
481
  if (clone) identityNodes.add(clone)
208
482
  }
209
483
 
210
484
  pruneNonRenderableMeshes(scene, identityNodes)
211
485
  sanitizeMaterialGroups(scene, identityNodes)
212
- convertMaterials(scene, options.textures ?? 'embed')
486
+ convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
487
+
488
+ const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
489
+ const keepClips = options.animations
490
+ ? options.animations === 'keep'
491
+ : options.purpose === 'viewer'
492
+ const animation = keepClips
493
+ ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
494
+ : { clips: [], clipNamesByNode: new Map<string, string[]>() }
495
+ stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
496
+
497
+ let disposed = false
498
+ return {
499
+ scene,
500
+ animations: animation.clips,
501
+ warnings: [],
502
+ dispose: () => {
503
+ if (disposed) return
504
+ disposed = true
505
+ disposeExportResources(scene)
506
+ },
507
+ }
508
+ }
509
+
510
+ function replaceBakeGeometrySync(preparation: SceneExportPreparation): void {
511
+ for (const [id, original] of preparation.registryEntries) {
512
+ const node = preparation.nodes[id]
513
+ const builders = preparation.builders.get(id)
514
+ if (!node || !builders) continue
515
+ const cloned = preparation.cloneByOriginal.get(original)
516
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
517
+ if (preparation.options.requireSynchronousBake && builders.async && !builders.sync) {
518
+ throw new Error(
519
+ `Node kind "${node.type}" can only bake geometry asynchronously. Choose GLB/USDZ or exclude it from the export.`,
520
+ )
521
+ }
522
+ if (!builders.sync) continue
523
+ replaceBakedNode(
524
+ preparation,
525
+ id,
526
+ original,
527
+ builders.sync(
528
+ node,
529
+ buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext),
530
+ ),
531
+ 'bakeGeometry',
532
+ )
533
+ }
534
+ }
213
535
 
214
- const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
536
+ async function replaceBakeGeometryAsync(preparation: SceneExportPreparation): Promise<void> {
537
+ for (const [id, original] of preparation.registryEntries) {
538
+ const node = preparation.nodes[id]
539
+ const builders = preparation.builders.get(id)
540
+ if (!node || (!builders?.async && !builders?.sync)) continue
541
+ const cloned = preparation.cloneByOriginal.get(original)
542
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
543
+ const context = buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext)
544
+ const replacement = builders.async
545
+ ? await builders.async(node, context)
546
+ : builders.sync!(node, context)
547
+ replaceBakedNode(
548
+ preparation,
549
+ id,
550
+ original,
551
+ replacement,
552
+ builders.async ? 'bakeGeometryAsync' : 'bakeGeometry',
553
+ )
554
+ }
555
+ }
215
556
 
216
- stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
557
+ function replaceBakedNode(
558
+ preparation: SceneExportPreparation,
559
+ id: string,
560
+ original: THREE.Object3D,
561
+ replacement: THREE.Object3D,
562
+ hook: 'bakeGeometry' | 'bakeGeometryAsync',
563
+ ): void {
564
+ const cloned = preparation.cloneByOriginal.get(original)
565
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) return
566
+ const parent = cloned.parent
567
+ if (!parent) throw new Error(`Cannot replace root export geometry for node ${id}`)
568
+ if (replacement === cloned || replacement.parent) {
569
+ throw new Error(
570
+ `${hook} for ${preparation.nodes[id]?.type} must return a new detached Object3D`,
571
+ )
572
+ }
217
573
 
218
- return { scene, animations: clips }
574
+ const siblingIndex = parent.children.indexOf(cloned)
575
+ replacement.position.copy(cloned.position)
576
+ replacement.quaternion.copy(cloned.quaternion)
577
+ replacement.scale.copy(cloned.scale)
578
+ replacement.matrix.copy(cloned.matrix)
579
+ replacement.matrixAutoUpdate = cloned.matrixAutoUpdate
580
+ replacement.visible = cloned.visible
581
+ replacement.layers.mask = cloned.layers.mask
582
+ replacement.renderOrder = cloned.renderOrder
583
+ parent.remove(cloned)
584
+ parent.add(replacement)
585
+ const appendedIndex = parent.children.indexOf(replacement)
586
+
587
+ parent.children.splice(appendedIndex, 1)
588
+ parent.children.splice(siblingIndex, 0, replacement)
589
+ preparation.cloneByOriginal.set(original, replacement)
590
+ }
591
+ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
592
+ const ownedTextures = new Map<THREE.Texture, THREE.Texture>()
593
+ root.traverse((object) => {
594
+ const renderable = object as THREE.Mesh
595
+ if (!renderable.material) return
596
+ const materials = Array.isArray(renderable.material)
597
+ ? renderable.material
598
+ : [renderable.material]
599
+ for (const material of materials) {
600
+ const textured = material as THREE.Material & Record<string, unknown>
601
+ for (const slot of REFERENCE_MAP_SLOTS) {
602
+ const sourceTexture = textured[slot]
603
+ if (
604
+ !(sourceTexture instanceof THREE.Texture) ||
605
+ !isViewerPresentationTextureBorrowed(sourceTexture)
606
+ ) {
607
+ continue
608
+ }
609
+ let ownedTexture = ownedTextures.get(sourceTexture)
610
+ if (!ownedTexture) {
611
+ ownedTexture = sourceTexture.clone()
612
+ ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
613
+ ownedTexture.needsUpdate = true
614
+ ownedTextures.set(sourceTexture, ownedTexture)
615
+ }
616
+ textured[slot] = ownedTexture
617
+ }
618
+ }
619
+ })
219
620
  }
220
621
 
221
- /**
222
- * Pair each original Object3D with its clone. `clone(true)` builds children in
223
- * source order, so parallel pre-order traversals line up 1:1 — this is how we
224
- * map `sceneRegistry`'s live refs onto the export tree without mutating either.
225
- */
226
- function pairClones(
227
- source: THREE.Object3D,
228
- clone: THREE.Object3D,
622
+ async function appendSelectedPresentations(preparation: SceneExportPreparation): Promise<void> {
623
+ for (const { contribution, configuration } of preparation.presentations) {
624
+ const staticExport = contribution.staticExport!
625
+ const built = await staticExport.build({
626
+ nodes: preparation.nodes,
627
+ configuration,
628
+ onlyVisible: preparation.options.onlyVisible ?? true,
629
+ excludedNodeTypes: preparation.options.excludedNodeTypes ?? [],
630
+ })
631
+ if (!built) continue
632
+ if (built.parent) {
633
+ throw new Error(`Static viewer presentation "${contribution.id}" returned an attached root`)
634
+ }
635
+ ownBorrowedPresentationTextures(built)
636
+ const wrapper = new THREE.Group()
637
+ wrapper.name = contribution.id
638
+ wrapper.userData = {
639
+ pascalPresentationId: contribution.id,
640
+ label: staticExport.label,
641
+ }
642
+ wrapper.add(built)
643
+ preparation.scene.add(wrapper)
644
+ }
645
+ }
646
+
647
+ function buildBakeGeometryContext(
648
+ node: AnyNode,
649
+ nodes: Record<string, AnyNode>,
650
+ base: Pick<GeometryContext, 'materials' | 'levelData'>,
651
+ ): GeometryContext {
652
+ const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
653
+ const childIds = Array.isArray((node as { children?: AnyNodeId[] }).children)
654
+ ? (node as { children: AnyNodeId[] }).children
655
+ : []
656
+ const children = childIds
657
+ .map((id) => nodes[id])
658
+ .filter((child): child is AnyNode => child !== undefined)
659
+ const allNodes = node.parentId ? undefined : Object.values(nodes)
660
+ const parent = node.parentId
661
+ ? (nodes[node.parentId] ?? null)
662
+ : (allNodes?.find(
663
+ (candidate) =>
664
+ candidate.type === 'site' &&
665
+ 'children' in candidate &&
666
+ Array.isArray(candidate.children) &&
667
+ candidate.children.includes(node.id),
668
+ ) ?? null)
669
+ const siblingIds =
670
+ parent && Array.isArray((parent as { children?: AnyNodeId[] }).children)
671
+ ? (parent as { children: AnyNodeId[] }).children
672
+ : []
673
+ let siblings: AnyNode[]
674
+ if (parent?.type === 'site') {
675
+ const declaredChildren = new Set(siblingIds)
676
+ siblings = (allNodes ?? Object.values(nodes)).filter(
677
+ (candidate) =>
678
+ candidate.id !== node.id &&
679
+ candidate.type === node.type &&
680
+ (candidate.parentId === parent.id || declaredChildren.has(candidate.id)),
681
+ )
682
+ } else {
683
+ siblings = siblingIds
684
+ .filter((id) => id !== node.id)
685
+ .map((id) => nodes[id])
686
+ .filter((sibling): sibling is AnyNode => sibling?.type === node.type)
687
+ }
688
+ const levelId = findLevelAncestorId(node.id, nodes)
689
+ const levelBaseAt = (x: number, z: number) =>
690
+ levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0
691
+
692
+ return {
693
+ resolve,
694
+ children,
695
+ siblings,
696
+ parent,
697
+ levelBaseAt,
698
+ levelData: base.levelData,
699
+ materials: base.materials,
700
+ }
701
+ }
702
+
703
+ function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D): boolean {
704
+ let current: THREE.Object3D | null = object
705
+ while (current) {
706
+ if (current === ancestor) return true
707
+ current = current.parent
708
+ }
709
+ return false
710
+ }
711
+
712
+ function pruneHiddenSceneNodes(
713
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
714
+ nodes: Record<string, AnyNode>,
715
+ registryEntries: readonly RegistryEntry[],
716
+ ) {
717
+ const visibility = new Map<string, boolean>()
718
+ const declaredSiteParents = new Map<string, string>()
719
+ for (const node of Object.values(nodes)) {
720
+ if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
721
+ for (const childId of node.children) {
722
+ const child = nodes[childId]
723
+ if (child && !child.parentId && !declaredSiteParents.has(childId)) {
724
+ declaredSiteParents.set(childId, node.id)
725
+ }
726
+ }
727
+ }
728
+
729
+ const isVisible = (id: string, path: Set<string>): boolean => {
730
+ const cached = visibility.get(id)
731
+ if (cached !== undefined) return cached
732
+
733
+ const node = nodes[id]
734
+ if (!node) return true
735
+ if (node.visible === false) {
736
+ visibility.set(id, false)
737
+ return false
738
+ }
739
+ const parentId = node.parentId || declaredSiteParents.get(id)
740
+ if (!parentId || path.has(id)) {
741
+ visibility.set(id, true)
742
+ return true
743
+ }
744
+
745
+ path.add(id)
746
+ const visible = isVisible(parentId, path)
747
+ path.delete(id)
748
+ visibility.set(id, visible)
749
+ return visible
750
+ }
751
+
752
+ for (const [id, original] of registryEntries) {
753
+ if (isVisible(id, new Set())) continue
754
+ cloneByOriginal.get(original)?.removeFromParent()
755
+ }
756
+ }
757
+
758
+ function retainedClones(
759
+ root: THREE.Object3D,
760
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
229
761
  ): Map<THREE.Object3D, THREE.Object3D> {
230
- const originals: THREE.Object3D[] = []
231
- const clones: THREE.Object3D[] = []
232
- source.traverse((object) => originals.push(object))
233
- clone.traverse((object) => clones.push(object))
234
-
235
- const map = new Map<THREE.Object3D, THREE.Object3D>()
236
- for (let i = 0; i < originals.length; i++) {
237
- const target = clones[i]
238
- if (target) map.set(originals[i]!, target)
762
+ const retained = new Set<THREE.Object3D>()
763
+ root.traverse((object) => retained.add(object))
764
+ return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone)))
765
+ }
766
+
767
+ type ResourceCloneCache = {
768
+ geometries: Map<THREE.BufferGeometry, THREE.BufferGeometry>
769
+ materials: Map<THREE.Material, THREE.Material>
770
+ textures: Map<THREE.Texture, THREE.Texture>
771
+ }
772
+
773
+ /** Skip excluded subtrees before cloning large procedural instance buffers. */
774
+ function cloneSceneForExport(
775
+ source: THREE.Object3D,
776
+ excludedObjects: Set<THREE.Object3D>,
777
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
778
+ cache: ResourceCloneCache = {
779
+ geometries: new Map(),
780
+ materials: new Map(),
781
+ textures: new Map(),
782
+ },
783
+ ): THREE.Object3D {
784
+ if (excludedObjects.has(source)) return new THREE.Group()
785
+
786
+ const clone = source.clone(false)
787
+ clone.userData = cloneExportUserData(source.userData)
788
+ const renderable = source as THREE.Mesh
789
+ const renderableClone = clone as THREE.Mesh
790
+ if (renderable.geometry) {
791
+ let geometry = cache.geometries.get(renderable.geometry)
792
+ if (!geometry) {
793
+ geometry = renderable.geometry.clone()
794
+ cache.geometries.set(renderable.geometry, geometry)
795
+ }
796
+ renderableClone.geometry = geometry
797
+ }
798
+ if (renderable.material) {
799
+ const cloneMaterial = (material: THREE.Material): THREE.Material => {
800
+ let result = cache.materials.get(material)
801
+ if (result) return result
802
+ result = material.clone()
803
+ const textured = result as THREE.Material & Record<string, unknown>
804
+ for (const slot of REFERENCE_MAP_SLOTS) {
805
+ const texture = textured[slot]
806
+ if (!(texture instanceof THREE.Texture)) continue
807
+ let textureClone = cache.textures.get(texture)
808
+ if (!textureClone) {
809
+ textureClone = texture.clone()
810
+ textureClone.userData = cloneExportUserData(texture.userData)
811
+ textureClone.needsUpdate = true
812
+ cache.textures.set(texture, textureClone)
813
+ }
814
+ textured[slot] = textureClone
815
+ }
816
+ cache.materials.set(material, result)
817
+ return result
818
+ }
819
+ if (Array.isArray(renderable.material)) {
820
+ const materialSlots = renderable.material as Array<THREE.Material | null | undefined>
821
+ renderableClone.material = materialSlots.map((material) =>
822
+ material ? cloneMaterial(material) : material,
823
+ ) as THREE.Material[]
824
+ } else {
825
+ renderableClone.material = cloneMaterial(renderable.material)
826
+ }
827
+ }
828
+
829
+ cloneByOriginal.set(source, clone)
830
+ for (const child of source.children) {
831
+ if (!excludedObjects.has(child)) {
832
+ clone.add(cloneSceneForExport(child, excludedObjects, cloneByOriginal, cache))
833
+ }
834
+ }
835
+ return clone
836
+ }
837
+
838
+ function cloneSkinnedSkeletons(cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>): void {
839
+ for (const [original, cloned] of cloneByOriginal) {
840
+ const source = original as THREE.SkinnedMesh
841
+ const target = cloned as THREE.SkinnedMesh
842
+ if (!source.isSkinnedMesh) continue
843
+ const bones = source.skeleton.bones.map((bone) => {
844
+ const clonedBone = cloneByOriginal.get(bone)
845
+ if (!(clonedBone as THREE.Bone | undefined)?.isBone) {
846
+ throw new Error(
847
+ `Skinned mesh "${source.name}" references a bone outside its export subtree`,
848
+ )
849
+ }
850
+ return clonedBone as THREE.Bone
851
+ })
852
+ target.bindMode = source.bindMode
853
+ target.bind(
854
+ new THREE.Skeleton(
855
+ bones,
856
+ source.skeleton.boneInverses.map((inverse) => inverse.clone()),
857
+ ),
858
+ source.bindMatrix.clone(),
859
+ )
239
860
  }
240
- return map
241
861
  }
242
862
 
243
863
  // A single empty geometry shared by every container mesh we neutralise below —
@@ -296,8 +916,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
296
916
  renderable.material == null
297
917
  ) {
298
918
  if (object.children.length > 0) {
299
- renderable.geometry = EMPTY_GEOMETRY
300
- renderable.material = PLACEHOLDER_MATERIAL
919
+ renderable.geometry = EMPTY_GEOMETRY.clone()
920
+ renderable.material = PLACEHOLDER_MATERIAL.clone()
301
921
  } else {
302
922
  toRemove.push(object)
303
923
  }
@@ -315,12 +935,12 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
315
935
  Array.isArray(renderable.material) &&
316
936
  renderable.material.some((m) => m == null)
317
937
  ) {
318
- renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
938
+ renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
319
939
  }
320
940
  const mesh = object as THREE.Mesh
321
941
  if (!mesh.isMesh || isRenderableMesh(mesh)) return
322
942
  if (mesh.children.length > 0) {
323
- mesh.geometry = EMPTY_GEOMETRY
943
+ mesh.geometry = EMPTY_GEOMETRY.clone()
324
944
  } else {
325
945
  toRemove.push(mesh)
326
946
  }
@@ -343,8 +963,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
343
963
  * system's degenerate placeholder) — is neutralised like other
344
964
  * non-renderables (kept as a bare transform node, or removed if a leaf
345
965
  * that carries no node identity).
346
- * Geometry/material refs are shared with the live scene (`clone(true)` is
347
- * shallow for both), so repairs swap refs instead of mutating in place.
966
+ * The export tree owns its resources; repairs still avoid copying vertex
967
+ * buffers when replacing only group metadata.
348
968
  */
349
969
  function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
350
970
  const toRemove: THREE.Object3D[] = []
@@ -364,15 +984,15 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
364
984
  )
365
985
  if (validGroups.length === 0) {
366
986
  if (mesh.children.length > 0 || identityNodes.has(mesh)) {
367
- mesh.geometry = EMPTY_GEOMETRY
368
- mesh.material = PLACEHOLDER_MATERIAL
987
+ mesh.geometry = EMPTY_GEOMETRY.clone()
988
+ mesh.material = PLACEHOLDER_MATERIAL.clone()
369
989
  } else {
370
990
  toRemove.push(mesh)
371
991
  }
372
992
  return
373
993
  }
374
- // Only the group list needs repair — share the attribute/index refs
375
- // instead of geometry.clone(), which deep-copies every vertex buffer.
994
+ // Only the group list needs repair; reuse export-owned attributes rather
995
+ // than duplicating every vertex buffer.
376
996
  if (validGroups.length !== groups.length) {
377
997
  const geometry = new THREE.BufferGeometry()
378
998
  geometry.index = mesh.geometry.index
@@ -385,7 +1005,7 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
385
1005
  geometry.groups = validGroups.map((g) => ({ ...g }))
386
1006
  mesh.geometry = geometry
387
1007
  }
388
- mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
1008
+ mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
389
1009
  })
390
1010
  for (const object of toRemove) {
391
1011
  object.removeFromParent()
@@ -435,29 +1055,44 @@ const REFERENCE_MAP_SLOTS = [
435
1055
  'anisotropyMap',
436
1056
  ] as const
437
1057
 
438
- function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') {
1058
+ function convertMaterials(
1059
+ root: THREE.Object3D,
1060
+ textureMode: 'embed' | 'reference',
1061
+ purpose: 'portable' | 'viewer',
1062
+ ) {
439
1063
  const cache = new Map<THREE.Material, THREE.Material>()
440
1064
  const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
441
1065
  root.traverse((object) => {
442
1066
  const mesh = object as THREE.Mesh
443
1067
  if (!mesh.isMesh) return
444
1068
  const material = mesh.material
445
- if (Array.isArray(material)) {
446
- mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache))
447
- return
448
- }
449
- // glTF has no BackSide — GLTFExporter renders the *front* face for any
450
- // non-DoubleSide material, which inverts a BackSide surface (e.g. the
451
- // ceiling underside, meant to be seen from the room). Flip the mesh winding
452
- // so the intended face shows with the FrontSide material convertMaterial
453
- // produces. Per-mesh geometry clone keeps shared geometry untouched.
1069
+ const materialArray = Array.isArray(material) ? material : [material]
454
1070
  if (
455
- (material as { isNodeMaterial?: boolean }).isNodeMaterial &&
456
- material.side === THREE.BackSide
1071
+ purpose === 'viewer' &&
1072
+ materialArray.length === 1 &&
1073
+ (materialArray[0] as { isNodeMaterial?: boolean }).isNodeMaterial &&
1074
+ materialArray[0]!.side === THREE.BackSide
457
1075
  ) {
458
1076
  mesh.geometry = flipGeometryWinding(mesh.geometry)
459
1077
  }
460
- mesh.material = convertMaterial(material, cache, textureMode, placeholderCache)
1078
+ const converted = materialArray.map((entry) =>
1079
+ convertMaterial(entry, cache, textureMode, placeholderCache, purpose),
1080
+ )
1081
+ const color = mesh.geometry.getAttribute('color')
1082
+ let hasVertexAlpha = false
1083
+ if (color?.itemSize === 4) {
1084
+ for (let index = 0; index < color.count; index++) {
1085
+ if (color.getW(index) < 1) {
1086
+ hasVertexAlpha = true
1087
+ break
1088
+ }
1089
+ }
1090
+ }
1091
+ for (const entry of converted) {
1092
+ const textured = entry as THREE.Material & { alphaMap?: THREE.Texture | null }
1093
+ if (hasVertexAlpha || textured.alphaMap) entry.transparent = true
1094
+ }
1095
+ mesh.material = Array.isArray(material) ? converted : converted[0]!
461
1096
  })
462
1097
  }
463
1098
 
@@ -509,6 +1144,7 @@ function convertMaterial(
509
1144
  cache: Map<THREE.Material, THREE.Material>,
510
1145
  textureMode: 'embed' | 'reference',
511
1146
  placeholderCache: Map<THREE.Texture, THREE.Texture>,
1147
+ purpose: 'portable' | 'viewer',
512
1148
  ): THREE.Material {
513
1149
  const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
514
1150
  if (!isNodeMaterial) {
@@ -525,7 +1161,8 @@ function convertMaterial(
525
1161
  if (cached) return cached
526
1162
 
527
1163
  const src = material as THREE.Material & Record<string, unknown>
528
- const target = new THREE.MeshStandardMaterial()
1164
+ const glass = purpose === 'portable' && isPortableGlass(material)
1165
+ const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
529
1166
 
530
1167
  target.name = material.name
531
1168
  if (src.color instanceof THREE.Color) target.color.copy(src.color)
@@ -543,7 +1180,8 @@ function convertMaterial(
543
1180
  target.opacity = material.opacity
544
1181
  // BackSide is flipped to FrontSide (with the mesh winding reversed in
545
1182
  // convertMaterials) because glTF has no back-face-only mode.
546
- target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
1183
+ target.side =
1184
+ purpose === 'viewer' && material.side === THREE.BackSide ? THREE.FrontSide : material.side
547
1185
  target.alphaTest = material.alphaTest
548
1186
  target.depthWrite = material.depthWrite
549
1187
  target.depthTest = material.depthTest
@@ -560,12 +1198,44 @@ function convertMaterial(
560
1198
  }
561
1199
  }
562
1200
 
1201
+ if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
563
1202
  if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
564
1203
 
565
1204
  cache.set(material, target)
566
1205
  return target
567
1206
  }
568
1207
 
1208
+ /** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
1209
+ * see-through surface below this opacity is glass, not tinted plastic. */
1210
+ const GLASS_OPACITY_THRESHOLD = 0.6
1211
+
1212
+ function isPortableGlass(material: THREE.Material): boolean {
1213
+ const src = material as THREE.Material & Record<string, unknown>
1214
+ return (
1215
+ material.transparent &&
1216
+ material.opacity < GLASS_OPACITY_THRESHOLD &&
1217
+ !(src.map instanceof THREE.Texture)
1218
+ )
1219
+ }
1220
+
1221
+ /**
1222
+ * The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
1223
+ * carry; a plain alpha blend lands in every other tool as a blue film. Real
1224
+ * transmission (KHR_materials_transmission + ior) is what Blender, Unity and
1225
+ * Unreal all render as glass. The authored opacity becomes the tint strength.
1226
+ */
1227
+ function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
1228
+ target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
1229
+ target.transmission = 1
1230
+ target.ior = 1.5
1231
+ target.roughness = Math.min(target.roughness, 0.15)
1232
+ target.metalness = 0
1233
+ target.transparent = false
1234
+ target.opacity = 1
1235
+ target.depthWrite = true
1236
+ target.userData[GLASS_OPACITY_USERDATA] = source.opacity
1237
+ }
1238
+
569
1239
  function replaceReferencedTextures(
570
1240
  material: THREE.Material,
571
1241
  placeholderCache: Map<THREE.Texture, THREE.Texture>,
@@ -649,11 +1319,12 @@ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
649
1319
  function bakeAnimationClips(
650
1320
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
651
1321
  nodes: Record<string, AnyNode>,
1322
+ registryEntries: readonly RegistryEntry[],
652
1323
  ): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
653
1324
  const clips: THREE.AnimationClip[] = []
654
1325
  const clipNamesByNode = new Map<string, string[]>()
655
1326
 
656
- for (const [id, original] of sceneRegistry.nodes) {
1327
+ for (const [id, original] of registryEntries) {
657
1328
  const node = nodes[id]
658
1329
  const target = cloneByOriginal.get(original)
659
1330
  if (!node || !target) continue
@@ -1006,12 +1677,16 @@ function stampIdentity(
1006
1677
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
1007
1678
  nodes: Record<string, AnyNode>,
1008
1679
  clipNamesByNode: Map<string, string[]>,
1680
+ registryEntries: readonly RegistryEntry[],
1009
1681
  ) {
1010
1682
  scene.traverse((object) => {
1011
- object.userData = {}
1683
+ const presentationId = object.userData.pascalPresentationId
1684
+ const label = object.userData.label
1685
+ object.userData =
1686
+ typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {}
1012
1687
  })
1013
1688
 
1014
- for (const [id, original] of sceneRegistry.nodes) {
1689
+ for (const [id, original] of registryEntries) {
1015
1690
  const node = nodes[id]
1016
1691
  const target = cloneByOriginal.get(original)
1017
1692
  if (!node || !target) continue