@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
@@ -0,0 +1,1042 @@
1
+ import * as THREE from 'three'
2
+ import { StaticGeometryGenerator } from 'three-mesh-bvh'
3
+ import { cloneExportUserData } from './export-user-data'
4
+
5
+ const VERTEX_COLOR_UV_CHANNEL = 0
6
+ const VERTEX_COLOR_TILE_SIZE = 8
7
+ const MAX_VERTEX_COLOR_ATLAS_SIZE = 2048
8
+
9
+ const MATERIAL_TEXTURE_SLOTS = [
10
+ 'map',
11
+ 'normalMap',
12
+ 'roughnessMap',
13
+ 'metalnessMap',
14
+ 'aoMap',
15
+ 'emissiveMap',
16
+ 'alphaMap',
17
+ 'lightMap',
18
+ 'bumpMap',
19
+ 'displacementMap',
20
+ 'clearcoatMap',
21
+ 'clearcoatNormalMap',
22
+ 'clearcoatRoughnessMap',
23
+ 'iridescenceMap',
24
+ 'iridescenceThicknessMap',
25
+ 'transmissionMap',
26
+ 'thicknessMap',
27
+ 'specularIntensityMap',
28
+ 'specularColorMap',
29
+ 'sheenRoughnessMap',
30
+ 'sheenColorMap',
31
+ 'anisotropyMap',
32
+ ] as const
33
+
34
+ type TexturedMaterial = THREE.Material & Record<string, unknown>
35
+ type DisposableResource = THREE.BufferGeometry | THREE.Material | THREE.Texture
36
+
37
+ function uvAttributeName(channel: number): string {
38
+ return channel === 0 ? 'uv' : `uv${channel}`
39
+ }
40
+
41
+ const discardedResources = new WeakMap<THREE.Object3D, Set<DisposableResource>>()
42
+
43
+ function rememberDiscarded(root: THREE.Object3D, resource: DisposableResource): void {
44
+ let resources = discardedResources.get(root)
45
+ if (!resources) {
46
+ resources = new Set()
47
+ discardedResources.set(root, resources)
48
+ }
49
+ resources.add(resource)
50
+ }
51
+
52
+ function materialsOf(mesh: THREE.Mesh): THREE.Material[] {
53
+ return Array.isArray(mesh.material) ? mesh.material : [mesh.material]
54
+ }
55
+
56
+ function copyObjectState(source: THREE.Object3D, target: THREE.Object3D): void {
57
+ target.name = source.name
58
+ target.up.copy(source.up)
59
+ target.position.copy(source.position)
60
+ target.quaternion.copy(source.quaternion)
61
+ target.scale.copy(source.scale)
62
+ target.matrix.copy(source.matrix)
63
+ target.matrixAutoUpdate = source.matrixAutoUpdate
64
+ target.visible = source.visible
65
+ target.layers.mask = source.layers.mask
66
+ target.renderOrder = source.renderOrder
67
+ target.frustumCulled = source.frustumCulled
68
+ target.userData = cloneExportUserData(source.userData)
69
+ }
70
+
71
+ function replaceObject(source: THREE.Object3D, replacement: THREE.Object3D): void {
72
+ const parent = source.parent
73
+ if (!parent) throw new Error(`Cannot replace detached export object "${source.name}"`)
74
+ const index = parent.children.indexOf(source)
75
+ parent.remove(source)
76
+ parent.add(replacement)
77
+ const appended = parent.children.indexOf(replacement)
78
+ parent.children.splice(appended, 1)
79
+ parent.children.splice(index, 0, replacement)
80
+ }
81
+
82
+ function tintMaterial(material: THREE.Material, tint: THREE.Color | null): THREE.Material {
83
+ if (!tint) return material
84
+ const clone = material.clone() as TexturedMaterial
85
+ const color = clone.color
86
+ if (!(color instanceof THREE.Color)) {
87
+ throw new Error(`Instance-colored material "${material.name}" has no portable color factor`)
88
+ }
89
+ color.multiply(tint)
90
+ return clone
91
+ }
92
+
93
+ function tintMaterials(
94
+ material: THREE.Material | THREE.Material[],
95
+ tint: THREE.Color | null,
96
+ ): THREE.Material | THREE.Material[] {
97
+ return Array.isArray(material)
98
+ ? material.map((entry) => tintMaterial(entry, tint))
99
+ : tintMaterial(material, tint)
100
+ }
101
+ type InstancedGeometryAttribute = [string, THREE.InstancedBufferAttribute]
102
+
103
+ function geometryForInstance(
104
+ source: THREE.BufferGeometry,
105
+ instancedAttributes: readonly InstancedGeometryAttribute[],
106
+ instanceIndex: number,
107
+ ): THREE.BufferGeometry {
108
+ if (instancedAttributes.length === 0) return source
109
+
110
+ const vertexCount = source.getAttribute('position')?.count
111
+ if (vertexCount === undefined) {
112
+ throw new Error('Instanced geometry has no position attribute')
113
+ }
114
+ const geometry = source.clone()
115
+ for (const [name, attribute] of instancedAttributes) {
116
+ const valueIndex = Math.floor(instanceIndex / attribute.meshPerAttribute)
117
+ if (valueIndex >= attribute.count) {
118
+ geometry.dispose()
119
+ throw new Error(`Instanced attribute "${name}" has no value for instance ${instanceIndex}`)
120
+ }
121
+ const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array
122
+ const values = new ArrayType(vertexCount * attribute.itemSize)
123
+ const sourceOffset = valueIndex * attribute.itemSize
124
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
125
+ const targetOffset = vertex * attribute.itemSize
126
+ for (let component = 0; component < attribute.itemSize; component++) {
127
+ values[targetOffset + component] = attribute.array[sourceOffset + component]!
128
+ }
129
+ }
130
+ const resolved = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized)
131
+ resolved.name = attribute.name
132
+ resolved.setUsage(attribute.usage)
133
+ resolved.gpuType = attribute.gpuType
134
+ geometry.setAttribute(name, resolved)
135
+ }
136
+ return geometry
137
+ }
138
+
139
+ /** Replace every InstancedMesh with ordinary meshes in the same local hierarchy. */
140
+ export function expandInstancedMeshes(root: THREE.Object3D): void {
141
+ const instances: THREE.InstancedMesh[] = []
142
+ root.traverse((object) => {
143
+ if ((object as THREE.InstancedMesh).isInstancedMesh) {
144
+ instances.push(object as THREE.InstancedMesh)
145
+ }
146
+ })
147
+
148
+ const matrix = new THREE.Matrix4()
149
+ const tint = new THREE.Color()
150
+ for (const source of instances) {
151
+ const wrapper = new THREE.Group()
152
+ copyObjectState(source, wrapper)
153
+ const instancedAttributes = Object.entries(source.geometry.attributes).filter(
154
+ ([, attribute]) => (attribute as THREE.InstancedBufferAttribute).isInstancedBufferAttribute,
155
+ ) as InstancedGeometryAttribute[]
156
+
157
+ for (let index = 0; index < source.count; index++) {
158
+ const color = source.instanceColor ? source.getColorAt(index, tint).clone() : null
159
+ const geometry = geometryForInstance(source.geometry, instancedAttributes, index)
160
+ const mesh = new THREE.Mesh(geometry, tintMaterials(source.material, color))
161
+ mesh.name = source.name ? `${source.name}_${index + 1}` : `instance_${index + 1}`
162
+ mesh.castShadow = source.castShadow
163
+ mesh.receiveShadow = source.receiveShadow
164
+ mesh.frustumCulled = source.frustumCulled
165
+ source.getMatrixAt(index, matrix)
166
+ matrix.decompose(mesh.position, mesh.quaternion, mesh.scale)
167
+
168
+ if (source.morphTexture) {
169
+ source.getMorphAt(index, mesh)
170
+ }
171
+ wrapper.add(mesh)
172
+ }
173
+ if (instancedAttributes.length > 0) rememberDiscarded(root, source.geometry)
174
+
175
+ for (const child of [...source.children]) wrapper.add(child)
176
+ replaceObject(source, wrapper)
177
+ }
178
+ }
179
+
180
+ function bakeDeformedGeometry(mesh: THREE.Mesh): THREE.BufferGeometry {
181
+ const geometry = mesh.geometry
182
+ const generator = new StaticGeometryGenerator(mesh)
183
+ generator.applyWorldTransforms = false
184
+ generator.useGroups = false
185
+ generator.attributes = Object.keys(geometry.attributes).filter(
186
+ (name) => name !== 'skinIndex' && name !== 'skinWeight',
187
+ )
188
+
189
+ const savedMatrixWorld = mesh.matrixWorld.clone()
190
+ mesh.matrixWorld.identity()
191
+ try {
192
+ const baked = generator.generate()
193
+ baked.groups = geometry.groups.map((group) => ({ ...group }))
194
+ baked.setDrawRange(geometry.drawRange.start, geometry.drawRange.count)
195
+ return baked
196
+ } finally {
197
+ mesh.matrixWorld.copy(savedMatrixWorld)
198
+ }
199
+ }
200
+
201
+ /** Freeze current morph and skin deformation into ordinary vertex buffers. */
202
+ export function freezeDeformedMeshes(root: THREE.Object3D): void {
203
+ const meshes: THREE.Mesh[] = []
204
+ root.traverse((object) => {
205
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
206
+ })
207
+
208
+ for (const mesh of meshes) {
209
+ const skinnedMesh = mesh as THREE.SkinnedMesh
210
+ const hasMorphTargets = Object.values(mesh.geometry.morphAttributes).some(
211
+ (attributes) => attributes.length > 0,
212
+ )
213
+ if (!(skinnedMesh.isSkinnedMesh || hasMorphTargets)) continue
214
+
215
+ const originalGeometry = mesh.geometry
216
+ mesh.geometry = bakeDeformedGeometry(mesh)
217
+ mesh.geometry.morphAttributes = {}
218
+ mesh.morphTargetDictionary = undefined
219
+ mesh.morphTargetInfluences = undefined
220
+ if (skinnedMesh.isSkinnedMesh) {
221
+ const ordinaryMesh = mesh as unknown as { isSkinnedMesh: boolean }
222
+ ordinaryMesh.isSkinnedMesh = false
223
+ }
224
+ rememberDiscarded(root, originalGeometry)
225
+ }
226
+ }
227
+
228
+ function canvas2d(
229
+ width: number,
230
+ height: number,
231
+ ): {
232
+ canvas: HTMLCanvasElement
233
+ context: CanvasRenderingContext2D
234
+ } {
235
+ if (typeof document === 'undefined') {
236
+ throw new Error('Portable texture baking requires a browser canvas')
237
+ }
238
+ const canvas = document.createElement('canvas')
239
+ canvas.width = width
240
+ canvas.height = height
241
+ const context = canvas.getContext('2d', { willReadFrequently: true })
242
+ if (!context) throw new Error('Portable texture baking could not create a 2D canvas')
243
+ return { canvas, context }
244
+ }
245
+
246
+ function srgbByte(linear: number): number {
247
+ const value = Math.max(0, Math.min(1, linear))
248
+ const encoded = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055
249
+ return Math.round(encoded * 255)
250
+ }
251
+
252
+ function colorComponent(
253
+ attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute,
254
+ index: number,
255
+ component: number,
256
+ ): number {
257
+ if (component === 0) return attribute.getX(index)
258
+ if (component === 1) return attribute.getY(index)
259
+ if (component === 2) return attribute.getZ(index)
260
+ return attribute.itemSize >= 4 ? attribute.getW(index) : 1
261
+ }
262
+
263
+ function buildVertexColorAtlas(
264
+ geometry: THREE.BufferGeometry,
265
+ materials: readonly THREE.Material[],
266
+ relocatedUvChannel?: number,
267
+ ): {
268
+ geometry: THREE.BufferGeometry
269
+ texture: THREE.CanvasTexture
270
+ hasAlpha: boolean
271
+ clampedHdr: boolean
272
+ } {
273
+ const expanded = geometry.index ? geometry.toNonIndexed() : geometry.clone()
274
+ if (relocatedUvChannel !== undefined) {
275
+ const sourceUv = expanded.getAttribute('uv')
276
+ if (!sourceUv) {
277
+ expanded.dispose()
278
+ throw new Error('Textured vertex-color geometry has no UV coordinates to preserve')
279
+ }
280
+ expanded.setAttribute(uvAttributeName(relocatedUvChannel), sourceUv.clone())
281
+ }
282
+ const color = expanded.getAttribute('color')
283
+ const position = expanded.getAttribute('position')
284
+ if (!color || !position || position.count === 0 || position.count % 3 !== 0) {
285
+ expanded.dispose()
286
+ throw new Error('Vertex-color baking requires triangle geometry with a color attribute')
287
+ }
288
+
289
+ const faceCount = position.count / 3
290
+ const tilesPerRow = Math.ceil(Math.sqrt(faceCount))
291
+ const atlasSize = THREE.MathUtils.ceilPowerOfTwo(tilesPerRow * VERTEX_COLOR_TILE_SIZE)
292
+ if (atlasSize > MAX_VERTEX_COLOR_ATLAS_SIZE) {
293
+ expanded.dispose()
294
+ throw new Error(
295
+ `Vertex-color atlas requires ${atlasSize}px, exceeding the ${MAX_VERTEX_COLOR_ATLAS_SIZE}px export limit`,
296
+ )
297
+ }
298
+
299
+ const { canvas, context } = canvas2d(atlasSize, atlasSize)
300
+ const image = context.createImageData(atlasSize, atlasSize)
301
+
302
+ const uv = new Float32Array(position.count * 2)
303
+ let hasAlpha = false
304
+ let clampedHdr = false
305
+ for (let index = 0; index < color.count && !clampedHdr; index++) {
306
+ const triangleOffset = index - (index % 3)
307
+ const materialIndex = materialIndexForTriangle(expanded, triangleOffset)
308
+ if (!materials[materialIndex]?.vertexColors) continue
309
+ const red = colorComponent(color, index, 0)
310
+ const green = colorComponent(color, index, 1)
311
+ const blue = colorComponent(color, index, 2)
312
+ clampedHdr = red < 0 || red > 1 || green < 0 || green > 1 || blue < 0 || blue > 1
313
+ }
314
+
315
+ for (let face = 0; face < faceCount; face++) {
316
+ const tileX = (face % tilesPerRow) * VERTEX_COLOR_TILE_SIZE
317
+ const tileY = Math.floor(face / tilesPerRow) * VERTEX_COLOR_TILE_SIZE
318
+ const vertices = [face * 3, face * 3 + 1, face * 3 + 2]
319
+ const colors = vertices.map((vertex) => [
320
+ colorComponent(color, vertex, 0),
321
+ colorComponent(color, vertex, 1),
322
+ colorComponent(color, vertex, 2),
323
+ colorComponent(color, vertex, 3),
324
+ ])
325
+ if (
326
+ materials[materialIndexForTriangle(expanded, face * 3)]?.vertexColors &&
327
+ colors.some((entry) => entry[3]! < 1)
328
+ ) {
329
+ hasAlpha = true
330
+ }
331
+
332
+ const inset = 0.5
333
+ const span = VERTEX_COLOR_TILE_SIZE - 1
334
+ const atlasUvs = [
335
+ [tileX + inset, tileY + inset],
336
+ [tileX + inset + span, tileY + inset],
337
+ [tileX + inset, tileY + inset + span],
338
+ ]
339
+ for (let corner = 0; corner < 3; corner++) {
340
+ uv[(face * 3 + corner) * 2] = atlasUvs[corner]![0]! / atlasSize
341
+ uv[(face * 3 + corner) * 2 + 1] = 1 - atlasUvs[corner]![1]! / atlasSize
342
+ }
343
+
344
+ for (let y = 0; y < VERTEX_COLOR_TILE_SIZE; y++) {
345
+ for (let x = 0; x < VERTEX_COLOR_TILE_SIZE; x++) {
346
+ let b = x / span
347
+ let c = y / span
348
+ if (b + c > 1) {
349
+ const sum = b + c
350
+ b /= sum
351
+ c /= sum
352
+ }
353
+ const a = 1 - b - c
354
+ const pixel = ((tileY + y) * atlasSize + tileX + x) * 4
355
+ image.data[pixel] = srgbByte(colors[0]![0]! * a + colors[1]![0]! * b + colors[2]![0]! * c)
356
+ image.data[pixel + 1] = srgbByte(
357
+ colors[0]![1]! * a + colors[1]![1]! * b + colors[2]![1]! * c,
358
+ )
359
+ image.data[pixel + 2] = srgbByte(
360
+ colors[0]![2]! * a + colors[1]![2]! * b + colors[2]![2]! * c,
361
+ )
362
+ image.data[pixel + 3] = Math.round(
363
+ 255 *
364
+ Math.max(0, Math.min(1, colors[0]![3]! * a + colors[1]![3]! * b + colors[2]![3]! * c)),
365
+ )
366
+ }
367
+ }
368
+ }
369
+ // Dilate each row's terminal tile through all unused power-of-two cells.
370
+ // Importers may generate their own mip chain (USDZ in particular), so sampler
371
+ // flags cannot prevent transparent-black padding from bleeding into Grass.
372
+ for (let y = 0; y < atlasSize; y++) {
373
+ const tileRow = Math.floor(y / VERTEX_COLOR_TILE_SIZE)
374
+ for (let x = 0; x < atlasSize; x++) {
375
+ const tileColumn = Math.floor(x / VERTEX_COLOR_TILE_SIZE)
376
+ const tileIndex = tileRow * tilesPerRow + tileColumn
377
+ if (tileColumn < tilesPerRow && tileIndex < faceCount) continue
378
+ const sourceFace =
379
+ tileRow * tilesPerRow < faceCount
380
+ ? Math.min((tileRow + 1) * tilesPerRow - 1, faceCount - 1)
381
+ : faceCount - 1
382
+ const sourceX =
383
+ (sourceFace % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (x % VERTEX_COLOR_TILE_SIZE)
384
+ const sourceY =
385
+ Math.floor(sourceFace / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (y % VERTEX_COLOR_TILE_SIZE)
386
+ const sourcePixel = (sourceY * atlasSize + sourceX) * 4
387
+ const targetPixel = (y * atlasSize + x) * 4
388
+ image.data[targetPixel] = image.data[sourcePixel]!
389
+ image.data[targetPixel + 1] = image.data[sourcePixel + 1]!
390
+ image.data[targetPixel + 2] = image.data[sourcePixel + 2]!
391
+ image.data[targetPixel + 3] = image.data[sourcePixel + 3]!
392
+ }
393
+ }
394
+
395
+ context.putImageData(image, 0, 0)
396
+ expanded.setAttribute(uvAttributeName(VERTEX_COLOR_UV_CHANNEL), new THREE.BufferAttribute(uv, 2))
397
+ expanded.deleteAttribute('color')
398
+ const texture = new THREE.CanvasTexture(canvas)
399
+ texture.name = 'pascal_vertex_color_atlas'
400
+ texture.channel = VERTEX_COLOR_UV_CHANNEL
401
+ texture.colorSpace = THREE.SRGBColorSpace
402
+ texture.flipY = true
403
+ texture.needsUpdate = true
404
+ return { geometry: expanded, texture, hasAlpha, clampedHdr }
405
+ }
406
+ function hasDeformedGeometry(mesh: THREE.Mesh): boolean {
407
+ const skinnedMesh = mesh as THREE.SkinnedMesh
408
+ return (
409
+ skinnedMesh.isSkinnedMesh ||
410
+ Object.values(mesh.geometry.morphAttributes).some((attributes) => attributes.length > 0)
411
+ )
412
+ }
413
+
414
+ function bakeVertexColors(root: THREE.Object3D, preserveDeformations = false): boolean {
415
+ const meshes: THREE.Mesh[] = []
416
+ root.traverse((object) => {
417
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
418
+ })
419
+ let clampedHdr = false
420
+
421
+ for (const mesh of meshes) {
422
+ if (preserveDeformations && hasDeformedGeometry(mesh)) continue
423
+ const color = mesh.geometry.getAttribute('color')
424
+ if (!color) continue
425
+ const materials = materialsOf(mesh)
426
+ if (!materials.some((material) => material.vertexColors)) {
427
+ const originalGeometry = mesh.geometry
428
+ mesh.geometry = originalGeometry.clone()
429
+ mesh.geometry.deleteAttribute('color')
430
+ rememberDiscarded(root, originalGeometry)
431
+ continue
432
+ }
433
+ const occupiedUvChannels = new Set<number>()
434
+ for (let channel = 1; channel <= 3; channel++) {
435
+ if (mesh.geometry.getAttribute(uvAttributeName(channel))) occupiedUvChannels.add(channel)
436
+ }
437
+ const channelZeroTextures = new Set<THREE.Texture>()
438
+ for (const material of materials) {
439
+ const textured = material as TexturedMaterial
440
+ if (material.vertexColors && textured.map instanceof THREE.Texture) {
441
+ throw new Error(
442
+ `Material "${material.name}" combines a diffuse map with varying vertex colors; portable atlas composition is unavailable`,
443
+ )
444
+ }
445
+ for (const slot of MATERIAL_TEXTURE_SLOTS) {
446
+ const texture = textured[slot]
447
+ if (!(texture instanceof THREE.Texture)) continue
448
+ if (texture.channel === 0) channelZeroTextures.add(texture)
449
+ else occupiedUvChannels.add(texture.channel)
450
+ }
451
+ }
452
+
453
+ const relocatedUvChannel =
454
+ channelZeroTextures.size === 0
455
+ ? undefined
456
+ : [1, 2, 3].find((channel) => !occupiedUvChannels.has(channel))
457
+ if (channelZeroTextures.size > 0 && relocatedUvChannel === undefined) {
458
+ throw new Error(
459
+ `Vertex-color geometry "${mesh.name}" has no free portable UV channel for its PBR maps`,
460
+ )
461
+ }
462
+
463
+ const originalGeometry = mesh.geometry
464
+ const baked = buildVertexColorAtlas(originalGeometry, materials, relocatedUvChannel)
465
+ mesh.geometry = baked.geometry
466
+ if (baked.clampedHdr) mesh.userData.pascalHdrVertexColor = 'clamped-to-portable-range'
467
+ clampedHdr ||= baked.clampedHdr
468
+ const relocatedTextures = new Map<THREE.Texture, THREE.Texture>()
469
+ const convertedMaterials = new Map<THREE.Material, THREE.Material>()
470
+ const converted = materials.map((material) => {
471
+ const cached = convertedMaterials.get(material)
472
+ if (cached) return cached
473
+ if (!material.vertexColors && relocatedUvChannel === undefined) return material
474
+ const clone = material.clone() as TexturedMaterial
475
+ if (relocatedUvChannel !== undefined) {
476
+ for (const slot of MATERIAL_TEXTURE_SLOTS) {
477
+ const sourceTexture = clone[slot]
478
+ if (!(sourceTexture instanceof THREE.Texture) || sourceTexture.channel !== 0) continue
479
+ let relocatedTexture = relocatedTextures.get(sourceTexture)
480
+ if (!relocatedTexture) {
481
+ relocatedTexture = sourceTexture.clone()
482
+ relocatedTexture.channel = relocatedUvChannel
483
+ relocatedTexture.needsUpdate = true
484
+ relocatedTextures.set(sourceTexture, relocatedTexture)
485
+ rememberDiscarded(root, sourceTexture)
486
+ }
487
+ clone[slot] = relocatedTexture
488
+ }
489
+ }
490
+ if (material.vertexColors) {
491
+ clone.vertexColors = false
492
+ clone.map = baked.texture
493
+ if (baked.hasAlpha) clone.transparent = true
494
+ }
495
+ convertedMaterials.set(material, clone)
496
+ return clone
497
+ })
498
+ mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
499
+ rememberDiscarded(root, originalGeometry)
500
+ for (const material of materials) rememberDiscarded(root, material)
501
+ }
502
+ return clampedHdr
503
+ }
504
+
505
+ function sourcePixels(texture: THREE.Texture): {
506
+ width: number
507
+ height: number
508
+ data: Uint8ClampedArray
509
+ } {
510
+ const image = texture.image as {
511
+ width?: number
512
+ height?: number
513
+ data?: ArrayLike<number>
514
+ } | null
515
+ const width = image?.width
516
+ const height = image?.height
517
+ if (!(width && height)) throw new Error(`Texture "${texture.name}" has no readable dimensions`)
518
+
519
+ if (image?.data) {
520
+ if (
521
+ !(image.data instanceof Uint8Array || image.data instanceof Uint8ClampedArray) ||
522
+ image.data.length !== width * height * 4
523
+ ) {
524
+ throw new Error(`Texture "${texture.name}" is not an 8-bit RGBA texture`)
525
+ }
526
+ return { width, height, data: Uint8ClampedArray.from(image.data) }
527
+ }
528
+
529
+ const { context } = canvas2d(width, height)
530
+ try {
531
+ context.drawImage(texture.image as CanvasImageSource, 0, 0, width, height)
532
+ } catch {
533
+ throw new Error(`Texture "${texture.name}" cannot be read for portable export`)
534
+ }
535
+ return { width, height, data: context.getImageData(0, 0, width, height).data }
536
+ }
537
+
538
+ function cloneTextureSettings(source: THREE.Texture, target: THREE.Texture): void {
539
+ target.name = source.name
540
+ target.mapping = source.mapping
541
+ target.channel = source.channel
542
+ target.wrapS = source.wrapS
543
+ target.wrapT = source.wrapT
544
+ target.magFilter = source.magFilter
545
+ target.minFilter = source.minFilter
546
+ target.anisotropy = source.anisotropy
547
+ target.offset.copy(source.offset)
548
+ target.repeat.copy(source.repeat)
549
+ target.center.copy(source.center)
550
+ target.rotation = source.rotation
551
+ target.matrixAutoUpdate = source.matrixAutoUpdate
552
+ target.matrix.copy(source.matrix)
553
+ target.generateMipmaps = source.generateMipmaps
554
+ target.premultiplyAlpha = source.premultiplyAlpha
555
+ target.flipY = source.flipY
556
+ target.unpackAlignment = source.unpackAlignment
557
+ target.colorSpace = THREE.NoColorSpace
558
+ target.userData = cloneExportUserData(source.userData)
559
+ target.needsUpdate = true
560
+ }
561
+ function materializeDataTextures(root: THREE.Object3D): void {
562
+ const converted = new Map<THREE.Texture, THREE.CanvasTexture>()
563
+ root.traverse((object) => {
564
+ if (!(object as THREE.Mesh).isMesh) return
565
+ for (const material of materialsOf(object as THREE.Mesh)) {
566
+ const textured = material as TexturedMaterial
567
+ for (const slot of MATERIAL_TEXTURE_SLOTS) {
568
+ const source = textured[slot]
569
+ if (!(source instanceof THREE.Texture) || !(source as THREE.DataTexture).isDataTexture)
570
+ continue
571
+ let texture = converted.get(source)
572
+ if (!texture) {
573
+ if (source.format !== THREE.RGBAFormat || source.type !== THREE.UnsignedByteType) {
574
+ throw new Error(`Data texture "${source.name}" is not portable 8-bit RGBA`)
575
+ }
576
+ const pixels = sourcePixels(source)
577
+ const { canvas, context } = canvas2d(pixels.width, pixels.height)
578
+ const image = context.createImageData(pixels.width, pixels.height)
579
+ image.data.set(pixels.data)
580
+ context.putImageData(image, 0, 0)
581
+ texture = new THREE.CanvasTexture(canvas)
582
+ cloneTextureSettings(source, texture)
583
+ texture.colorSpace = source.colorSpace
584
+ converted.set(source, texture)
585
+ rememberDiscarded(root, source)
586
+ }
587
+ textured[slot] = texture
588
+ }
589
+ }
590
+ })
591
+ }
592
+
593
+ function canonicalizeAlphaMaps(root: THREE.Object3D): void {
594
+ const materials = new Set<THREE.Material>()
595
+ root.traverse((object) => {
596
+ if (!(object as THREE.Mesh).isMesh) return
597
+ for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
598
+ })
599
+
600
+ for (const material of materials) {
601
+ const standard = material as THREE.MeshStandardMaterial
602
+ if (!standard.alphaMap) continue
603
+ const alphaMap = standard.alphaMap
604
+ const map = standard.map
605
+ alphaMap.updateMatrix()
606
+ map?.updateMatrix()
607
+ if (
608
+ map &&
609
+ (map.channel !== alphaMap.channel ||
610
+ map.flipY !== alphaMap.flipY ||
611
+ !map.matrix.equals(alphaMap.matrix))
612
+ ) {
613
+ throw new Error(
614
+ `Material "${material.name}" uses incompatible diffuse and alpha texture coordinates`,
615
+ )
616
+ }
617
+
618
+ const alpha = sourcePixels(alphaMap)
619
+ const color = map ? sourcePixels(map) : null
620
+ if (color && (color.width !== alpha.width || color.height !== alpha.height)) {
621
+ throw new Error(`Material "${material.name}" uses mismatched diffuse and alpha map sizes`)
622
+ }
623
+ const { canvas, context } = canvas2d(alpha.width, alpha.height)
624
+ const output = context.createImageData(alpha.width, alpha.height)
625
+ for (let index = 0; index < alpha.data.length; index += 4) {
626
+ output.data[index] = color?.data[index] ?? 255
627
+ output.data[index + 1] = color?.data[index + 1] ?? 255
628
+ output.data[index + 2] = color?.data[index + 2] ?? 255
629
+ output.data[index + 3] = Math.round(
630
+ ((color?.data[index + 3] ?? 255) * alpha.data[index + 1]!) / 255,
631
+ )
632
+ }
633
+ context.putImageData(output, 0, 0)
634
+ const texture = new THREE.CanvasTexture(canvas)
635
+ cloneTextureSettings(map ?? alphaMap, texture)
636
+ texture.colorSpace = map?.colorSpace ?? THREE.SRGBColorSpace
637
+ texture.name = map?.name || alphaMap.name
638
+ standard.map = texture
639
+ standard.alphaMap = null
640
+ standard.transparent = true
641
+ rememberDiscarded(root, alphaMap)
642
+ if (map) rememberDiscarded(root, map)
643
+ }
644
+ }
645
+
646
+ function canonicalNormalTexture(source: THREE.Texture, scale: THREE.Vector2): THREE.CanvasTexture {
647
+ const compressed = source as THREE.CompressedTexture
648
+ if (compressed.isCompressedTexture) {
649
+ throw new Error(`Compressed normal map "${source.name}" must be baked before portable export`)
650
+ }
651
+ const pixels = sourcePixels(source)
652
+ const { canvas, context } = canvas2d(pixels.width, pixels.height)
653
+ const output = context.createImageData(pixels.width, pixels.height)
654
+ for (let index = 0; index < pixels.data.length; index += 4) {
655
+ let x = (pixels.data[index]! / 255) * 2 - 1
656
+ let y = (pixels.data[index + 1]! / 255) * 2 - 1
657
+ let z = (pixels.data[index + 2]! / 255) * 2 - 1
658
+ x *= scale.x
659
+ y *= scale.y
660
+ const inverseLength = 1 / Math.max(Math.hypot(x, y, z), 1e-8)
661
+ x *= inverseLength
662
+ y *= inverseLength
663
+ z *= inverseLength
664
+ output.data[index] = Math.round((x * 0.5 + 0.5) * 255)
665
+ output.data[index + 1] = Math.round((y * 0.5 + 0.5) * 255)
666
+ output.data[index + 2] = Math.round((z * 0.5 + 0.5) * 255)
667
+ output.data[index + 3] = pixels.data[index + 3]!
668
+ }
669
+ context.putImageData(output, 0, 0)
670
+ const texture = new THREE.CanvasTexture(canvas)
671
+ cloneTextureSettings(source, texture)
672
+ return texture
673
+ }
674
+
675
+ export type PortableNormalizeOptions = {
676
+ /**
677
+ * Leave this normal map and its material's `normalScale` untouched. By-reference
678
+ * placeholders get their real bytes re-attached later, so baking the scale into
679
+ * placeholder pixels would drop both the reference and the strength.
680
+ */
681
+ preserveNormalMap?: (texture: THREE.Texture) => boolean
682
+ }
683
+
684
+ export type CompressedTextureDecompressor = (
685
+ texture: THREE.CompressedTexture,
686
+ ) => Promise<THREE.Texture>
687
+
688
+ function canonicalNormalMaterials(
689
+ root: THREE.Object3D,
690
+ options: PortableNormalizeOptions,
691
+ ): THREE.MeshStandardMaterial[] {
692
+ const materials = new Set<THREE.Material>()
693
+ root.traverse((object) => {
694
+ if (!(object as THREE.Mesh).isMesh) return
695
+ for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
696
+ })
697
+ const result: THREE.MeshStandardMaterial[] = []
698
+ for (const material of materials) {
699
+ const standard = material as THREE.MeshStandardMaterial
700
+ if (!standard.normalMap || !standard.normalScale) continue
701
+ if (standard.normalScale.x === 1 && standard.normalScale.y === 1) continue
702
+ if (options.preserveNormalMap?.(standard.normalMap)) continue
703
+ result.push(standard)
704
+ }
705
+ return result
706
+ }
707
+
708
+ /**
709
+ * Canonicalizing a normal map reads its pixels on the CPU, which compressed
710
+ * (KTX2) textures cannot provide — decompress the ones that will be baked
711
+ * first. Runs before `normalizePortableScene` / `normalizeViewerArtifactMaterials`.
712
+ */
713
+ export async function decompressCanonicalNormalMaps(
714
+ root: THREE.Object3D,
715
+ decompress: CompressedTextureDecompressor,
716
+ options: PortableNormalizeOptions = {},
717
+ ): Promise<void> {
718
+ const readable = new Map<THREE.Texture, THREE.Texture>()
719
+ for (const standard of canonicalNormalMaterials(root, options)) {
720
+ const source = standard.normalMap as THREE.CompressedTexture
721
+ if (!source.isCompressedTexture) continue
722
+ let texture = readable.get(source)
723
+ if (!texture) {
724
+ texture = await decompress(source)
725
+ cloneTextureSettings(source, texture)
726
+ readable.set(source, texture)
727
+ rememberDiscarded(root, source)
728
+ }
729
+ standard.normalMap = texture
730
+ }
731
+ }
732
+
733
+ function canonicalizeNormalMaps(root: THREE.Object3D, options: PortableNormalizeOptions): void {
734
+ const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
735
+ for (const standard of canonicalNormalMaterials(root, options)) {
736
+ const source = standard.normalMap!
737
+ const key = `${standard.normalScale.x}:${standard.normalScale.y}`
738
+ let variants = cache.get(source)
739
+ if (!variants) {
740
+ variants = new Map()
741
+ cache.set(source, variants)
742
+ }
743
+ let canonical = variants.get(key)
744
+ if (!canonical) {
745
+ canonical = canonicalNormalTexture(source, standard.normalScale)
746
+ variants.set(key, canonical)
747
+ }
748
+ rememberDiscarded(root, source)
749
+ standard.normalMap = canonical
750
+ standard.normalScale.set(1, 1)
751
+ }
752
+ }
753
+
754
+ function materialIndexForTriangle(geometry: THREE.BufferGeometry, triangleOffset: number): number {
755
+ for (const group of geometry.groups) {
756
+ if (triangleOffset >= group.start && triangleOffset < group.start + group.count) {
757
+ return group.materialIndex ?? 0
758
+ }
759
+ }
760
+ return 0
761
+ }
762
+
763
+ function expandPortableSidedGeometry(
764
+ geometry: THREE.BufferGeometry,
765
+ materials: THREE.Material[],
766
+ ): THREE.BufferGeometry | null {
767
+ if (materials.every((material) => material.side === THREE.FrontSide)) return null
768
+ const source = geometry.index ? geometry.toNonIndexed() : geometry.clone()
769
+ const position = source.getAttribute('position')
770
+ if (!position || position.count % 3 !== 0) {
771
+ source.dispose()
772
+ throw new Error('Portable sidedness export requires triangle geometry')
773
+ }
774
+
775
+ const triangles: Array<{ offset: number; materialIndex: number; reverse: boolean }> = []
776
+ for (let offset = 0; offset < position.count; offset += 3) {
777
+ const materialIndex = materialIndexForTriangle(source, offset)
778
+ const side = materials[materialIndex]?.side ?? THREE.FrontSide
779
+ if (side !== THREE.BackSide) triangles.push({ offset, materialIndex, reverse: false })
780
+ if (side !== THREE.FrontSide) triangles.push({ offset, materialIndex, reverse: true })
781
+ }
782
+
783
+ const output = new THREE.BufferGeometry()
784
+ for (const [name, attribute] of Object.entries(source.attributes)) {
785
+ const ArrayType = attribute.array.constructor as new (length: number) => typeof attribute.array
786
+ const values = new ArrayType(triangles.length * 3 * attribute.itemSize)
787
+ const target = new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized)
788
+ for (let triangle = 0; triangle < triangles.length; triangle++) {
789
+ const entry = triangles[triangle]!
790
+ const order = entry.reverse ? [0, 2, 1] : [0, 1, 2]
791
+ for (let corner = 0; corner < 3; corner++) {
792
+ const sourceIndex = entry.offset + order[corner]!
793
+ const targetIndex = triangle * 3 + corner
794
+ for (let component = 0; component < attribute.itemSize; component++) {
795
+ let value = attribute.getComponent(sourceIndex, component)
796
+ if (entry.reverse && name === 'normal') value = -value
797
+ if (entry.reverse && name === 'tangent' && component === 3) value = -value
798
+ target.setComponent(targetIndex, component, value)
799
+ }
800
+ }
801
+ }
802
+ output.setAttribute(name, target)
803
+ }
804
+
805
+ let groupStart = 0
806
+ let groupMaterial = triangles[0]?.materialIndex ?? 0
807
+ for (let index = 1; index <= triangles.length; index++) {
808
+ const materialIndex = triangles[index]?.materialIndex
809
+ if (index < triangles.length && materialIndex === groupMaterial) continue
810
+ output.addGroup(groupStart * 3, (index - groupStart) * 3, groupMaterial)
811
+ groupStart = index
812
+ groupMaterial = materialIndex ?? 0
813
+ }
814
+ source.dispose()
815
+ return output
816
+ }
817
+
818
+ function bakeDoubleSidedMeshes(root: THREE.Object3D): void {
819
+ root.traverse((object) => {
820
+ const mesh = object as THREE.Mesh
821
+ if (!mesh.isMesh) return
822
+ const materials = materialsOf(mesh)
823
+ const geometry = expandPortableSidedGeometry(mesh.geometry, materials)
824
+ if (!geometry) return
825
+ const original = mesh.geometry
826
+ mesh.geometry = geometry
827
+ const converted = materials.map((material) => {
828
+ if (material.side === THREE.FrontSide) return material
829
+ const clone = material.clone()
830
+ clone.side = THREE.FrontSide
831
+ rememberDiscarded(root, material)
832
+ return clone
833
+ })
834
+ mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
835
+ rememberDiscarded(root, original)
836
+ })
837
+ }
838
+ const PORTABLE_COLOR_PROPERTIES = [
839
+ 'color',
840
+ 'emissive',
841
+ 'sheenColor',
842
+ 'specularColor',
843
+ 'attenuationColor',
844
+ ] as const
845
+
846
+ function clampPortableMaterialColors(root: THREE.Object3D): boolean {
847
+ const materials = new Set<THREE.Material>()
848
+ root.traverse((object) => {
849
+ if (!(object as THREE.Mesh).isMesh) return
850
+ for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
851
+ })
852
+
853
+ let clipped = false
854
+ for (const material of materials) {
855
+ const properties = material as unknown as Record<string, unknown>
856
+ for (const property of PORTABLE_COLOR_PROPERTIES) {
857
+ const color = properties[property] as THREE.Color | undefined
858
+ if (!color?.isColor) continue
859
+ if (color.r < 0 || color.r > 1 || color.g < 0 || color.g > 1 || color.b < 0 || color.b > 1) {
860
+ clipped = true
861
+ color.r = THREE.MathUtils.clamp(color.r, 0, 1)
862
+ color.g = THREE.MathUtils.clamp(color.g, 0, 1)
863
+ color.b = THREE.MathUtils.clamp(color.b, 0, 1)
864
+ }
865
+ }
866
+ }
867
+ return clipped
868
+ }
869
+
870
+ export function normalizePortableScene(
871
+ root: THREE.Object3D,
872
+ options: PortableNormalizeOptions = {},
873
+ ): string[] {
874
+ expandInstancedMeshes(root)
875
+ root.updateMatrixWorld(true)
876
+ freezeDeformedMeshes(root)
877
+ const clampedMaterialColors = clampPortableMaterialColors(root)
878
+ materializeDataTextures(root)
879
+ const clampedVertexColors = bakeVertexColors(root)
880
+ const clampedHdr = clampedMaterialColors || clampedVertexColors
881
+ canonicalizeAlphaMaps(root)
882
+ canonicalizeNormalMaps(root, options)
883
+ bakeDoubleSidedMeshes(root)
884
+ root.updateMatrixWorld(true)
885
+ return clampedHdr
886
+ ? [
887
+ 'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.',
888
+ ]
889
+ : []
890
+ }
891
+ /**
892
+ * Canonicalize baked static material details without freezing authored item
893
+ * deformation that existing saved-viewer animation clips still target.
894
+ */
895
+ export function normalizeViewerArtifactMaterials(
896
+ root: THREE.Object3D,
897
+ options: PortableNormalizeOptions = {},
898
+ ): string[] {
899
+ const clampedMaterialColors = clampPortableMaterialColors(root)
900
+ materializeDataTextures(root)
901
+ const clampedVertexColors = bakeVertexColors(root, true)
902
+ canonicalizeAlphaMaps(root)
903
+ canonicalizeNormalMaps(root, options)
904
+ return clampedMaterialColors || clampedVertexColors
905
+ ? [
906
+ 'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.',
907
+ ]
908
+ : []
909
+ }
910
+
911
+ /** Where the portable GLB conversion parks the authored opacity of glass it
912
+ * rewrote as transmission, so formats without transmission can fall back. */
913
+ export const GLASS_OPACITY_USERDATA = 'pascalGlassOpacity'
914
+
915
+ function cloneMaterialForUsdz(material: THREE.Material): THREE.Material {
916
+ const clone = material.clone()
917
+ clone.side = THREE.FrontSide
918
+ // USDPreviewSurface has no transmission and USDZExporter only writes
919
+ // `inputs:opacity`; hand glass back its authored opacity for Quick Look.
920
+ const physical = clone as THREE.MeshPhysicalMaterial
921
+ const authoredOpacity = clone.userData[GLASS_OPACITY_USERDATA]
922
+ if (
923
+ physical.isMeshPhysicalMaterial &&
924
+ physical.transmission > 0 &&
925
+ typeof authoredOpacity === 'number'
926
+ ) {
927
+ physical.transmission = 0
928
+ physical.transparent = true
929
+ physical.opacity = authoredOpacity
930
+ }
931
+ return clone
932
+ }
933
+
934
+ function reverseWindingPreservingNormals(geometry: THREE.BufferGeometry): void {
935
+ const index = geometry.getIndex()
936
+ if (index) {
937
+ for (let offset = 0; offset < index.count; offset += 3) {
938
+ const first = index.getX(offset)
939
+ index.setX(offset, index.getX(offset + 2))
940
+ index.setX(offset + 2, first)
941
+ }
942
+ index.needsUpdate = true
943
+ return
944
+ }
945
+
946
+ for (const attribute of Object.values(geometry.attributes)) {
947
+ for (let offset = 0; offset < attribute.count; offset += 3) {
948
+ for (let component = 0; component < attribute.itemSize; component++) {
949
+ const first = attribute.getComponent(offset, component)
950
+ attribute.setComponent(offset, component, attribute.getComponent(offset + 2, component))
951
+ attribute.setComponent(offset + 2, component, first)
952
+ }
953
+ }
954
+ attribute.needsUpdate = true
955
+ }
956
+ }
957
+
958
+ /** Make reflected static geometry agree with exported normals after world transforms. */
959
+ export function fixReflectedMeshWinding(root: THREE.Object3D): void {
960
+ root.updateMatrixWorld(true)
961
+ root.traverse((object) => {
962
+ const mesh = object as THREE.Mesh
963
+ if (!mesh.isMesh || mesh.matrixWorld.determinant() >= 0) return
964
+ const original = mesh.geometry
965
+ mesh.geometry = original.clone()
966
+ reverseWindingPreservingNormals(mesh.geometry)
967
+ rememberDiscarded(root, original)
968
+ })
969
+ }
970
+
971
+ /**
972
+ * Build a USDZ-only clone with world transforms baked into geometry. This
973
+ * removes unsupported negative-scale transforms without changing the shared
974
+ * prepared artifact or its identity hierarchy. Empty mesh containers become
975
+ * groups because USDZ requires vertex positions for every mesh primitive.
976
+ */
977
+ export function createUsdzScene(source: THREE.Object3D): THREE.Object3D {
978
+ source.updateMatrixWorld(true)
979
+
980
+ const cloneObject = (object: THREE.Object3D): THREE.Object3D => {
981
+ let clone: THREE.Object3D
982
+ const mesh = object as THREE.Mesh
983
+ if (mesh.isMesh && (mesh.geometry.getAttribute('position')?.count ?? 0) > 0) {
984
+ const geometry = mesh.geometry.clone()
985
+ geometry.applyMatrix4(mesh.matrixWorld)
986
+ if (mesh.matrixWorld.determinant() < 0) reverseWindingPreservingNormals(geometry)
987
+ const material = Array.isArray(mesh.material)
988
+ ? mesh.material.length === 1
989
+ ? cloneMaterialForUsdz(mesh.material[0]!)
990
+ : mesh.material.map(cloneMaterialForUsdz)
991
+ : cloneMaterialForUsdz(mesh.material)
992
+ clone = new THREE.Mesh(geometry, material)
993
+ } else {
994
+ clone = new THREE.Group()
995
+ }
996
+
997
+ clone.name = object.name
998
+ clone.visible = object.visible
999
+ clone.layers.mask = object.layers.mask
1000
+ clone.renderOrder = object.renderOrder
1001
+ clone.userData = cloneExportUserData(object.userData)
1002
+ for (const child of object.children) clone.add(cloneObject(child))
1003
+ return clone
1004
+ }
1005
+
1006
+ return cloneObject(source)
1007
+ }
1008
+
1009
+ /** Dispose geometry, materials, and export-owned texture handles exactly once. */
1010
+ export function disposeExportResources(
1011
+ root: THREE.Object3D,
1012
+ options: { textures?: boolean } = {},
1013
+ ): void {
1014
+ const geometries = new Set<THREE.BufferGeometry>()
1015
+ const materials = new Set<THREE.Material>()
1016
+ const textures = new Set<THREE.Texture>()
1017
+ root.traverse((object) => {
1018
+ const renderable = object as THREE.Mesh
1019
+ if (renderable.geometry) geometries.add(renderable.geometry)
1020
+ if (!renderable.material) return
1021
+ for (const material of materialsOf(renderable)) {
1022
+ materials.add(material)
1023
+ if (options.textures === false) continue
1024
+ const textured = material as TexturedMaterial
1025
+ for (const slot of MATERIAL_TEXTURE_SLOTS) {
1026
+ const texture = textured[slot]
1027
+ if (texture instanceof THREE.Texture) textures.add(texture)
1028
+ }
1029
+ }
1030
+ })
1031
+
1032
+ for (const resource of discardedResources.get(root) ?? []) {
1033
+ if (resource instanceof THREE.Texture) textures.add(resource)
1034
+ else if (resource instanceof THREE.Material) materials.add(resource)
1035
+ else geometries.add(resource)
1036
+ }
1037
+ discardedResources.delete(root)
1038
+
1039
+ for (const geometry of geometries) geometry.dispose()
1040
+ for (const material of materials) material.dispose()
1041
+ for (const texture of textures) texture.dispose()
1042
+ }