@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,366 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { strFromU8, unzipSync } from 'fflate'
3
+ import {
4
+ Box3,
5
+ BoxGeometry,
6
+ BufferGeometry,
7
+ Color,
8
+ DoubleSide,
9
+ Float32BufferAttribute,
10
+ FrontSide,
11
+ Group,
12
+ InstancedBufferAttribute,
13
+ InstancedMesh,
14
+ Matrix4,
15
+ Mesh,
16
+ MeshStandardMaterial,
17
+ Quaternion,
18
+ Vector3,
19
+ } from 'three'
20
+ import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
21
+ import {
22
+ createUsdzScene,
23
+ expandInstancedMeshes,
24
+ freezeDeformedMeshes,
25
+ normalizePortableScene,
26
+ } from './portable-export'
27
+
28
+ describe('portable vertex-color boundary', () => {
29
+ test('clips only over-bright channels while preserving ordinary linear colors', () => {
30
+ withCanvasCapture((capture) => {
31
+ const colors = new Float32Array([0.25, 0.5, 0.75, 1, 1.5, 0.4, 0.2, 0.5, 0.1, 0.2, 0.3, 0])
32
+ const sourceValues = Array.from(colors)
33
+ const geometry = triangleGeometry(colors)
34
+ const material = new MeshStandardMaterial({ vertexColors: true })
35
+ const mesh = new Mesh(geometry, material)
36
+ const root = new Group()
37
+ root.add(mesh)
38
+
39
+ const warnings = normalizePortableScene(root)
40
+
41
+ expect(warnings).toHaveLength(1)
42
+ expect(warnings[0]).toMatch(/color.*clip/i)
43
+ expect(Array.from(colors)).toEqual(sourceValues)
44
+ expect(portableColorAt(mesh, 0, capture)).toEqual([
45
+ encodeSrgbByte(0.25),
46
+ encodeSrgbByte(0.5),
47
+ encodeSrgbByte(0.75),
48
+ 255,
49
+ ])
50
+ expect(portableColorAt(mesh, 1, capture)).toEqual([
51
+ 255,
52
+ encodeSrgbByte(0.4),
53
+ encodeSrgbByte(0.2),
54
+ 128,
55
+ ])
56
+ expect(portableColorAt(mesh, 2, capture)).toEqual([
57
+ encodeSrgbByte(0.1),
58
+ encodeSrgbByte(0.2),
59
+ encodeSrgbByte(0.3),
60
+ 0,
61
+ ])
62
+ const portableMaterial = mesh.material as MeshStandardMaterial
63
+ expect(portableMaterial.emissive.getHex()).toBe(0)
64
+ expect(portableMaterial.emissiveMap).toBeNull()
65
+ })
66
+ })
67
+
68
+ test('does not warn when every channel is already portable', () => {
69
+ withCanvasCapture(() => {
70
+ const geometry = triangleGeometry(
71
+ new Float32Array([0, 0.25, 0.5, 1, 0.75, 1, 0.125, 1, 0.3, 0.4, 0.6, 1]),
72
+ )
73
+ const mesh = new Mesh(geometry, new MeshStandardMaterial({ vertexColors: true }))
74
+ const root = new Group()
75
+ root.add(mesh)
76
+
77
+ expect(normalizePortableScene(root)).toEqual([])
78
+ })
79
+ })
80
+ })
81
+
82
+ describe('portable geometry normalization', () => {
83
+ test('preserves two transformed instance populations without changing source data', () => {
84
+ const sourceMaterial = new MeshStandardMaterial({ color: '#808080' })
85
+ const sourceGeometry = new BoxGeometry(1, 2, 3)
86
+ const shaderInstancePayload = new InstancedBufferAttribute(new Float32Array(32), 16)
87
+ sourceGeometry.setAttribute('shaderInstancePayload', shaderInstancePayload)
88
+ const instances = new InstancedMesh(sourceGeometry, sourceMaterial, 2)
89
+ instances.name = 'asymmetric-instance'
90
+ instances.position.set(10, 0, -2)
91
+ const firstMatrix = new Matrix4().compose(
92
+ new Vector3(2, 0, 0),
93
+ new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2),
94
+ new Vector3(1, 2, 1),
95
+ )
96
+ const reflectedMatrix = new Matrix4().compose(
97
+ new Vector3(-3, 1, 4),
98
+ new Quaternion(),
99
+ new Vector3(-2, 1, 0.5),
100
+ )
101
+ instances.setMatrixAt(0, firstMatrix)
102
+ instances.setMatrixAt(1, reflectedMatrix)
103
+ instances.setColorAt(0, new Color('#ff0000'))
104
+ instances.setColorAt(1, new Color('#0000ff'))
105
+ const root = new Group()
106
+ root.add(instances)
107
+
108
+ expandInstancedMeshes(root)
109
+ root.updateMatrixWorld(true)
110
+
111
+ const firstPopulation = new Box3().makeEmpty()
112
+ const reflectedPopulation = new Box3().makeEmpty()
113
+ let triangleCount = 0
114
+ let firstOutputTint: Color | null = null
115
+ let reflectedOutputTint: Color | null = null
116
+ root.traverse((object) => {
117
+ const mesh = object as Mesh
118
+ if (!mesh.isMesh) return
119
+ const positions = mesh.geometry.getAttribute('position')
120
+ triangleCount += (mesh.geometry.getIndex()?.count ?? positions.count) / 3
121
+ for (const attribute of Object.values(mesh.geometry.attributes)) {
122
+ expect((attribute as InstancedBufferAttribute).isInstancedBufferAttribute).not.toBe(true)
123
+ expect(attribute.count).toBe(positions.count)
124
+ }
125
+ for (let index = 0; index < positions.count; index += 1) {
126
+ const world = new Vector3()
127
+ .fromBufferAttribute(positions, index)
128
+ .applyMatrix4(mesh.matrixWorld)
129
+ if (world.x < 9) {
130
+ reflectedPopulation.expandByPoint(world)
131
+ reflectedOutputTint ??= portableLinearTintAt(mesh, index)
132
+ } else {
133
+ firstPopulation.expandByPoint(world)
134
+ firstOutputTint ??= portableLinearTintAt(mesh, index)
135
+ }
136
+ }
137
+ })
138
+ expect(triangleCount).toBe(24)
139
+ expectVector(firstPopulation.getCenter(new Vector3()), new Vector3(12, 0, -2))
140
+ expectVector(firstPopulation.getSize(new Vector3()), new Vector3(3, 4, 1))
141
+ expectVector(reflectedPopulation.getCenter(new Vector3()), new Vector3(7, 1, 2))
142
+ expectVector(reflectedPopulation.getSize(new Vector3()), new Vector3(2, 2, 1.5))
143
+ expect(firstOutputTint).not.toBeNull()
144
+ expect(reflectedOutputTint).not.toBeNull()
145
+ expectColor(firstOutputTint!, sourceMaterial.color.clone().multiply(new Color('#ff0000')))
146
+ expectColor(reflectedOutputTint!, sourceMaterial.color.clone().multiply(new Color('#0000ff')))
147
+ const retainedMatrix = new Matrix4()
148
+ instances.getMatrixAt(1, retainedMatrix)
149
+ expect(retainedMatrix.equals(reflectedMatrix)).toBe(true)
150
+ const retainedColor = new Color()
151
+ expect(instances.geometry.getAttribute('shaderInstancePayload')).toBe(shaderInstancePayload)
152
+ instances.getColorAt(1, retainedColor)
153
+ expect(retainedColor.equals(new Color('#0000ff'))).toBe(true)
154
+ })
155
+
156
+ test('freezes a settled morph pose into static vertex positions without mutating its source', () => {
157
+ const geometry = new BufferGeometry()
158
+ const sourcePositions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0])
159
+ const morphDelta = new Float32Array([0, 0, 0, 0, 0, 0, 0, 2, 0])
160
+ geometry.setAttribute('position', new Float32BufferAttribute(sourcePositions, 3))
161
+ geometry.morphAttributes.position = [new Float32BufferAttribute(morphDelta, 3)]
162
+ geometry.morphTargetsRelative = true
163
+ const mesh = new Mesh(geometry, new MeshStandardMaterial())
164
+ mesh.updateMorphTargets()
165
+ mesh.morphTargetInfluences![0] = 0.25
166
+ const root = new Group()
167
+ root.add(mesh)
168
+
169
+ freezeDeformedMeshes(root)
170
+
171
+ const frozen = mesh.geometry.getAttribute('position')
172
+ expect([frozen.getX(2), frozen.getY(2), frozen.getZ(2)]).toEqual([0, 1.5, 0])
173
+ expect(mesh.geometry.morphAttributes.position).toBeUndefined()
174
+ expect(mesh.morphTargetInfluences).toBeUndefined()
175
+ expect(Array.from(sourcePositions)).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 0])
176
+ expect(Array.from(morphDelta)).toEqual([0, 0, 0, 0, 0, 0, 0, 2, 0])
177
+ })
178
+
179
+ test('bakes reflected USDZ transforms with outward-consistent winding and normals', () => {
180
+ const geometry = new BufferGeometry()
181
+ geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 1], 3))
182
+ geometry.computeVertexNormals()
183
+ const source = new Mesh(geometry, new MeshStandardMaterial())
184
+ source.name = 'reflected-triangle'
185
+ source.position.set(2, -1, 3)
186
+ source.scale.set(-2, 1, 0.5)
187
+ const root = new Group()
188
+ root.add(source)
189
+
190
+ const portable = createUsdzScene(root)
191
+ const reflected = portable.getObjectByName('reflected-triangle') as Mesh
192
+ const positions = reflected.geometry.getAttribute('position')
193
+ const normals = reflected.geometry.getAttribute('normal')
194
+ const first = new Vector3().fromBufferAttribute(positions, 0)
195
+ const second = new Vector3().fromBufferAttribute(positions, 1)
196
+ const third = new Vector3().fromBufferAttribute(positions, 2)
197
+ const faceNormal = second.clone().sub(first).cross(third.clone().sub(first)).normalize()
198
+ const vertexNormal = new Vector3().fromBufferAttribute(normals, 0).normalize()
199
+
200
+ expect(faceNormal.dot(vertexNormal)).toBeGreaterThan(0.999)
201
+ expectVector(reflected.position, new Vector3())
202
+ expectVector(reflected.scale, new Vector3(1, 1, 1))
203
+ })
204
+
205
+ test('emits every referenced geometry member for a one-element material array', async () => {
206
+ const root = new Group()
207
+ const mesh = new Mesh(new BoxGeometry(1, 1, 1), [new MeshStandardMaterial()])
208
+ root.add(mesh)
209
+
210
+ const archive = await new USDZExporter().parseAsync(createUsdzScene(root))
211
+ const files = unzipSync(archive)
212
+ const modelFile = files['model.usda']
213
+ expect(modelFile).toBeDefined()
214
+ const model = strFromU8(modelFile!)
215
+ const geometryReferences = Array.from(
216
+ model.matchAll(/@\.\/(geometries\/[^@]+)@/g),
217
+ (match) => match[1]!,
218
+ )
219
+
220
+ expect(geometryReferences).toHaveLength(1)
221
+ for (const reference of geometryReferences) {
222
+ expect(files[reference]).toBeDefined()
223
+ }
224
+ })
225
+
226
+ test('retains RGBA and back-face visibility without prescribing GLB representation', () => {
227
+ withCanvasCapture((capture) => {
228
+ const geometry = triangleGeometry(new Float32Array([1, 0, 0, 1, 0, 1, 0, 0.5, 0, 0, 1, 0]))
229
+ geometry.setAttribute('normal', new Float32BufferAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1], 3))
230
+ const mesh = new Mesh(
231
+ geometry,
232
+ new MeshStandardMaterial({ side: DoubleSide, vertexColors: true }),
233
+ )
234
+ const root = new Group()
235
+ root.add(mesh)
236
+
237
+ expect(normalizePortableScene(root)).toEqual([])
238
+
239
+ expect(portableColorAt(mesh, 0, capture)).toEqual([255, 0, 0, 255])
240
+ expect(portableColorAt(mesh, 1, capture)).toEqual([0, 255, 0, 128])
241
+ expect(portableColorAt(mesh, 2, capture)).toEqual([0, 0, 255, 0])
242
+ const material = mesh.material as MeshStandardMaterial
243
+ expect(material.transparent).toBe(true)
244
+ if (material.side === FrontSide) {
245
+ expect(mesh.geometry.getAttribute('position').count).toBe(6)
246
+ expect(mesh.geometry.getAttribute('normal').getZ(0)).toBe(1)
247
+ expect(mesh.geometry.getAttribute('normal').getZ(3)).toBe(-1)
248
+ } else {
249
+ expect(material.side).toBe(DoubleSide)
250
+ expect(mesh.geometry.getAttribute('position').count).toBe(3)
251
+ }
252
+ })
253
+ })
254
+ })
255
+
256
+ function expectVector(actual: Vector3, expected: Vector3): void {
257
+ expect(actual.x).toBeCloseTo(expected.x, 6)
258
+ expect(actual.y).toBeCloseTo(expected.y, 6)
259
+ expect(actual.z).toBeCloseTo(expected.z, 6)
260
+ }
261
+
262
+ function triangleGeometry(colors: Float32Array): BufferGeometry {
263
+ const geometry = new BufferGeometry()
264
+ geometry.setAttribute('position', new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3))
265
+ geometry.setAttribute('color', new Float32BufferAttribute(colors, 4))
266
+ return geometry
267
+ }
268
+
269
+ function portableLinearTintAt(mesh: Mesh, vertexIndex: number): Color {
270
+ const material = (
271
+ Array.isArray(mesh.material) ? mesh.material[0] : mesh.material
272
+ ) as MeshStandardMaterial
273
+ const tint = material.color.clone()
274
+ const color = mesh.geometry.getAttribute('color')
275
+ if (color) {
276
+ tint.multiply(
277
+ new Color(color.getX(vertexIndex), color.getY(vertexIndex), color.getZ(vertexIndex)),
278
+ )
279
+ }
280
+ return tint
281
+ }
282
+
283
+ function expectColor(actual: Color, expected: Color): void {
284
+ expect(actual.r).toBeCloseTo(expected.r, 6)
285
+ expect(actual.g).toBeCloseTo(expected.g, 6)
286
+ expect(actual.b).toBeCloseTo(expected.b, 6)
287
+ }
288
+
289
+ function portableColorAt(mesh: Mesh, vertexIndex: number, capture: () => ImageData): number[] {
290
+ const colors = mesh.geometry.getAttribute('color')
291
+ if (colors) {
292
+ return [
293
+ encodeSrgbByte(colors.getX(vertexIndex)),
294
+ encodeSrgbByte(colors.getY(vertexIndex)),
295
+ encodeSrgbByte(colors.getZ(vertexIndex)),
296
+ Math.round((colors.itemSize >= 4 ? colors.getW(vertexIndex) : 1) * 255),
297
+ ]
298
+ }
299
+ const material = mesh.material as MeshStandardMaterial
300
+ if (!material.map) throw new Error('Portable color has no material-connected carrier')
301
+ const channel = material.map.channel ?? 0
302
+ const uvName = channel === 0 ? 'uv' : `uv${channel}`
303
+ const uvs = mesh.geometry.getAttribute(uvName)
304
+ if (!uvs) throw new Error(`Portable color map has no geometry ${uvName} coordinates`)
305
+ const image = capture()
306
+ const u = Math.max(0, Math.min(1, uvs.getX(vertexIndex)))
307
+ const rawV = Math.max(0, Math.min(1, uvs.getY(vertexIndex)))
308
+ const v = material.map.flipY ? 1 - rawV : rawV
309
+ return pixel(
310
+ image.data,
311
+ image.width,
312
+ Math.min(image.width - 1, Math.floor(u * image.width)),
313
+ Math.min(image.height - 1, Math.floor(v * image.height)),
314
+ )
315
+ }
316
+
317
+ function encodeSrgbByte(linear: number): number {
318
+ const clipped = Math.max(0, Math.min(1, linear))
319
+ const encoded = clipped <= 0.0031308 ? clipped * 12.92 : 1.055 * clipped ** (1 / 2.4) - 0.055
320
+ return Math.round(encoded * 255)
321
+ }
322
+
323
+ function pixel(pixels: Uint8ClampedArray, width: number, x: number, y: number): number[] {
324
+ const offset = (y * width + x) * 4
325
+ return Array.from(pixels.subarray(offset, offset + 4))
326
+ }
327
+
328
+ function withCanvasCapture(run: (capture: () => ImageData) => void): void {
329
+ const globals = globalThis as unknown as { document?: Document }
330
+ const previousDocument = globals.document
331
+ let captured: ImageData | null = null
332
+ const canvas = {
333
+ width: 0,
334
+ height: 0,
335
+ getContext: () => ({
336
+ createImageData: (width: number, height: number) => {
337
+ captured = {
338
+ colorSpace: 'srgb',
339
+ data: new Uint8ClampedArray(width * height * 4),
340
+ height,
341
+ width,
342
+ } as ImageData
343
+ return captured
344
+ },
345
+ putImageData: (image: ImageData) => {
346
+ captured = image
347
+ },
348
+ }),
349
+ } as unknown as HTMLCanvasElement
350
+ globals.document = {
351
+ createElement: (tagName: string) => {
352
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
353
+ return canvas
354
+ },
355
+ } as unknown as Document
356
+
357
+ try {
358
+ run(() => {
359
+ if (!captured) throw new Error('Portable normalization did not emit atlas pixels')
360
+ return captured
361
+ })
362
+ } finally {
363
+ if (previousDocument) globals.document = previousDocument
364
+ else delete globals.document
365
+ }
366
+ }