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

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