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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,735 @@
1
+ import * as THREE from 'three'
2
+ import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
3
+ import { HalfEdgeMap } from 'three-bvh-csg'
4
+
5
+ const MILLIMETERS_PER_METER = 1000
6
+ const EDGE_CONNECTIVITY_EPSILON_METERS = 1e-5
7
+ const EDGE_INCIDENCE_EPSILON_METERS = 1e-7
8
+ const MAX_EDGE_CHECK_TRIANGLES = 500_000
9
+ const DEGENERATE_CROSS_LENGTH_SQ = 1e-12
10
+
11
+ const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
12
+ EMPTY_POSITION_GEOMETRY.setAttribute(
13
+ 'position',
14
+ new THREE.Float32BufferAttribute(new Float32Array(0), 3),
15
+ )
16
+
17
+ export type PrintExportDiagnostic = {
18
+ severity: 'error' | 'warning' | 'info'
19
+ code: string
20
+ message: string
21
+ nodeIds?: string[]
22
+ }
23
+
24
+ export type PrintArtifactFormat = 'stl' | '3mf'
25
+
26
+ export type PrintExportOptions = {
27
+ scale: number
28
+ compiled?: boolean
29
+ indexedTopology?: boolean
30
+ format?: PrintArtifactFormat
31
+ /** Original Y-up world elevation that becomes print Z=0. Omit to use geometry minimum. */
32
+ sourceBedElevationMeters?: number
33
+ }
34
+
35
+ export type PrintExportBounds = {
36
+ min: { x: number; y: number; z: number }
37
+ max: { x: number; y: number; z: number }
38
+ width: number
39
+ depth: number
40
+ height: number
41
+ }
42
+
43
+ export type PrintExportReport = {
44
+ kind: 'print-export-report'
45
+ version: 2
46
+ format: PrintArtifactFormat
47
+ scale: number
48
+ units: 'millimeter'
49
+ orientation: 'z-up'
50
+ status: 'pass' | 'warning' | 'blocked'
51
+ bounds: PrintExportBounds | null
52
+ triangleCount: number
53
+ invalidTriangleCount: number
54
+ degenerateTriangleCount: number
55
+ boundaryEdgeCount: number | null
56
+ nonManifoldEdgeCount: number | null
57
+ connectedComponentCount: number | null
58
+ solidComponentCount: number | null
59
+ invertedWinding: boolean | null
60
+ volumeMm3: number
61
+ minimumFeatureThicknessMm?: number | null
62
+ diagnostics: PrintExportDiagnostic[]
63
+ }
64
+
65
+ export type PrintStlExport = {
66
+ buffer: ArrayBuffer
67
+ report: PrintExportReport
68
+ }
69
+
70
+ export type PrintMeshData = {
71
+ positions: Float64Array<ArrayBuffer>
72
+ indices: Uint32Array<ArrayBuffer>
73
+ }
74
+
75
+ type BoundsMeasurement = {
76
+ min: THREE.Vector3
77
+ max: THREE.Vector3
78
+ } | null
79
+
80
+ type EdgeTopologyMeasurement = {
81
+ boundaryEdgeCount: number | null
82
+ nonManifoldEdgeCount: number | null
83
+ connectedComponentCount: number | null
84
+ componentIndexByTriangle: Int32Array<ArrayBuffer> | null
85
+ edgeCheckComplete: boolean
86
+ }
87
+
88
+ function ensureMeshPositions(root: THREE.Object3D) {
89
+ root.traverse((object) => {
90
+ const mesh = object as THREE.Mesh
91
+ if (mesh.isMesh && !mesh.geometry?.getAttribute('position')) {
92
+ mesh.geometry = EMPTY_POSITION_GEOMETRY
93
+ }
94
+ })
95
+ }
96
+
97
+ function isFiniteVector(vector: THREE.Vector3): boolean {
98
+ return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z)
99
+ }
100
+
101
+ function forEachTriangle(
102
+ root: THREE.Object3D,
103
+ visit: (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3) => void,
104
+ ) {
105
+ root.updateMatrixWorld(true)
106
+
107
+ const a = new THREE.Vector3()
108
+ const b = new THREE.Vector3()
109
+ const c = new THREE.Vector3()
110
+
111
+ root.traverse((object) => {
112
+ const mesh = object as THREE.Mesh
113
+ if (!mesh.isMesh) return
114
+
115
+ const position = mesh.geometry.getAttribute('position')
116
+ if (!position) return
117
+ const index = mesh.geometry.index
118
+ const skinnedMesh = mesh as THREE.SkinnedMesh
119
+
120
+ const readVertex = (vertexIndex: number, target: THREE.Vector3) => {
121
+ target.fromBufferAttribute(position, vertexIndex)
122
+ if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, target)
123
+ target.applyMatrix4(mesh.matrixWorld)
124
+ }
125
+
126
+ const visitIndices = (indexA: number, indexB: number, indexC: number) => {
127
+ readVertex(indexA, a)
128
+ readVertex(indexB, b)
129
+ readVertex(indexC, c)
130
+ visit(a, b, c)
131
+ }
132
+
133
+ if (index) {
134
+ for (let offset = 0; offset + 2 < index.count; offset += 3) {
135
+ visitIndices(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2))
136
+ }
137
+ return
138
+ }
139
+
140
+ for (let offset = 0; offset + 2 < position.count; offset += 3) {
141
+ visitIndices(offset, offset + 1, offset + 2)
142
+ }
143
+ })
144
+ }
145
+
146
+ function measureBounds(root: THREE.Object3D): BoundsMeasurement {
147
+ const min = new THREE.Vector3(
148
+ Number.POSITIVE_INFINITY,
149
+ Number.POSITIVE_INFINITY,
150
+ Number.POSITIVE_INFINITY,
151
+ )
152
+ const max = new THREE.Vector3(
153
+ Number.NEGATIVE_INFINITY,
154
+ Number.NEGATIVE_INFINITY,
155
+ Number.NEGATIVE_INFINITY,
156
+ )
157
+ let hasFiniteTriangle = false
158
+
159
+ forEachTriangle(root, (a, b, c) => {
160
+ if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return
161
+ min.min(a).min(b).min(c)
162
+ max.max(a).max(b).max(c)
163
+ hasFiniteTriangle = true
164
+ })
165
+
166
+ return hasFiniteTriangle ? { min, max } : null
167
+ }
168
+
169
+ function pointKey(point: THREE.Vector3): string {
170
+ return `${Math.round(point.x / EDGE_INCIDENCE_EPSILON_METERS)},${Math.round(
171
+ point.y / EDGE_INCIDENCE_EPSILON_METERS,
172
+ )},${Math.round(point.z / EDGE_INCIDENCE_EPSILON_METERS)}`
173
+ }
174
+
175
+ function edgeKey(a: THREE.Vector3, b: THREE.Vector3): string {
176
+ const keyA = pointKey(a)
177
+ const keyB = pointKey(b)
178
+ return keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`
179
+ }
180
+
181
+ function indexedNonManifoldEdgeCount(root: THREE.Object3D): number | null {
182
+ let hasGeometry = false
183
+ let nonManifoldEdgeCount = 0
184
+ for (const object of root.children) object.updateMatrixWorld(true)
185
+
186
+ root.traverse((object) => {
187
+ const mesh = object as THREE.Mesh
188
+ if (!mesh.isMesh) return
189
+ const position = mesh.geometry.getAttribute('position')
190
+ if (!position || position.count === 0) return
191
+ const index = mesh.geometry.getIndex()
192
+ if (!index) {
193
+ nonManifoldEdgeCount = Number.NaN
194
+ return
195
+ }
196
+ hasGeometry = true
197
+ const edges = new Map<string, number>()
198
+ const add = (a: number, b: number) => {
199
+ const key = a < b ? `${a}|${b}` : `${b}|${a}`
200
+ edges.set(key, (edges.get(key) ?? 0) + 1)
201
+ }
202
+ for (let offset = 0; offset + 2 < index.count; offset += 3) {
203
+ const a = index.getX(offset)
204
+ const b = index.getX(offset + 1)
205
+ const c = index.getX(offset + 2)
206
+ add(a, b)
207
+ add(b, c)
208
+ add(c, a)
209
+ }
210
+ for (const uses of edges.values()) {
211
+ if (uses > 2) nonManifoldEdgeCount += 1
212
+ }
213
+ })
214
+
215
+ return hasGeometry && Number.isFinite(nonManifoldEdgeCount) ? nonManifoldEdgeCount : null
216
+ }
217
+
218
+ function analyzeEdgeTopology(
219
+ root: THREE.Object3D,
220
+ useIndexedIncidence: boolean,
221
+ ): EdgeTopologyMeasurement {
222
+ const edges = new Map<string, number>()
223
+ const halfEdgePositions: number[] = []
224
+ const topologyTriangleIndices: number[] = []
225
+ const componentParents: number[] = []
226
+ const componentRanks: number[] = []
227
+ let edgeCheckComplete = true
228
+ let triangleCount = 0
229
+
230
+ const findComponent = (triangleIndex: number): number => {
231
+ let root = triangleIndex
232
+ while (componentParents[root] !== root) root = componentParents[root]!
233
+ let current = triangleIndex
234
+ while (componentParents[current] !== root) {
235
+ const parent = componentParents[current]!
236
+ componentParents[current] = root
237
+ current = parent
238
+ }
239
+ return root
240
+ }
241
+
242
+ const unionComponents = (first: number, second: number) => {
243
+ let firstRoot = findComponent(first)
244
+ let secondRoot = findComponent(second)
245
+ if (firstRoot === secondRoot) return
246
+ const firstRank = componentRanks[firstRoot] ?? 0
247
+ const secondRank = componentRanks[secondRoot] ?? 0
248
+ if (firstRank < secondRank) [firstRoot, secondRoot] = [secondRoot, firstRoot]
249
+ componentParents[secondRoot] = firstRoot
250
+ if (firstRank === secondRank) componentRanks[firstRoot] = firstRank + 1
251
+ }
252
+
253
+ const addConnectedEdge = (a: THREE.Vector3, b: THREE.Vector3, triangleIndex: number) => {
254
+ const key = edgeKey(a, b)
255
+ const encoded = edges.get(key)
256
+ if (encoded === undefined) {
257
+ edges.set(key, triangleIndex * 4 + 1)
258
+ return
259
+ }
260
+ const firstTriangle = Math.floor(encoded / 4)
261
+ const uses = encoded % 4
262
+ unionComponents(firstTriangle, triangleIndex)
263
+ edges.set(key, firstTriangle * 4 + Math.min(uses + 1, 3))
264
+ }
265
+
266
+ forEachTriangle(root, (a, b, c) => {
267
+ const triangleIndex = triangleCount
268
+ triangleCount += 1
269
+ if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return
270
+ if (edgeCheckComplete && triangleCount > MAX_EDGE_CHECK_TRIANGLES) {
271
+ edges.clear()
272
+ halfEdgePositions.length = 0
273
+ topologyTriangleIndices.length = 0
274
+ componentParents.length = 0
275
+ componentRanks.length = 0
276
+ edgeCheckComplete = false
277
+ }
278
+ if (!edgeCheckComplete) return
279
+
280
+ componentParents[triangleIndex] = triangleIndex
281
+ componentRanks[triangleIndex] = 0
282
+ addConnectedEdge(a, b, triangleIndex)
283
+ addConnectedEdge(b, c, triangleIndex)
284
+ addConnectedEdge(c, a, triangleIndex)
285
+ topologyTriangleIndices.push(triangleIndex)
286
+ halfEdgePositions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
287
+ })
288
+
289
+ if (!edgeCheckComplete) {
290
+ return {
291
+ boundaryEdgeCount: null,
292
+ nonManifoldEdgeCount: null,
293
+ connectedComponentCount: null,
294
+ componentIndexByTriangle: null,
295
+ edgeCheckComplete,
296
+ }
297
+ }
298
+
299
+ const connectivityGeometry = new THREE.BufferGeometry()
300
+ connectivityGeometry.setAttribute(
301
+ 'position',
302
+ new THREE.BufferAttribute(new Float64Array(halfEdgePositions), 3),
303
+ )
304
+ const halfEdges = new HalfEdgeMap() as HalfEdgeMap & {
305
+ data: Int32Array<ArrayBuffer>
306
+ disjointConnections: Map<number, number[]> | null
307
+ matchDisjointEdges: boolean
308
+ degenerateEpsilon: number
309
+ unmatchedEdges: number
310
+ }
311
+ halfEdges.matchDisjointEdges = true
312
+ halfEdges.degenerateEpsilon = EDGE_CONNECTIVITY_EPSILON_METERS
313
+ halfEdges.updateFrom(connectivityGeometry)
314
+ const boundaryEdgeCount = halfEdges.unmatchedEdges
315
+ for (let localEdgeIndex = 0; localEdgeIndex < halfEdges.data.length; localEdgeIndex += 1) {
316
+ const triangleIndex = topologyTriangleIndices[Math.floor(localEdgeIndex / 3)]
317
+ if (triangleIndex === undefined) continue
318
+ const siblingEdgeIndex = halfEdges.data[localEdgeIndex] ?? -1
319
+ if (siblingEdgeIndex >= 0) {
320
+ const siblingTriangleIndex = topologyTriangleIndices[Math.floor(siblingEdgeIndex / 3)]
321
+ if (siblingTriangleIndex !== undefined) unionComponents(triangleIndex, siblingTriangleIndex)
322
+ }
323
+ for (const disjointEdgeIndex of halfEdges.disjointConnections?.get(localEdgeIndex) ?? []) {
324
+ const disjointTriangleIndex = topologyTriangleIndices[Math.floor(disjointEdgeIndex / 3)]
325
+ if (disjointTriangleIndex !== undefined) unionComponents(triangleIndex, disjointTriangleIndex)
326
+ }
327
+ }
328
+ connectivityGeometry.dispose()
329
+
330
+ const componentIndexByTriangle = new Int32Array(triangleCount)
331
+ componentIndexByTriangle.fill(-1)
332
+ const componentIndexByRoot = new Map<number, number>()
333
+ for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
334
+ if (componentParents[triangleIndex] === undefined) continue
335
+ const root = findComponent(triangleIndex)
336
+ let componentIndex = componentIndexByRoot.get(root)
337
+ if (componentIndex === undefined) {
338
+ componentIndex = componentIndexByRoot.size
339
+ componentIndexByRoot.set(root, componentIndex)
340
+ }
341
+ componentIndexByTriangle[triangleIndex] = componentIndex
342
+ }
343
+
344
+ let nonManifoldEdgeCount = useIndexedIncidence ? indexedNonManifoldEdgeCount(root) : null
345
+ if (nonManifoldEdgeCount === null) {
346
+ nonManifoldEdgeCount = 0
347
+ for (const encoded of edges.values()) {
348
+ if (encoded % 4 > 2) nonManifoldEdgeCount += 1
349
+ }
350
+ }
351
+
352
+ return {
353
+ boundaryEdgeCount,
354
+ nonManifoldEdgeCount,
355
+ connectedComponentCount: componentIndexByRoot.size,
356
+ componentIndexByTriangle,
357
+ edgeCheckComplete,
358
+ }
359
+ }
360
+
361
+ function analyzePrintScene(
362
+ root: THREE.Object3D,
363
+ scale: number,
364
+ edgeTopology: EdgeTopologyMeasurement,
365
+ compiled: boolean,
366
+ format: PrintArtifactFormat,
367
+ ): PrintExportReport {
368
+ const min = new THREE.Vector3(
369
+ Number.POSITIVE_INFINITY,
370
+ Number.POSITIVE_INFINITY,
371
+ Number.POSITIVE_INFINITY,
372
+ )
373
+ const max = new THREE.Vector3(
374
+ Number.NEGATIVE_INFINITY,
375
+ Number.NEGATIVE_INFINITY,
376
+ Number.NEGATIVE_INFINITY,
377
+ )
378
+ const ab = new THREE.Vector3()
379
+ const ac = new THREE.Vector3()
380
+ const areaCross = new THREE.Vector3()
381
+ const volumeCross = new THREE.Vector3()
382
+ let triangleCount = 0
383
+ let invalidTriangleCount = 0
384
+ let degenerateTriangleCount = 0
385
+ let signedVolumeMm3 = 0
386
+ let hasFiniteTriangle = false
387
+ const componentSignedVolumesMm3 =
388
+ edgeTopology.connectedComponentCount === null
389
+ ? null
390
+ : Array.from({ length: edgeTopology.connectedComponentCount }, () => 0)
391
+
392
+ forEachTriangle(root, (a, b, c) => {
393
+ const triangleIndex = triangleCount
394
+ triangleCount += 1
395
+ if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) {
396
+ invalidTriangleCount += 1
397
+ return
398
+ }
399
+
400
+ min.min(a).min(b).min(c)
401
+ max.max(a).max(b).max(c)
402
+ hasFiniteTriangle = true
403
+
404
+ ab.subVectors(b, a)
405
+ ac.subVectors(c, a)
406
+ areaCross.crossVectors(ab, ac)
407
+ if (areaCross.lengthSq() <= DEGENERATE_CROSS_LENGTH_SQ) {
408
+ degenerateTriangleCount += 1
409
+ }
410
+
411
+ volumeCross.crossVectors(b, c)
412
+ const triangleVolumeMm3 = a.dot(volumeCross) / 6
413
+ signedVolumeMm3 += triangleVolumeMm3
414
+ const componentIndex = edgeTopology.componentIndexByTriangle?.[triangleIndex] ?? -1
415
+ if (componentSignedVolumesMm3 && componentIndex >= 0) {
416
+ componentSignedVolumesMm3[componentIndex] =
417
+ (componentSignedVolumesMm3[componentIndex] ?? 0) + triangleVolumeMm3
418
+ }
419
+ })
420
+
421
+ const { boundaryEdgeCount, nonManifoldEdgeCount, connectedComponentCount, edgeCheckComplete } =
422
+ edgeTopology
423
+ const hasClosedTopology =
424
+ edgeCheckComplete &&
425
+ boundaryEdgeCount === 0 &&
426
+ nonManifoldEdgeCount === 0 &&
427
+ invalidTriangleCount === 0 &&
428
+ degenerateTriangleCount === 0
429
+ const solidComponentCount =
430
+ hasClosedTopology && componentSignedVolumesMm3
431
+ ? componentSignedVolumesMm3.filter((volume) => volume > 1e-6).length
432
+ : null
433
+ const inwardComponentCount =
434
+ hasClosedTopology && componentSignedVolumesMm3
435
+ ? componentSignedVolumesMm3.filter((volume) => volume < -1e-6).length
436
+ : null
437
+ const invertedWinding = hasClosedTopology && triangleCount > 0 ? signedVolumeMm3 < -1e-6 : null
438
+
439
+ const bounds = hasFiniteTriangle
440
+ ? {
441
+ min: { x: min.x, y: min.y, z: min.z },
442
+ max: { x: max.x, y: max.y, z: max.z },
443
+ width: max.x - min.x,
444
+ depth: max.y - min.y,
445
+ height: max.z - min.z,
446
+ }
447
+ : null
448
+
449
+ const diagnostics: PrintExportDiagnostic[] = []
450
+ if (triangleCount === 0) {
451
+ diagnostics.push({
452
+ severity: 'error',
453
+ code: 'no_triangles',
454
+ message: 'No printable triangles remain after applying the export scope.',
455
+ })
456
+ }
457
+ if (invalidTriangleCount > 0) {
458
+ diagnostics.push({
459
+ severity: 'error',
460
+ code: 'non_finite_geometry',
461
+ message: `${invalidTriangleCount.toLocaleString()} triangle${
462
+ invalidTriangleCount === 1 ? '' : 's'
463
+ } contain non-finite coordinates.`,
464
+ })
465
+ }
466
+ if (degenerateTriangleCount > 0) {
467
+ diagnostics.push({
468
+ severity: 'error',
469
+ code: 'degenerate_triangles',
470
+ message: `${degenerateTriangleCount.toLocaleString()} zero-area or near-zero-area triangle${
471
+ degenerateTriangleCount === 1 ? '' : 's'
472
+ } prevent a print-ready artifact.`,
473
+ })
474
+ }
475
+ if (boundaryEdgeCount && boundaryEdgeCount > 0) {
476
+ diagnostics.push({
477
+ severity: 'error',
478
+ code: 'open_boundaries',
479
+ message: `${boundaryEdgeCount.toLocaleString()} boundary edge${
480
+ boundaryEdgeCount === 1 ? '' : 's'
481
+ } leave the exported surface open.`,
482
+ })
483
+ }
484
+ if (nonManifoldEdgeCount && nonManifoldEdgeCount > 0) {
485
+ diagnostics.push({
486
+ severity: 'error',
487
+ code: 'non_manifold_edges',
488
+ message: `${nonManifoldEdgeCount.toLocaleString()} edge${
489
+ nonManifoldEdgeCount === 1 ? '' : 's'
490
+ } are shared by more than two triangles and must be repaired.`,
491
+ })
492
+ }
493
+ if (!edgeCheckComplete) {
494
+ diagnostics.push({
495
+ severity: 'warning',
496
+ code: 'edge_check_skipped',
497
+ message: `Edge and connected-component checks were skipped above ${MAX_EDGE_CHECK_TRIANGLES.toLocaleString()} triangles.`,
498
+ })
499
+ }
500
+ if (solidComponentCount !== null && solidComponentCount > 1) {
501
+ diagnostics.push({
502
+ severity: compiled ? 'error' : 'warning',
503
+ code: 'disconnected_solids',
504
+ message: `${solidComponentCount.toLocaleString()} disconnected outward solid components remain in this ${compiled ? 'compiled part' : 'export'}. ${
505
+ compiled
506
+ ? 'Each printable level must be one physically connected solid.'
507
+ : 'Use structure compilation or split them into separate printable parts.'
508
+ }`,
509
+ })
510
+ } else if (
511
+ connectedComponentCount !== null &&
512
+ connectedComponentCount > 1 &&
513
+ inwardComponentCount !== null &&
514
+ inwardComponentCount > 0
515
+ ) {
516
+ diagnostics.push({
517
+ severity: 'warning',
518
+ code: 'inward_surface_components',
519
+ message: `${connectedComponentCount.toLocaleString()} connected surface shells include ${inwardComponentCount.toLocaleString()} inward-oriented shell${
520
+ inwardComponentCount === 1 ? '' : 's'
521
+ }. These may be sealed cavities; inspect the sliced layers before printing.`,
522
+ })
523
+ }
524
+ if (invertedWinding) {
525
+ diagnostics.push({
526
+ severity: 'error',
527
+ code: 'inverted_winding',
528
+ message: 'The closed surface has globally inverted face winding and must be reoriented.',
529
+ })
530
+ }
531
+ if (triangleCount > 0 && Math.abs(signedVolumeMm3) <= 1e-6) {
532
+ diagnostics.push({
533
+ severity: 'error',
534
+ code: 'zero_volume',
535
+ message: 'The exported surfaces enclose no measurable signed volume.',
536
+ })
537
+ }
538
+ diagnostics.push(
539
+ compiled
540
+ ? {
541
+ severity: 'info',
542
+ code: 'compiler_limits',
543
+ message:
544
+ 'The shell was boolean-unioned, but self-intersections and minimum wall thickness are not checked yet.',
545
+ }
546
+ : {
547
+ severity: 'info',
548
+ code: 'compiler_pending',
549
+ message:
550
+ 'Boolean union, shell intersections, and minimum wall thickness are not checked yet.',
551
+ },
552
+ )
553
+
554
+ const status = diagnostics.some((diagnostic) => diagnostic.severity === 'error')
555
+ ? 'blocked'
556
+ : diagnostics.some((diagnostic) => diagnostic.severity === 'warning')
557
+ ? 'warning'
558
+ : 'pass'
559
+
560
+ return {
561
+ kind: 'print-export-report',
562
+ version: 2,
563
+ format,
564
+ scale,
565
+ units: 'millimeter',
566
+ orientation: 'z-up',
567
+ status,
568
+ bounds,
569
+ triangleCount,
570
+ invalidTriangleCount,
571
+ degenerateTriangleCount,
572
+ boundaryEdgeCount,
573
+ nonManifoldEdgeCount,
574
+ connectedComponentCount,
575
+ solidComponentCount,
576
+ invertedWinding,
577
+ volumeMm3: Math.abs(signedVolumeMm3),
578
+ diagnostics,
579
+ }
580
+ }
581
+
582
+ export function prepareSceneForPrint(
583
+ source: THREE.Object3D,
584
+ options: PrintExportOptions,
585
+ ): { scene: THREE.Object3D; report: PrintExportReport } {
586
+ if (!Number.isFinite(options.scale) || options.scale <= 0) {
587
+ throw new RangeError('Print scale must be a positive finite denominator')
588
+ }
589
+ if (
590
+ options.sourceBedElevationMeters !== undefined &&
591
+ !Number.isFinite(options.sourceBedElevationMeters)
592
+ ) {
593
+ throw new RangeError('Print bed elevation must be finite')
594
+ }
595
+
596
+ ensureMeshPositions(source)
597
+
598
+ const physicalScale = MILLIMETERS_PER_METER / options.scale
599
+ // Connectivity is invariant under print scale and orientation. Checking it
600
+ // in model-space meters avoids scale-dependent ray tolerances and π/2 drift.
601
+ const edgeTopology = analyzeEdgeTopology(source, options.indexedTopology ?? false)
602
+
603
+ const scene = new THREE.Group()
604
+ scene.name = 'print-export'
605
+ scene.add(source)
606
+ scene.rotation.x = Math.PI / 2
607
+ scene.scale.setScalar(physicalScale)
608
+ scene.updateMatrixWorld(true)
609
+
610
+ const initialBounds = measureBounds(scene)
611
+ if (initialBounds) {
612
+ const bedElevation =
613
+ options.sourceBedElevationMeters === undefined
614
+ ? initialBounds.min.z
615
+ : options.sourceBedElevationMeters * physicalScale
616
+ scene.position.set(
617
+ -(initialBounds.min.x + initialBounds.max.x) / 2,
618
+ -(initialBounds.min.y + initialBounds.max.y) / 2,
619
+ -bedElevation,
620
+ )
621
+ scene.updateMatrixWorld(true)
622
+ }
623
+
624
+ return {
625
+ scene,
626
+ report: analyzePrintScene(
627
+ scene,
628
+ options.scale,
629
+ edgeTopology,
630
+ options.compiled ?? false,
631
+ options.format ?? 'stl',
632
+ ),
633
+ }
634
+ }
635
+
636
+ export function extractPreparedPrintMesh(root: THREE.Object3D): PrintMeshData {
637
+ root.updateMatrixWorld(true)
638
+
639
+ const positions: number[] = []
640
+ const indices: number[] = []
641
+ const point = new THREE.Vector3()
642
+
643
+ root.traverse((object) => {
644
+ const mesh = object as THREE.Mesh
645
+ if (!mesh.isMesh) return
646
+
647
+ const position = mesh.geometry.getAttribute('position')
648
+ if (!position) return
649
+ const index = mesh.geometry.getIndex()
650
+ const skinnedMesh = mesh as THREE.SkinnedMesh
651
+ const outputIndexByPosition = new Map<string, number>()
652
+
653
+ const outputIndexFor = (vertexIndex: number): number => {
654
+ point.fromBufferAttribute(position, vertexIndex)
655
+ if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, point)
656
+ point.applyMatrix4(mesh.matrixWorld)
657
+ if (!isFiniteVector(point)) {
658
+ throw new RangeError(
659
+ 'Print geometry contains non-finite coordinates and cannot be encoded.',
660
+ )
661
+ }
662
+
663
+ const x = Object.is(point.x, -0) ? 0 : point.x
664
+ const y = Object.is(point.y, -0) ? 0 : point.y
665
+ const z = Object.is(point.z, -0) ? 0 : point.z
666
+ const key = `${x},${y},${z}`
667
+ const existing = outputIndexByPosition.get(key)
668
+ if (existing !== undefined) return existing
669
+
670
+ const next = positions.length / 3
671
+ positions.push(x, y, z)
672
+ outputIndexByPosition.set(key, next)
673
+ return next
674
+ }
675
+
676
+ const appendTriangle = (a: number, b: number, c: number) => {
677
+ indices.push(outputIndexFor(a), outputIndexFor(b), outputIndexFor(c))
678
+ }
679
+
680
+ if (index) {
681
+ for (let offset = 0; offset + 2 < index.count; offset += 3) {
682
+ appendTriangle(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2))
683
+ }
684
+ return
685
+ }
686
+
687
+ for (let offset = 0; offset + 2 < position.count; offset += 3) {
688
+ appendTriangle(offset, offset + 1, offset + 2)
689
+ }
690
+ })
691
+
692
+ return {
693
+ positions: new Float64Array(positions),
694
+ indices: new Uint32Array(indices),
695
+ }
696
+ }
697
+
698
+ export function encodePreparedPrintSceneToStl(scene: THREE.Object3D): ArrayBuffer {
699
+ const exporter = new STLExporter()
700
+ const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView
701
+ return output instanceof DataView
702
+ ? (output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer)
703
+ : output
704
+ }
705
+
706
+ export function exportSceneToPrintStl(
707
+ source: THREE.Object3D,
708
+ options: PrintExportOptions,
709
+ ): PrintStlExport {
710
+ const { scene, report } = prepareSceneForPrint(source, options)
711
+ return { buffer: encodePreparedPrintSceneToStl(scene), report }
712
+ }
713
+
714
+ export function mergePrintExportDiagnostics(
715
+ report: PrintExportReport,
716
+ diagnostics: PrintExportDiagnostic[],
717
+ omitCodes: ReadonlySet<string> = new Set(),
718
+ ): PrintExportReport {
719
+ const merged = [
720
+ ...report.diagnostics.filter((diagnostic) => !omitCodes.has(diagnostic.code)),
721
+ ...diagnostics,
722
+ ]
723
+ const status = merged.some((diagnostic) => diagnostic.severity === 'error')
724
+ ? 'blocked'
725
+ : merged.some((diagnostic) => diagnostic.severity === 'warning')
726
+ ? 'warning'
727
+ : 'pass'
728
+ return { ...report, status, diagnostics: merged }
729
+ }
730
+
731
+ export function isPrintExportReport(value: unknown): value is PrintExportReport {
732
+ if (!value || typeof value !== 'object') return false
733
+ const report = value as Partial<PrintExportReport>
734
+ return report.kind === 'print-export-report' && report.version === 2
735
+ }