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