@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,86 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { XMLParser } from 'fast-xml-parser'
3
+ import { strFromU8, unzipSync } from 'fflate'
4
+ import * as THREE from 'three'
5
+ import { exportSceneToPrint3mf } from './print-3mf'
6
+
7
+ function asArray<T>(value: T | T[]): T[] {
8
+ return Array.isArray(value) ? value : [value]
9
+ }
10
+
11
+ describe('print 3MF export', () => {
12
+ test('writes a deterministic standards package with explicit millimeter units', () => {
13
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 4, 6))
14
+ mesh.position.set(5, 2, -7)
15
+
16
+ const first = exportSceneToPrint3mf(mesh, { scale: 100 })
17
+ const second = exportSceneToPrint3mf(mesh, { scale: 100 })
18
+ const files = unzipSync(first.buffer)
19
+ const xml = strFromU8(files['3D/3dmodel.model']!)
20
+ const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse(
21
+ xml,
22
+ ).model
23
+ const object = asArray<Record<string, unknown>>(model.resources.object)[0]!
24
+ const item = asArray<Record<string, string>>(model.build.item)[0]!
25
+ const metadata = asArray<Record<string, string>>(model.metadata)
26
+ const partManifest = JSON.parse(
27
+ metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!,
28
+ )
29
+ const objectMesh = object.mesh as {
30
+ vertices: { vertex: Record<string, string> | Record<string, string>[] }
31
+ triangles: { triangle: Record<string, string> | Record<string, string>[] }
32
+ }
33
+ const vertices = asArray(objectMesh.vertices.vertex)
34
+ const triangles = asArray(objectMesh.triangles.triangle)
35
+ const bounds = new THREE.Box3()
36
+ for (const vertex of vertices) {
37
+ bounds.expandByPoint(new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)))
38
+ }
39
+
40
+ expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model'])
41
+ expect(strFromU8(files['_rels/.rels']!)).toContain('Target="/3D/3dmodel.model"')
42
+ expect(model.unit).toBe('millimeter')
43
+ expect(object.name).toBe('Pascal print model')
44
+ expect(vertices).toHaveLength(8)
45
+ expect(triangles).toHaveLength(12)
46
+ expect(item.objectid).toBe('1')
47
+ expect(item.transform).toBeUndefined()
48
+ expect(partManifest).toEqual([
49
+ {
50
+ name: 'Pascal print model',
51
+ vertexStart: 0,
52
+ vertexCount: 8,
53
+ triangleStart: 0,
54
+ triangleCount: 12,
55
+ },
56
+ ])
57
+ expect(bounds.min.toArray()).toEqual([0, 0, 0])
58
+ expect(bounds.max.toArray()).toEqual([100, 60, 40])
59
+ expect(first.report.format).toBe('3mf')
60
+ expect(first.report.bounds?.width).toBeCloseTo(100, 6)
61
+ expect(first.report.bounds?.depth).toBeCloseTo(60, 6)
62
+ expect(first.report.bounds?.height).toBeCloseTo(40, 6)
63
+ expect(first.buffer).toEqual(second.buffer)
64
+
65
+ for (const triangle of triangles) {
66
+ expect(Number(triangle.v1)).toBeLessThan(vertices.length)
67
+ expect(Number(triangle.v2)).toBeLessThan(vertices.length)
68
+ expect(Number(triangle.v3)).toBeLessThan(vertices.length)
69
+ }
70
+ })
71
+
72
+ test('returns a blocking report instead of serializing non-finite coordinates', () => {
73
+ const geometry = new THREE.BoxGeometry(1, 1, 1)
74
+ geometry.getAttribute('position').setX(0, Number.NaN)
75
+
76
+ const output = exportSceneToPrint3mf(new THREE.Mesh(geometry), { scale: 100 })
77
+ const model = strFromU8(unzipSync(output.buffer)['3D/3dmodel.model']!)
78
+
79
+ expect(output.report.status).toBe('blocked')
80
+ expect(output.report.invalidTriangleCount).toBeGreaterThan(0)
81
+ expect(output.report.diagnostics).toContainEqual(
82
+ expect.objectContaining({ code: 'non_finite_geometry', severity: 'error' }),
83
+ )
84
+ expect(model).not.toContain('<object ')
85
+ })
86
+ })
@@ -0,0 +1,161 @@
1
+ import { strToU8, type Zippable, zipSync } from 'fflate'
2
+ import type * as THREE from 'three'
3
+ import type {
4
+ PrintExportBounds,
5
+ PrintExportOptions,
6
+ PrintExportReport,
7
+ PrintMeshData,
8
+ } from './print-export'
9
+ import { extractPreparedPrintMesh, prepareSceneForPrint } from './print-export'
10
+
11
+ const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0)
12
+ const PART_GAP_MM = 5
13
+
14
+ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8"?>
15
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
16
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
17
+ <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
18
+ </Types>
19
+ `
20
+
21
+ const ROOT_RELATIONSHIPS = `<?xml version="1.0" encoding="UTF-8"?>
22
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
23
+ <Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
24
+ </Relationships>
25
+ `
26
+
27
+ export type Print3mfPart = {
28
+ name: string
29
+ mesh: PrintMeshData
30
+ bounds: PrintExportBounds
31
+ }
32
+
33
+ export type Print3mfExport = {
34
+ buffer: Uint8Array<ArrayBuffer>
35
+ report: PrintExportReport
36
+ }
37
+
38
+ function escapeXml(value: string): string {
39
+ return value
40
+ .replaceAll('&', '&amp;')
41
+ .replaceAll('<', '&lt;')
42
+ .replaceAll('>', '&gt;')
43
+ .replaceAll('"', '&quot;')
44
+ .replaceAll("'", '&apos;')
45
+ }
46
+
47
+ function decimal(value: number): string {
48
+ if (!Number.isFinite(value)) throw new RangeError('3MF coordinates must be finite.')
49
+ const rounded = Math.abs(value) < 5e-10 ? 0 : value
50
+ return rounded.toFixed(9).replace(/\.?0+$/, '')
51
+ }
52
+
53
+ type PlacedPrint3mfPart = {
54
+ part: Print3mfPart
55
+ translateX: number
56
+ translateY: number
57
+ vertexStart: number
58
+ triangleStart: number
59
+ }
60
+
61
+ function appendMeshObject(lines: string[], name: string, parts: PlacedPrint3mfPart[]) {
62
+ lines.push(` <object id="1" type="model" name="${escapeXml(name)}">`)
63
+ lines.push(' <mesh>')
64
+ lines.push(' <vertices>')
65
+ for (const { part, translateX, translateY } of parts) {
66
+ for (let offset = 0; offset < part.mesh.positions.length; offset += 3) {
67
+ lines.push(
68
+ ` <vertex x="${decimal(part.mesh.positions[offset]! + translateX)}" y="${decimal(part.mesh.positions[offset + 1]! + translateY)}" z="${decimal(part.mesh.positions[offset + 2]!)}"/>`,
69
+ )
70
+ }
71
+ }
72
+ lines.push(' </vertices>')
73
+ lines.push(' <triangles>')
74
+ for (const { part, vertexStart } of parts) {
75
+ for (let offset = 0; offset < part.mesh.indices.length; offset += 3) {
76
+ lines.push(
77
+ ` <triangle v1="${part.mesh.indices[offset]! + vertexStart}" v2="${part.mesh.indices[offset + 1]! + vertexStart}" v3="${part.mesh.indices[offset + 2]! + vertexStart}"/>`,
78
+ )
79
+ }
80
+ }
81
+ lines.push(' </triangles>')
82
+ lines.push(' </mesh>')
83
+ lines.push(' </object>')
84
+ }
85
+
86
+ export function createPrint3mf(
87
+ parts: Print3mfPart[],
88
+ title = 'Pascal print export',
89
+ ): Uint8Array<ArrayBuffer> {
90
+ const placements: PlacedPrint3mfPart[] = []
91
+ let cursorX = 0
92
+ let vertexStart = 0
93
+ let triangleStart = 0
94
+ for (const part of parts) {
95
+ placements.push({
96
+ part,
97
+ translateX: cursorX - part.bounds.min.x,
98
+ translateY: -part.bounds.min.y,
99
+ vertexStart,
100
+ triangleStart,
101
+ })
102
+ cursorX += part.bounds.width + PART_GAP_MM
103
+ vertexStart += part.mesh.positions.length / 3
104
+ triangleStart += part.mesh.indices.length / 3
105
+ }
106
+ const partManifest = placements.map(({ part, vertexStart, triangleStart }) => ({
107
+ name: part.name,
108
+ vertexStart,
109
+ vertexCount: part.mesh.positions.length / 3,
110
+ triangleStart,
111
+ triangleCount: part.mesh.indices.length / 3,
112
+ }))
113
+ const lines = [
114
+ '<?xml version="1.0" encoding="UTF-8"?>',
115
+ '<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
116
+ ` <metadata name="Title">${escapeXml(title)}</metadata>`,
117
+ ' <metadata name="Application">Pascal</metadata>',
118
+ ` <metadata name="Pascal.PartManifest">${escapeXml(JSON.stringify(partManifest))}</metadata>`,
119
+ ' <resources>',
120
+ ]
121
+
122
+ // CHITUBOX 1.3.0 recenters independent 3MF objects, so package the plate as one mesh.
123
+ if (placements.length > 0) {
124
+ appendMeshObject(lines, parts.length === 1 ? parts[0]!.name : title, placements)
125
+ }
126
+ lines.push(' </resources>')
127
+ lines.push(' <build>')
128
+
129
+ if (parts.length > 0) lines.push(' <item objectid="1"/>')
130
+ lines.push(' </build>')
131
+ lines.push('</model>')
132
+ lines.push('')
133
+
134
+ const files: Zippable = {
135
+ '[Content_Types].xml': [strToU8(CONTENT_TYPES), { level: 0, mtime: ZIP_MTIME }],
136
+ '_rels/.rels': [strToU8(ROOT_RELATIONSHIPS), { level: 0, mtime: ZIP_MTIME }],
137
+ '3D/3dmodel.model': [strToU8(lines.join('\n')), { level: 0, mtime: ZIP_MTIME }],
138
+ }
139
+ return zipSync(files, { level: 0 })
140
+ }
141
+
142
+ export function exportSceneToPrint3mf(
143
+ source: THREE.Object3D,
144
+ options: PrintExportOptions,
145
+ ): Print3mfExport {
146
+ const prepared = prepareSceneForPrint(source, { ...options, format: '3mf' })
147
+ const parts =
148
+ prepared.report.bounds && prepared.report.invalidTriangleCount === 0
149
+ ? [
150
+ {
151
+ name: 'Pascal print model',
152
+ mesh: extractPreparedPrintMesh(prepared.scene),
153
+ bounds: prepared.report.bounds,
154
+ },
155
+ ]
156
+ : []
157
+ return {
158
+ buffer: createPrint3mf(parts),
159
+ report: prepared.report,
160
+ }
161
+ }
@@ -0,0 +1,107 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
+ import * as THREE from 'three'
4
+ import { prepareSceneForExport } from './glb-export'
5
+ import { filterPreparedSceneForPrintContent } from './print-content-scope'
6
+ import { exportSceneToPrintStl } from './print-export'
7
+
8
+ function registerFixtureKind(category: 'site' | 'structure' | 'furnish'): string {
9
+ const kind = `print-${category}-${crypto.randomUUID()}`
10
+ registerNode({
11
+ kind,
12
+ schemaVersion: 1,
13
+ category,
14
+ defaults: () => ({}),
15
+ capabilities: {},
16
+ } as never)
17
+ return kind
18
+ }
19
+
20
+ function printContentFixture() {
21
+ const root = new THREE.Group()
22
+ const building = new THREE.Group()
23
+ const level = new THREE.Group()
24
+ const wall = new THREE.Group()
25
+ const furniture = new THREE.Group()
26
+ wall.add(new THREE.Mesh(new THREE.BoxGeometry(4, 3, 2)))
27
+ furniture.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)))
28
+ root.add(building)
29
+ building.add(level)
30
+ level.add(wall, furniture)
31
+
32
+ const buildingKind = registerFixtureKind('site')
33
+ const levelKind = registerFixtureKind('site')
34
+ const wallKind = registerFixtureKind('structure')
35
+ const furnitureKind = registerFixtureKind('furnish')
36
+
37
+ sceneRegistry.nodes.set('building_main', building)
38
+ sceneRegistry.nodes.set('level_ground', level)
39
+ sceneRegistry.nodes.set('wall_main', wall)
40
+ sceneRegistry.nodes.set('chair_main', furniture)
41
+
42
+ const nodes: Record<string, AnyNode> = {
43
+ building_main: {
44
+ object: 'node',
45
+ id: 'building_main',
46
+ type: buildingKind,
47
+ parentId: null,
48
+ children: ['level_ground'],
49
+ } as unknown as AnyNode,
50
+ level_ground: {
51
+ object: 'node',
52
+ id: 'level_ground',
53
+ type: levelKind,
54
+ parentId: 'building_main',
55
+ children: ['wall_main', 'chair_main'],
56
+ } as unknown as AnyNode,
57
+ wall_main: {
58
+ object: 'node',
59
+ id: 'wall_main',
60
+ type: wallKind,
61
+ parentId: 'level_ground',
62
+ } as unknown as AnyNode,
63
+ chair_main: {
64
+ object: 'node',
65
+ id: 'chair_main',
66
+ type: furnitureKind,
67
+ parentId: 'level_ground',
68
+ } as unknown as AnyNode,
69
+ }
70
+
71
+ return { root, nodes }
72
+ }
73
+
74
+ describe('print content scope', () => {
75
+ afterEach(() => {
76
+ sceneRegistry.nodes.clear()
77
+ })
78
+
79
+ test('keeps registered structure and its transform ancestors while removing visible furniture', () => {
80
+ const fixture = printContentFixture()
81
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true })
82
+
83
+ const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure')
84
+ const print = exportSceneToPrintStl(structure, { scale: 100 })
85
+
86
+ expect(structure.getObjectByName('building_main')).toBeDefined()
87
+ expect(structure.getObjectByName('level_ground')).toBeDefined()
88
+ expect(structure.getObjectByName('wall_main')).toBeDefined()
89
+ expect(structure.getObjectByName('chair_main')).toBeUndefined()
90
+ expect(print.report.triangleCount).toBe(12)
91
+ })
92
+
93
+ test('preserves all prepared semantic geometry in everything scope', () => {
94
+ const fixture = printContentFixture()
95
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true })
96
+
97
+ const everything = filterPreparedSceneForPrintContent(
98
+ prepared.scene,
99
+ fixture.nodes,
100
+ 'everything',
101
+ )
102
+ const print = exportSceneToPrintStl(everything, { scale: 100 })
103
+
104
+ expect(everything.getObjectByName('chair_main')).toBeDefined()
105
+ expect(print.report.triangleCount).toBe(24)
106
+ })
107
+ })
@@ -0,0 +1,90 @@
1
+ import { type AnyNode, nodeRegistry } from '@pascal-app/core'
2
+ import * as THREE from 'three'
3
+
4
+ export type PrintContentScope = 'structure' | 'everything'
5
+
6
+ const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
7
+ EMPTY_POSITION_GEOMETRY.setAttribute(
8
+ 'position',
9
+ new THREE.Float32BufferAttribute(new Float32Array(0), 3),
10
+ )
11
+
12
+ function identityId(object: THREE.Object3D): string | null {
13
+ const id = object.userData.pascalId
14
+ return typeof id === 'string' ? id : null
15
+ }
16
+
17
+ function structureIds(nodes: Record<string, AnyNode>): Set<string> {
18
+ return new Set(
19
+ Object.values(nodes)
20
+ .filter((node) => nodeRegistry.get(node.type)?.category === 'structure')
21
+ .map((node) => node.id),
22
+ )
23
+ }
24
+
25
+ function retainedIds(
26
+ includedIds: ReadonlySet<string>,
27
+ nodes: Record<string, AnyNode>,
28
+ ): Set<string> {
29
+ const retained = new Set(includedIds)
30
+ for (const id of includedIds) {
31
+ const visited = new Set<string>([id])
32
+ let parentId = nodes[id]?.parentId ?? null
33
+ while (parentId && !visited.has(parentId)) {
34
+ retained.add(parentId)
35
+ visited.add(parentId)
36
+ parentId = nodes[parentId]?.parentId ?? null
37
+ }
38
+ }
39
+ return retained
40
+ }
41
+
42
+ function neutralizeRenderable(object: THREE.Object3D) {
43
+ const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
44
+ if (renderable.isMesh || renderable.isLine || renderable.isPoints) {
45
+ renderable.geometry = EMPTY_POSITION_GEOMETRY
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Keep registered structural nodes plus the minimum semantic/Three ancestry
51
+ * needed to preserve their world transforms. Unknown, furnishing, analysis,
52
+ * utility, and site-owned geometry is removed unless it is only a transform
53
+ * container leading to retained structure.
54
+ */
55
+ export function filterPreparedSceneForPrintContent(
56
+ source: THREE.Object3D,
57
+ nodes: Record<string, AnyNode>,
58
+ scope: PrintContentScope,
59
+ ): THREE.Object3D {
60
+ const scene = source.clone(true)
61
+ if (scope === 'everything') return scene
62
+
63
+ const includedIds = structureIds(nodes)
64
+ const retained = retainedIds(includedIds, nodes)
65
+
66
+ const prune = (object: THREE.Object3D, inheritedStructure: boolean, isRoot = false): boolean => {
67
+ const id = identityId(object)
68
+ let carriesStructure = inheritedStructure
69
+
70
+ if (id) {
71
+ if (includedIds.has(id)) carriesStructure = true
72
+ else if (retained.has(id)) carriesStructure = false
73
+ else return false
74
+ }
75
+
76
+ for (const child of [...object.children]) {
77
+ if (!prune(child, carriesStructure)) child.removeFromParent()
78
+ }
79
+
80
+ if (carriesStructure || isRoot) return true
81
+ if (object.children.length === 0) return false
82
+
83
+ neutralizeRenderable(object)
84
+ return true
85
+ }
86
+
87
+ prune(scene, false, true)
88
+ scene.name = 'print-content-structure'
89
+ return scene
90
+ }