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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,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'
@@ -27,6 +36,11 @@ import {
27
36
  type GLTFWriter,
28
37
  } from 'three/examples/jsm/exporters/GLTFExporter.js'
29
38
  import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
39
+ import {
40
+ disposeExportResources,
41
+ normalizePortableScene,
42
+ normalizeViewerArtifactMaterials,
43
+ } from './portable-export'
30
44
 
31
45
  /**
32
46
  * Two TRS samples (closed vs open) differing by less than this are treated as
@@ -44,10 +58,23 @@ type SwingLeafMarker = { axis: 'y'; openRotationY: number }
44
58
  export type GlbExport = {
45
59
  scene: THREE.Object3D
46
60
  animations: THREE.AnimationClip[]
61
+ warnings: string[]
62
+ dispose: () => void
47
63
  }
48
64
 
49
65
  export type GlbExportOptions = {
50
66
  textures?: 'embed' | 'reference'
67
+ onlyVisible?: boolean
68
+ /** Omit these node kinds and their rendered subtrees before baking. */
69
+ excludedNodeTypes?: readonly string[]
70
+ /** Selected static viewer-presentation contributions; omitted means none. */
71
+ includedPresentationIds?: readonly string[]
72
+ /** Portable downloads are static; the baked viewer retains internal clips. */
73
+ purpose?: 'portable' | 'viewer'
74
+ /** Called for actual lossy portable conversions discovered during preparation. */
75
+ onWarning?: (warning: string) => void
76
+ /** Reject retained node kinds whose export geometry can only be baked asynchronously. */
77
+ requireSynchronousBake?: boolean
51
78
  }
52
79
 
53
80
  /** Resolve after the next couple of animation frames, giving React/R3F time to
@@ -117,127 +144,606 @@ export async function exportSceneToGlb(
117
144
  options: GlbExportOptions = {},
118
145
  ): Promise<ArrayBuffer> {
119
146
  const textureMode = options.textures ?? 'embed'
147
+ const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, options)
148
+ for (const warning of prepared.warnings) options.onWarning?.(warning)
149
+ try {
150
+ return await serializePreparedSceneToGlb(prepared, {
151
+ textures: textureMode,
152
+ onlyVisible: options.onlyVisible,
153
+ })
154
+ } finally {
155
+ prepared.dispose()
156
+ }
157
+ }
158
+ /**
159
+ * Capture the live renderer synchronously, restore editor presentation, then do
160
+ * async offscreen material/presentation work against only the owned clone.
161
+ */
162
+ export async function preparePortableSceneFromViewer(
163
+ sceneGroup: Object3D,
164
+ nodes: Record<string, AnyNode>,
165
+ options: GlbExportOptions = {},
166
+ ): Promise<GlbExport> {
120
167
  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
168
  const restoreLevels = snapLevelsToTruePositions()
126
- let prepared: ReturnType<typeof prepareSceneForExport>
169
+ let preparation: SceneExportPreparation
127
170
  try {
128
- prepared =
129
- textureMode === 'reference'
130
- ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
131
- : prepareSceneForExport(sceneGroup, nodes)
171
+ preparation = startSceneExportPreparation(sceneGroup, nodes, {
172
+ ...options,
173
+ purpose: options.purpose ?? 'portable',
174
+ })
132
175
  } finally {
133
176
  restoreLevels()
134
177
  emitter.emit('thumbnail:after-capture', undefined)
135
178
  }
136
- const { scene: exportScene, animations } = prepared
179
+ return completeSceneExportPreparation(preparation)
180
+ }
137
181
 
182
+ export function serializePreparedSceneToGlb(
183
+ prepared: GlbExport,
184
+ options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> = {},
185
+ ): Promise<ArrayBuffer> {
138
186
  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.
187
+ if ((options.textures ?? 'embed') === 'reference') {
188
+ exporter.register(textureReferencePlugin)
189
+ }
144
190
  exporter.setTextureUtils(WebGPUTextureUtils)
145
191
 
146
192
  return new Promise<ArrayBuffer>((resolve, reject) => {
147
193
  exporter.parse(
148
- exportScene,
194
+ prepared.scene,
149
195
  (gltf) => {
150
196
  resolve(gltf as ArrayBuffer)
151
197
  },
152
198
  (error) => {
153
199
  reject(error)
154
200
  },
155
- { binary: true, animations },
201
+ {
202
+ binary: true,
203
+ animations: prepared.animations,
204
+ onlyVisible: options.onlyVisible ?? true,
205
+ },
156
206
  )
157
207
  })
158
208
  }
159
209
 
210
+ type RegistryEntry = readonly [id: string, original: THREE.Object3D]
211
+
212
+ type SelectedPresentation = {
213
+ contribution: ViewerPresentationContribution
214
+ configuration: unknown
215
+ }
216
+
217
+ type SceneExportPreparation = {
218
+ scene: THREE.Object3D
219
+ nodes: Record<string, AnyNode>
220
+ options: GlbExportOptions
221
+ registryEntries: RegistryEntry[]
222
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>
223
+ builders: Map<
224
+ string,
225
+ {
226
+ sync: ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D) | undefined
227
+ async: ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>) | undefined
228
+ }
229
+ >
230
+ geometryContext: Pick<GeometryContext, 'materials' | 'levelData'>
231
+ presentations: SelectedPresentation[]
232
+ }
233
+
160
234
  /**
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.
235
+ * Build the legacy synchronous artifact used by geometry-only/print exports.
236
+ * The default remains viewer-purpose so existing internal baked-viewer clips
237
+ * are unchanged; portable downloads use the async entry point below.
177
238
  */
178
239
  export function prepareSceneForExport(
179
240
  source: THREE.Object3D,
180
241
  nodes: Record<string, AnyNode>,
181
242
  options: GlbExportOptions = {},
182
243
  ): 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) {
244
+ const preparation = startSceneExportPreparation(source, nodes, {
245
+ ...options,
246
+ purpose: options.purpose ?? 'viewer',
247
+ })
248
+ try {
249
+ replaceBakeGeometrySync(preparation)
250
+ return finishSceneExportPreparation(preparation)
251
+ } catch (error) {
252
+ disposeExportResources(preparation.scene)
253
+ throw error
254
+ }
255
+ }
256
+
257
+ /** Capture once, await async bake hooks, then normalize a static portable tree. */
258
+ export async function prepareSceneForExportAsync(
259
+ source: THREE.Object3D,
260
+ nodes: Record<string, AnyNode>,
261
+ options: GlbExportOptions = {},
262
+ ): Promise<GlbExport> {
263
+ const preparation = startSceneExportPreparation(source, nodes, {
264
+ ...options,
265
+ purpose: options.purpose ?? 'portable',
266
+ })
267
+ return completeSceneExportPreparation(preparation)
268
+ }
269
+
270
+ function startSceneExportPreparation(
271
+ source: THREE.Object3D,
272
+ inputNodes: Record<string, AnyNode>,
273
+ options: GlbExportOptions,
274
+ ): SceneExportPreparation {
275
+ const nodes = structuredClone(inputNodes)
276
+ const registryEntries = Array.from(sceneRegistry.nodes.entries())
277
+ const excludedNodeTypes = new Set(options.excludedNodeTypes)
278
+ const excludedObjects = new Set<THREE.Object3D>()
279
+ const builders: SceneExportPreparation['builders'] = new Map()
280
+ const sceneState = useScene.getState()
281
+
282
+ for (const [id, original] of registryEntries) {
194
283
  const node = nodes[id]
195
- if (node && bakePolicyOf(node.type) === 'strip') {
196
- cloneByOriginal.get(original)?.removeFromParent()
284
+ if (!node) continue
285
+ const installedPlugins = sceneState.hasExplicitPluginInstallState
286
+ ? sceneState.installedPlugins
287
+ : undefined
288
+ if (!isNodeKindEnabled(node.type, installedPlugins)) {
289
+ excludedObjects.add(original)
290
+ continue
291
+ }
292
+ const definition = nodeRegistry.get(node.type)
293
+ builders.set(id, {
294
+ sync: definition?.bakeGeometry as
295
+ | ((node: AnyNode, ctx: GeometryContext) => THREE.Object3D)
296
+ | undefined,
297
+ async: definition?.bakeGeometryAsync as
298
+ | ((node: AnyNode, ctx: GeometryContext) => Promise<THREE.Object3D>)
299
+ | undefined,
300
+ })
301
+ if (bakePolicyOf(node.type) === 'strip' || excludedNodeTypes.has(node.type)) {
302
+ excludedObjects.add(original)
197
303
  }
198
304
  }
199
305
 
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.
306
+ const selectedIds = new Set(options.includedPresentationIds ?? [])
307
+ const registeredPresentations = viewerPresentationRegistry.getSnapshot()
308
+ const presentations: SelectedPresentation[] = []
309
+ for (const id of selectedIds) {
310
+ const contribution = registeredPresentations.find((entry) => entry.id === id)
311
+ if (!contribution?.staticExport) {
312
+ throw new Error(`Static viewer presentation "${id}" is not registered`)
313
+ }
314
+ if (contribution.pluginId && !sceneState.installedPlugins.includes(contribution.pluginId)) {
315
+ throw new Error(`Static viewer presentation "${id}" belongs to an uninstalled plugin`)
316
+ }
317
+ presentations.push({
318
+ contribution,
319
+ configuration: contribution.configuration?.getSnapshot(),
320
+ })
321
+ }
322
+
323
+ const cloneByOriginal = new Map<THREE.Object3D, THREE.Object3D>()
324
+ const scene = cloneSceneForExport(source, excludedObjects, cloneByOriginal)
325
+ cloneSkinnedSkeletons(cloneByOriginal)
326
+ if (options.onlyVisible ?? true) {
327
+ pruneHiddenSceneNodes(cloneByOriginal, nodes, registryEntries)
328
+ }
329
+
330
+ return {
331
+ scene,
332
+ nodes,
333
+ options,
334
+ registryEntries,
335
+ cloneByOriginal,
336
+ builders,
337
+ geometryContext: {
338
+ levelData: undefined,
339
+ materials: structuredClone(sceneState.materials),
340
+ },
341
+ presentations,
342
+ }
343
+ }
344
+
345
+ async function completeSceneExportPreparation(
346
+ preparation: SceneExportPreparation,
347
+ ): Promise<GlbExport> {
348
+ try {
349
+ await replaceBakeGeometryAsync(preparation)
350
+ await appendSelectedPresentations(preparation)
351
+ const prepared = finishSceneExportPreparation(preparation)
352
+ prepared.warnings.push(
353
+ ...(preparation.options.purpose === 'viewer'
354
+ ? normalizeViewerArtifactMaterials(prepared.scene)
355
+ : normalizePortableScene(prepared.scene)),
356
+ )
357
+ return prepared
358
+ } catch (error) {
359
+ disposeExportResources(preparation.scene)
360
+ throw error
361
+ }
362
+ }
363
+
364
+ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbExport {
365
+ const { scene, cloneByOriginal, nodes, options, registryEntries } = preparation
204
366
  const identityNodes = new Set<THREE.Object3D>()
205
- for (const original of sceneRegistry.nodes.values()) {
367
+ for (const [, original] of registryEntries) {
206
368
  const clone = cloneByOriginal.get(original)
207
369
  if (clone) identityNodes.add(clone)
208
370
  }
209
371
 
210
372
  pruneNonRenderableMeshes(scene, identityNodes)
211
373
  sanitizeMaterialGroups(scene, identityNodes)
212
- convertMaterials(scene, options.textures ?? 'embed')
374
+ convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
213
375
 
214
- const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
376
+ const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
377
+ const animation =
378
+ options.purpose === 'viewer'
379
+ ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
380
+ : { clips: [], clipNamesByNode: new Map<string, string[]>() }
381
+ stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
382
+
383
+ let disposed = false
384
+ return {
385
+ scene,
386
+ animations: animation.clips,
387
+ warnings: [],
388
+ dispose: () => {
389
+ if (disposed) return
390
+ disposed = true
391
+ disposeExportResources(scene)
392
+ },
393
+ }
394
+ }
215
395
 
216
- stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
396
+ function replaceBakeGeometrySync(preparation: SceneExportPreparation): void {
397
+ for (const [id, original] of preparation.registryEntries) {
398
+ const node = preparation.nodes[id]
399
+ const builders = preparation.builders.get(id)
400
+ if (!node || !builders) continue
401
+ const cloned = preparation.cloneByOriginal.get(original)
402
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
403
+ if (preparation.options.requireSynchronousBake && builders.async && !builders.sync) {
404
+ throw new Error(
405
+ `Node kind "${node.type}" can only bake geometry asynchronously. Choose GLB/USDZ or exclude it from the export.`,
406
+ )
407
+ }
408
+ if (!builders.sync) continue
409
+ replaceBakedNode(
410
+ preparation,
411
+ id,
412
+ original,
413
+ builders.sync(
414
+ node,
415
+ buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext),
416
+ ),
417
+ 'bakeGeometry',
418
+ )
419
+ }
420
+ }
217
421
 
218
- return { scene, animations: clips }
422
+ async function replaceBakeGeometryAsync(preparation: SceneExportPreparation): Promise<void> {
423
+ for (const [id, original] of preparation.registryEntries) {
424
+ const node = preparation.nodes[id]
425
+ const builders = preparation.builders.get(id)
426
+ if (!node || (!builders?.async && !builders?.sync)) continue
427
+ const cloned = preparation.cloneByOriginal.get(original)
428
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) continue
429
+ const context = buildBakeGeometryContext(node, preparation.nodes, preparation.geometryContext)
430
+ const replacement = builders.async
431
+ ? await builders.async(node, context)
432
+ : builders.sync!(node, context)
433
+ replaceBakedNode(
434
+ preparation,
435
+ id,
436
+ original,
437
+ replacement,
438
+ builders.async ? 'bakeGeometryAsync' : 'bakeGeometry',
439
+ )
440
+ }
219
441
  }
220
442
 
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,
443
+ function replaceBakedNode(
444
+ preparation: SceneExportPreparation,
445
+ id: string,
446
+ original: THREE.Object3D,
447
+ replacement: THREE.Object3D,
448
+ hook: 'bakeGeometry' | 'bakeGeometryAsync',
449
+ ): void {
450
+ const cloned = preparation.cloneByOriginal.get(original)
451
+ if (!cloned || !isDescendantOf(cloned, preparation.scene)) return
452
+ const parent = cloned.parent
453
+ if (!parent) throw new Error(`Cannot replace root export geometry for node ${id}`)
454
+ if (replacement === cloned || replacement.parent) {
455
+ throw new Error(
456
+ `${hook} for ${preparation.nodes[id]?.type} must return a new detached Object3D`,
457
+ )
458
+ }
459
+
460
+ const siblingIndex = parent.children.indexOf(cloned)
461
+ replacement.position.copy(cloned.position)
462
+ replacement.quaternion.copy(cloned.quaternion)
463
+ replacement.scale.copy(cloned.scale)
464
+ replacement.matrix.copy(cloned.matrix)
465
+ replacement.matrixAutoUpdate = cloned.matrixAutoUpdate
466
+ replacement.visible = cloned.visible
467
+ replacement.layers.mask = cloned.layers.mask
468
+ replacement.renderOrder = cloned.renderOrder
469
+ parent.remove(cloned)
470
+ parent.add(replacement)
471
+ const appendedIndex = parent.children.indexOf(replacement)
472
+
473
+ parent.children.splice(appendedIndex, 1)
474
+ parent.children.splice(siblingIndex, 0, replacement)
475
+ preparation.cloneByOriginal.set(original, replacement)
476
+ }
477
+ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
478
+ const ownedTextures = new Map<THREE.Texture, THREE.Texture>()
479
+ root.traverse((object) => {
480
+ const renderable = object as THREE.Mesh
481
+ if (!renderable.material) return
482
+ const materials = Array.isArray(renderable.material)
483
+ ? renderable.material
484
+ : [renderable.material]
485
+ for (const material of materials) {
486
+ const textured = material as THREE.Material & Record<string, unknown>
487
+ for (const slot of REFERENCE_MAP_SLOTS) {
488
+ const sourceTexture = textured[slot]
489
+ if (
490
+ !(sourceTexture instanceof THREE.Texture) ||
491
+ !isViewerPresentationTextureBorrowed(sourceTexture)
492
+ ) {
493
+ continue
494
+ }
495
+ let ownedTexture = ownedTextures.get(sourceTexture)
496
+ if (!ownedTexture) {
497
+ ownedTexture = sourceTexture.clone()
498
+ ownedTexture.userData = structuredClone(sourceTexture.userData)
499
+ ownedTexture.needsUpdate = true
500
+ ownedTextures.set(sourceTexture, ownedTexture)
501
+ }
502
+ textured[slot] = ownedTexture
503
+ }
504
+ }
505
+ })
506
+ }
507
+
508
+ async function appendSelectedPresentations(preparation: SceneExportPreparation): Promise<void> {
509
+ for (const { contribution, configuration } of preparation.presentations) {
510
+ const staticExport = contribution.staticExport!
511
+ const built = await staticExport.build({
512
+ nodes: preparation.nodes,
513
+ configuration,
514
+ onlyVisible: preparation.options.onlyVisible ?? true,
515
+ excludedNodeTypes: preparation.options.excludedNodeTypes ?? [],
516
+ })
517
+ if (!built) continue
518
+ if (built.parent) {
519
+ throw new Error(`Static viewer presentation "${contribution.id}" returned an attached root`)
520
+ }
521
+ ownBorrowedPresentationTextures(built)
522
+ const wrapper = new THREE.Group()
523
+ wrapper.name = contribution.id
524
+ wrapper.userData = {
525
+ pascalPresentationId: contribution.id,
526
+ label: staticExport.label,
527
+ }
528
+ wrapper.add(built)
529
+ preparation.scene.add(wrapper)
530
+ }
531
+ }
532
+
533
+ function buildBakeGeometryContext(
534
+ node: AnyNode,
535
+ nodes: Record<string, AnyNode>,
536
+ base: Pick<GeometryContext, 'materials' | 'levelData'>,
537
+ ): GeometryContext {
538
+ const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
539
+ const childIds = Array.isArray((node as { children?: AnyNodeId[] }).children)
540
+ ? (node as { children: AnyNodeId[] }).children
541
+ : []
542
+ const children = childIds
543
+ .map((id) => nodes[id])
544
+ .filter((child): child is AnyNode => child !== undefined)
545
+ const allNodes = node.parentId ? undefined : Object.values(nodes)
546
+ const parent = node.parentId
547
+ ? (nodes[node.parentId] ?? null)
548
+ : (allNodes?.find(
549
+ (candidate) =>
550
+ candidate.type === 'site' &&
551
+ 'children' in candidate &&
552
+ Array.isArray(candidate.children) &&
553
+ candidate.children.includes(node.id),
554
+ ) ?? null)
555
+ const siblingIds =
556
+ parent && Array.isArray((parent as { children?: AnyNodeId[] }).children)
557
+ ? (parent as { children: AnyNodeId[] }).children
558
+ : []
559
+ let siblings: AnyNode[]
560
+ if (parent?.type === 'site') {
561
+ const declaredChildren = new Set(siblingIds)
562
+ siblings = (allNodes ?? Object.values(nodes)).filter(
563
+ (candidate) =>
564
+ candidate.id !== node.id &&
565
+ candidate.type === node.type &&
566
+ (candidate.parentId === parent.id || declaredChildren.has(candidate.id)),
567
+ )
568
+ } else {
569
+ siblings = siblingIds
570
+ .filter((id) => id !== node.id)
571
+ .map((id) => nodes[id])
572
+ .filter((sibling): sibling is AnyNode => sibling?.type === node.type)
573
+ }
574
+ const levelId = findLevelAncestorId(node.id, nodes)
575
+ const levelBaseAt = (x: number, z: number) =>
576
+ levelId ? levelBaseElevationAt(nodes, levelId, x, z) : 0
577
+
578
+ return {
579
+ resolve,
580
+ children,
581
+ siblings,
582
+ parent,
583
+ levelBaseAt,
584
+ levelData: base.levelData,
585
+ materials: base.materials,
586
+ }
587
+ }
588
+
589
+ function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D): boolean {
590
+ let current: THREE.Object3D | null = object
591
+ while (current) {
592
+ if (current === ancestor) return true
593
+ current = current.parent
594
+ }
595
+ return false
596
+ }
597
+
598
+ function pruneHiddenSceneNodes(
599
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
600
+ nodes: Record<string, AnyNode>,
601
+ registryEntries: readonly RegistryEntry[],
602
+ ) {
603
+ const visibility = new Map<string, boolean>()
604
+ const declaredSiteParents = new Map<string, string>()
605
+ for (const node of Object.values(nodes)) {
606
+ if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
607
+ for (const childId of node.children) {
608
+ const child = nodes[childId]
609
+ if (child && !child.parentId && !declaredSiteParents.has(childId)) {
610
+ declaredSiteParents.set(childId, node.id)
611
+ }
612
+ }
613
+ }
614
+
615
+ const isVisible = (id: string, path: Set<string>): boolean => {
616
+ const cached = visibility.get(id)
617
+ if (cached !== undefined) return cached
618
+
619
+ const node = nodes[id]
620
+ if (!node) return true
621
+ if (node.visible === false) {
622
+ visibility.set(id, false)
623
+ return false
624
+ }
625
+ const parentId = node.parentId || declaredSiteParents.get(id)
626
+ if (!parentId || path.has(id)) {
627
+ visibility.set(id, true)
628
+ return true
629
+ }
630
+
631
+ path.add(id)
632
+ const visible = isVisible(parentId, path)
633
+ path.delete(id)
634
+ visibility.set(id, visible)
635
+ return visible
636
+ }
637
+
638
+ for (const [id, original] of registryEntries) {
639
+ if (isVisible(id, new Set())) continue
640
+ cloneByOriginal.get(original)?.removeFromParent()
641
+ }
642
+ }
643
+
644
+ function retainedClones(
645
+ root: THREE.Object3D,
646
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
229
647
  ): 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)
648
+ const retained = new Set<THREE.Object3D>()
649
+ root.traverse((object) => retained.add(object))
650
+ return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone)))
651
+ }
652
+
653
+ type ResourceCloneCache = {
654
+ geometries: Map<THREE.BufferGeometry, THREE.BufferGeometry>
655
+ materials: Map<THREE.Material, THREE.Material>
656
+ textures: Map<THREE.Texture, THREE.Texture>
657
+ }
658
+
659
+ /** Skip excluded subtrees before cloning large procedural instance buffers. */
660
+ function cloneSceneForExport(
661
+ source: THREE.Object3D,
662
+ excludedObjects: Set<THREE.Object3D>,
663
+ cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
664
+ cache: ResourceCloneCache = {
665
+ geometries: new Map(),
666
+ materials: new Map(),
667
+ textures: new Map(),
668
+ },
669
+ ): THREE.Object3D {
670
+ if (excludedObjects.has(source)) return new THREE.Group()
671
+
672
+ const clone = source.clone(false)
673
+ clone.userData = structuredClone(source.userData)
674
+ const renderable = source as THREE.Mesh
675
+ const renderableClone = clone as THREE.Mesh
676
+ if (renderable.geometry) {
677
+ let geometry = cache.geometries.get(renderable.geometry)
678
+ if (!geometry) {
679
+ geometry = renderable.geometry.clone()
680
+ cache.geometries.set(renderable.geometry, geometry)
681
+ }
682
+ renderableClone.geometry = geometry
683
+ }
684
+ if (renderable.material) {
685
+ const cloneMaterial = (material: THREE.Material): THREE.Material => {
686
+ let result = cache.materials.get(material)
687
+ if (result) return result
688
+ result = material.clone()
689
+ const textured = result as THREE.Material & Record<string, unknown>
690
+ for (const slot of REFERENCE_MAP_SLOTS) {
691
+ const texture = textured[slot]
692
+ if (!(texture instanceof THREE.Texture)) continue
693
+ let textureClone = cache.textures.get(texture)
694
+ if (!textureClone) {
695
+ textureClone = texture.clone()
696
+ textureClone.userData = structuredClone(texture.userData)
697
+ textureClone.needsUpdate = true
698
+ cache.textures.set(texture, textureClone)
699
+ }
700
+ textured[slot] = textureClone
701
+ }
702
+ cache.materials.set(material, result)
703
+ return result
704
+ }
705
+ if (Array.isArray(renderable.material)) {
706
+ const materialSlots = renderable.material as Array<THREE.Material | null | undefined>
707
+ renderableClone.material = materialSlots.map((material) =>
708
+ material ? cloneMaterial(material) : material,
709
+ ) as THREE.Material[]
710
+ } else {
711
+ renderableClone.material = cloneMaterial(renderable.material)
712
+ }
713
+ }
714
+
715
+ cloneByOriginal.set(source, clone)
716
+ for (const child of source.children) {
717
+ if (!excludedObjects.has(child)) {
718
+ clone.add(cloneSceneForExport(child, excludedObjects, cloneByOriginal, cache))
719
+ }
720
+ }
721
+ return clone
722
+ }
723
+
724
+ function cloneSkinnedSkeletons(cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>): void {
725
+ for (const [original, cloned] of cloneByOriginal) {
726
+ const source = original as THREE.SkinnedMesh
727
+ const target = cloned as THREE.SkinnedMesh
728
+ if (!source.isSkinnedMesh) continue
729
+ const bones = source.skeleton.bones.map((bone) => {
730
+ const clonedBone = cloneByOriginal.get(bone)
731
+ if (!(clonedBone as THREE.Bone | undefined)?.isBone) {
732
+ throw new Error(
733
+ `Skinned mesh "${source.name}" references a bone outside its export subtree`,
734
+ )
735
+ }
736
+ return clonedBone as THREE.Bone
737
+ })
738
+ target.bindMode = source.bindMode
739
+ target.bind(
740
+ new THREE.Skeleton(
741
+ bones,
742
+ source.skeleton.boneInverses.map((inverse) => inverse.clone()),
743
+ ),
744
+ source.bindMatrix.clone(),
745
+ )
239
746
  }
240
- return map
241
747
  }
242
748
 
243
749
  // A single empty geometry shared by every container mesh we neutralise below —
@@ -296,8 +802,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
296
802
  renderable.material == null
297
803
  ) {
298
804
  if (object.children.length > 0) {
299
- renderable.geometry = EMPTY_GEOMETRY
300
- renderable.material = PLACEHOLDER_MATERIAL
805
+ renderable.geometry = EMPTY_GEOMETRY.clone()
806
+ renderable.material = PLACEHOLDER_MATERIAL.clone()
301
807
  } else {
302
808
  toRemove.push(object)
303
809
  }
@@ -315,12 +821,12 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
315
821
  Array.isArray(renderable.material) &&
316
822
  renderable.material.some((m) => m == null)
317
823
  ) {
318
- renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
824
+ renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
319
825
  }
320
826
  const mesh = object as THREE.Mesh
321
827
  if (!mesh.isMesh || isRenderableMesh(mesh)) return
322
828
  if (mesh.children.length > 0) {
323
- mesh.geometry = EMPTY_GEOMETRY
829
+ mesh.geometry = EMPTY_GEOMETRY.clone()
324
830
  } else {
325
831
  toRemove.push(mesh)
326
832
  }
@@ -343,8 +849,8 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
343
849
  * system's degenerate placeholder) — is neutralised like other
344
850
  * non-renderables (kept as a bare transform node, or removed if a leaf
345
851
  * 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.
852
+ * The export tree owns its resources; repairs still avoid copying vertex
853
+ * buffers when replacing only group metadata.
348
854
  */
349
855
  function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
350
856
  const toRemove: THREE.Object3D[] = []
@@ -364,15 +870,15 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
364
870
  )
365
871
  if (validGroups.length === 0) {
366
872
  if (mesh.children.length > 0 || identityNodes.has(mesh)) {
367
- mesh.geometry = EMPTY_GEOMETRY
368
- mesh.material = PLACEHOLDER_MATERIAL
873
+ mesh.geometry = EMPTY_GEOMETRY.clone()
874
+ mesh.material = PLACEHOLDER_MATERIAL.clone()
369
875
  } else {
370
876
  toRemove.push(mesh)
371
877
  }
372
878
  return
373
879
  }
374
- // Only the group list needs repair share the attribute/index refs
375
- // instead of geometry.clone(), which deep-copies every vertex buffer.
880
+ // Only the group list needs repair; reuse export-owned attributes rather
881
+ // than duplicating every vertex buffer.
376
882
  if (validGroups.length !== groups.length) {
377
883
  const geometry = new THREE.BufferGeometry()
378
884
  geometry.index = mesh.geometry.index
@@ -385,7 +891,7 @@ function sanitizeMaterialGroups(root: THREE.Object3D, identityNodes: Set<THREE.O
385
891
  geometry.groups = validGroups.map((g) => ({ ...g }))
386
892
  mesh.geometry = geometry
387
893
  }
388
- mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL)
894
+ mesh.material = materials.map((m) => m ?? PLACEHOLDER_MATERIAL.clone())
389
895
  })
390
896
  for (const object of toRemove) {
391
897
  object.removeFromParent()
@@ -435,29 +941,44 @@ const REFERENCE_MAP_SLOTS = [
435
941
  'anisotropyMap',
436
942
  ] as const
437
943
 
438
- function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') {
944
+ function convertMaterials(
945
+ root: THREE.Object3D,
946
+ textureMode: 'embed' | 'reference',
947
+ purpose: 'portable' | 'viewer',
948
+ ) {
439
949
  const cache = new Map<THREE.Material, THREE.Material>()
440
950
  const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
441
951
  root.traverse((object) => {
442
952
  const mesh = object as THREE.Mesh
443
953
  if (!mesh.isMesh) return
444
954
  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.
955
+ const materialArray = Array.isArray(material) ? material : [material]
454
956
  if (
455
- (material as { isNodeMaterial?: boolean }).isNodeMaterial &&
456
- material.side === THREE.BackSide
957
+ purpose === 'viewer' &&
958
+ materialArray.length === 1 &&
959
+ (materialArray[0] as { isNodeMaterial?: boolean }).isNodeMaterial &&
960
+ materialArray[0]!.side === THREE.BackSide
457
961
  ) {
458
962
  mesh.geometry = flipGeometryWinding(mesh.geometry)
459
963
  }
460
- mesh.material = convertMaterial(material, cache, textureMode, placeholderCache)
964
+ const converted = materialArray.map((entry) =>
965
+ convertMaterial(entry, cache, textureMode, placeholderCache, purpose),
966
+ )
967
+ const color = mesh.geometry.getAttribute('color')
968
+ let hasVertexAlpha = false
969
+ if (color?.itemSize === 4) {
970
+ for (let index = 0; index < color.count; index++) {
971
+ if (color.getW(index) < 1) {
972
+ hasVertexAlpha = true
973
+ break
974
+ }
975
+ }
976
+ }
977
+ for (const entry of converted) {
978
+ const textured = entry as THREE.Material & { alphaMap?: THREE.Texture | null }
979
+ if (hasVertexAlpha || textured.alphaMap) entry.transparent = true
980
+ }
981
+ mesh.material = Array.isArray(material) ? converted : converted[0]!
461
982
  })
462
983
  }
463
984
 
@@ -509,6 +1030,7 @@ function convertMaterial(
509
1030
  cache: Map<THREE.Material, THREE.Material>,
510
1031
  textureMode: 'embed' | 'reference',
511
1032
  placeholderCache: Map<THREE.Texture, THREE.Texture>,
1033
+ purpose: 'portable' | 'viewer',
512
1034
  ): THREE.Material {
513
1035
  const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
514
1036
  if (!isNodeMaterial) {
@@ -543,7 +1065,8 @@ function convertMaterial(
543
1065
  target.opacity = material.opacity
544
1066
  // BackSide is flipped to FrontSide (with the mesh winding reversed in
545
1067
  // convertMaterials) because glTF has no back-face-only mode.
546
- target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
1068
+ target.side =
1069
+ purpose === 'viewer' && material.side === THREE.BackSide ? THREE.FrontSide : material.side
547
1070
  target.alphaTest = material.alphaTest
548
1071
  target.depthWrite = material.depthWrite
549
1072
  target.depthTest = material.depthTest
@@ -649,11 +1172,12 @@ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
649
1172
  function bakeAnimationClips(
650
1173
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
651
1174
  nodes: Record<string, AnyNode>,
1175
+ registryEntries: readonly RegistryEntry[],
652
1176
  ): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
653
1177
  const clips: THREE.AnimationClip[] = []
654
1178
  const clipNamesByNode = new Map<string, string[]>()
655
1179
 
656
- for (const [id, original] of sceneRegistry.nodes) {
1180
+ for (const [id, original] of registryEntries) {
657
1181
  const node = nodes[id]
658
1182
  const target = cloneByOriginal.get(original)
659
1183
  if (!node || !target) continue
@@ -1006,12 +1530,16 @@ function stampIdentity(
1006
1530
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
1007
1531
  nodes: Record<string, AnyNode>,
1008
1532
  clipNamesByNode: Map<string, string[]>,
1533
+ registryEntries: readonly RegistryEntry[],
1009
1534
  ) {
1010
1535
  scene.traverse((object) => {
1011
- object.userData = {}
1536
+ const presentationId = object.userData.pascalPresentationId
1537
+ const label = object.userData.label
1538
+ object.userData =
1539
+ typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {}
1012
1540
  })
1013
1541
 
1014
- for (const [id, original] of sceneRegistry.nodes) {
1542
+ for (const [id, original] of registryEntries) {
1015
1543
  const node = nodes[id]
1016
1544
  const target = cloneByOriginal.get(original)
1017
1545
  if (!node || !target) continue