@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,526 @@
1
+ import {
2
+ type AnyNode,
3
+ getLevelDisplayName,
4
+ getLevelElevations,
5
+ type LevelNode,
6
+ } from '@pascal-app/core'
7
+ import { disposeObject3DResources } from '@pascal-app/viewer'
8
+ import { type Zippable, zipSync } from 'fflate'
9
+ import * as THREE from 'three'
10
+ import { createPrint3mf, type Print3mfPart } from './print-3mf'
11
+ import {
12
+ encodePreparedPrintSceneToStl,
13
+ extractPreparedPrintMesh,
14
+ mergePrintExportDiagnostics,
15
+ type PrintArtifactFormat,
16
+ type PrintExportBounds,
17
+ type PrintExportDiagnostic,
18
+ type PrintExportReport,
19
+ type PrintMeshData,
20
+ prepareSceneForPrint,
21
+ } from './print-export'
22
+ import {
23
+ applyPrintFeatureThickness,
24
+ applySemanticPrintFeatureThickness,
25
+ isPrintFeatureThicknessDiagnostic,
26
+ } from './print-feature-thickness'
27
+ import { compileSemanticPrintShell } from './print-shell-compiler'
28
+ import type { PrintShellCompileResult } from './print-shell-compiler-baseline'
29
+
30
+ const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0)
31
+ const MILLIMETERS_PER_METER = 1000
32
+ const LEVEL_BASE_TOLERANCE_MM = 0.01
33
+
34
+ export type PrintBaseMode = 'none' | 'plinth'
35
+
36
+ export type PrintPlinthOptions = {
37
+ marginMm: number
38
+ thicknessMm: number
39
+ }
40
+
41
+ export type PrintLevelPartReport = {
42
+ kind: 'level' | 'plinth'
43
+ levelId: string
44
+ label: string
45
+ objectName: string
46
+ filename: string | null
47
+ sourceBaseMeters: number | null
48
+ report: PrintExportReport
49
+ }
50
+
51
+ export type PrintLevelBundleReport = {
52
+ kind: 'print-level-export-report'
53
+ version: 2
54
+ format: PrintArtifactFormat
55
+ scale: number
56
+ units: 'millimeter'
57
+ orientation: 'z-up'
58
+ status: 'pass' | 'warning' | 'blocked'
59
+ partCount: number
60
+ parts: PrintLevelPartReport[]
61
+ excludedNodeIds: string[]
62
+ diagnostics: PrintExportDiagnostic[]
63
+ }
64
+
65
+ export type PrintLevelPackage = {
66
+ data: Uint8Array<ArrayBuffer>
67
+ report: PrintLevelBundleReport
68
+ }
69
+
70
+ export type PrintLevelExportOptions = {
71
+ scale: number
72
+ format?: PrintArtifactFormat
73
+ plinth?: PrintPlinthOptions
74
+ minimumFeatureMm?: number
75
+ compileShells?: boolean
76
+ compileShell?: (
77
+ source: THREE.Object3D,
78
+ nodes: Record<string, AnyNode>,
79
+ ) => Promise<PrintShellCompileResult>
80
+ }
81
+
82
+ function exportedIdentityIds(root: THREE.Object3D): Set<string> {
83
+ const ids = new Set<string>()
84
+ root.traverse((object) => {
85
+ const id = object.userData.pascalId
86
+ if (typeof id === 'string') ids.add(id)
87
+ })
88
+ return ids
89
+ }
90
+
91
+ function owningLevelId(
92
+ id: string,
93
+ nodes: Record<string, AnyNode>,
94
+ memo: Map<string, string | null>,
95
+ path = new Set<string>(),
96
+ ): string | null {
97
+ if (memo.has(id)) return memo.get(id) ?? null
98
+ const node = nodes[id]
99
+ if (!node || path.has(id)) return null
100
+ if (node.type === 'level') {
101
+ memo.set(id, id)
102
+ return id
103
+ }
104
+ if (!node.parentId) {
105
+ memo.set(id, null)
106
+ return null
107
+ }
108
+
109
+ path.add(id)
110
+ const levelId = owningLevelId(node.parentId, nodes, memo, path)
111
+ path.delete(id)
112
+ memo.set(id, levelId)
113
+ return levelId
114
+ }
115
+
116
+ function levelAncestors(levelId: string, nodes: Record<string, AnyNode>): Set<string> {
117
+ const ancestors = new Set<string>()
118
+ const visited = new Set<string>()
119
+ let parentId = nodes[levelId]?.parentId ?? null
120
+ while (parentId && !visited.has(parentId)) {
121
+ visited.add(parentId)
122
+ ancestors.add(parentId)
123
+ parentId = nodes[parentId]?.parentId ?? null
124
+ }
125
+ return ancestors
126
+ }
127
+
128
+ function isSpanningNode(node: AnyNode, ownerLevelId: string | null): boolean {
129
+ if (node.type === 'elevator') return true
130
+ if (node.type !== 'stair') return false
131
+
132
+ const fromLevelId = node.fromLevelId ?? ownerLevelId
133
+ const toLevelId = node.toLevelId
134
+ return Boolean(fromLevelId && toLevelId && fromLevelId !== toLevelId)
135
+ }
136
+
137
+ function hasExcludedAncestor(
138
+ id: string,
139
+ excludedIds: ReadonlySet<string>,
140
+ nodes: Record<string, AnyNode>,
141
+ ): boolean {
142
+ const visited = new Set<string>()
143
+ let parentId = nodes[id]?.parentId ?? null
144
+ while (parentId && !visited.has(parentId)) {
145
+ if (excludedIds.has(parentId)) return true
146
+ visited.add(parentId)
147
+ parentId = nodes[parentId]?.parentId ?? null
148
+ }
149
+ return false
150
+ }
151
+
152
+ function pruneSceneToLevel(
153
+ source: THREE.Object3D,
154
+ levelId: string,
155
+ nodes: Record<string, AnyNode>,
156
+ excludedIds: ReadonlySet<string>,
157
+ ownerByNodeId: Map<string, string | null>,
158
+ ): THREE.Object3D {
159
+ const scene = source.clone(true)
160
+ const ancestors = levelAncestors(levelId, nodes)
161
+ const removals: THREE.Object3D[] = []
162
+
163
+ scene.traverse((object) => {
164
+ const id = object.userData.pascalId
165
+ if (typeof id !== 'string') return
166
+ const belongsToLevel =
167
+ ownerByNodeId.get(id) === levelId &&
168
+ !excludedIds.has(id) &&
169
+ !hasExcludedAncestor(id, excludedIds, nodes)
170
+ if (!belongsToLevel && !ancestors.has(id)) removals.push(object)
171
+ })
172
+
173
+ for (const object of removals) object.removeFromParent()
174
+ scene.name = `print-level-${levelId}`
175
+ return scene
176
+ }
177
+
178
+ function safeFilenamePart(value: string): string {
179
+ return (
180
+ value
181
+ .normalize('NFKD')
182
+ .replace(/[\u0300-\u036f]/g, '')
183
+ .toLowerCase()
184
+ .replace(/[^a-z0-9]+/g, '-')
185
+ .replace(/^-+|-+$/g, '') || 'level'
186
+ )
187
+ }
188
+
189
+ function bundleStatus(
190
+ diagnostics: PrintExportDiagnostic[],
191
+ parts: PrintLevelPartReport[],
192
+ ): PrintLevelBundleReport['status'] {
193
+ if (
194
+ diagnostics.some((diagnostic) => diagnostic.severity === 'error') ||
195
+ parts.some((part) => part.report.status === 'blocked')
196
+ ) {
197
+ return 'blocked'
198
+ }
199
+ if (
200
+ diagnostics.some((diagnostic) => diagnostic.severity === 'warning') ||
201
+ parts.some((part) => part.report.status === 'warning')
202
+ ) {
203
+ return 'warning'
204
+ }
205
+ return 'pass'
206
+ }
207
+
208
+ type PreparedLevelArtifact = {
209
+ filename: string | null
210
+ objectName: string
211
+ bytes: Uint8Array<ArrayBuffer> | null
212
+ mesh: PrintMeshData | null
213
+ bounds: PrintExportBounds | null
214
+ }
215
+
216
+ function levelBaseDiagnostics(
217
+ level: LevelNode,
218
+ label: string,
219
+ sourceBaseMeters: number | null,
220
+ report: PrintExportReport,
221
+ ): PrintExportDiagnostic[] {
222
+ if (sourceBaseMeters === null) {
223
+ return [
224
+ {
225
+ severity: 'error',
226
+ code: 'missing_level_base',
227
+ message: `${label} has no finite stored level base and cannot be normalized reliably.`,
228
+ nodeIds: [level.id],
229
+ },
230
+ ]
231
+ }
232
+
233
+ const minZ = report.bounds?.min.z
234
+ if (minZ === undefined || Math.abs(minZ) <= LEVEL_BASE_TOLERANCE_MM) return []
235
+ if (minZ < 0) {
236
+ return [
237
+ {
238
+ severity: 'error',
239
+ code: 'level_geometry_below_base',
240
+ message: `${label} extends ${Math.abs(minZ).toFixed(3)} mm below its stored level base. Correct the level ownership or supporting slab before printing.`,
241
+ nodeIds: [level.id],
242
+ },
243
+ ]
244
+ }
245
+
246
+ return [
247
+ {
248
+ severity: 'error',
249
+ code: 'level_geometry_detached_from_base',
250
+ message: `${label} begins ${minZ.toFixed(3)} mm above its stored level base, leaving the printable part detached from the bed. Add or assign a floor solid before printing.`,
251
+ nodeIds: [level.id],
252
+ },
253
+ ]
254
+ }
255
+
256
+ export async function exportSceneLevelsForPrint(
257
+ source: THREE.Object3D,
258
+ nodes: Record<string, AnyNode>,
259
+ options: PrintLevelExportOptions,
260
+ ): Promise<PrintLevelPackage> {
261
+ const format = options.format ?? 'stl'
262
+ const exportedIds = exportedIdentityIds(source)
263
+ const levelElevations = getLevelElevations(nodes)
264
+ const ownerByNodeId = new Map<string, string | null>()
265
+ for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId)
266
+
267
+ const levels = Object.values(nodes)
268
+ .filter((node): node is LevelNode => node.type === 'level' && exportedIds.has(node.id))
269
+ .sort(
270
+ (a, b) =>
271
+ (a.parentId ?? '').localeCompare(b.parentId ?? '') ||
272
+ a.level - b.level ||
273
+ a.id.localeCompare(b.id),
274
+ )
275
+
276
+ const excludedIds = new Set<string>()
277
+ const diagnostics: PrintExportDiagnostic[] = []
278
+ for (const id of exportedIds) {
279
+ const node = nodes[id]
280
+ if (!node || !isSpanningNode(node, ownerByNodeId.get(id) ?? null)) continue
281
+ excludedIds.add(id)
282
+ diagnostics.push({
283
+ severity: 'error',
284
+ code: 'unsplit_spanning_node',
285
+ message: `${node.type} ${id} spans levels and was omitted. Hide it or define a deterministic split before downloading level parts.`,
286
+ })
287
+ }
288
+
289
+ if (levels.length === 0) {
290
+ diagnostics.push({
291
+ severity: 'error',
292
+ code: 'no_visible_levels',
293
+ message: 'No visible level nodes remain in the print scope.',
294
+ })
295
+ }
296
+
297
+ const levelArtifacts: PreparedLevelArtifact[] = []
298
+ const levelParts: PrintLevelPartReport[] = []
299
+ for (const [index, level] of levels.entries()) {
300
+ const label = getLevelDisplayName(level)
301
+ const prefix = String(index + 1).padStart(2, '0')
302
+ const objectName = `${prefix} ${label}`
303
+ const filename = format === 'stl' ? `${prefix}_${safeFilenamePart(label)}.stl` : null
304
+ const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId)
305
+ const sourceBase = levelElevations.get(level.id)?.baseY
306
+ const sourceBaseMeters =
307
+ typeof sourceBase === 'number' && Number.isFinite(sourceBase) ? sourceBase : null
308
+ const compiled = options.compileShells
309
+ ? options.compileShell
310
+ ? await options.compileShell(levelScene, nodes)
311
+ : compileSemanticPrintShell(levelScene, nodes)
312
+ : null
313
+ try {
314
+ const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene
315
+ const prepared = prepareSceneForPrint(printSource, {
316
+ scale: options.scale,
317
+ compiled: compiled?.status === 'compiled',
318
+ indexedTopology: compiled?.backend === 'manifold-3d',
319
+ format,
320
+ ...(sourceBaseMeters === null ? {} : { sourceBedElevationMeters: sourceBaseMeters }),
321
+ })
322
+ let report = compiled
323
+ ? mergePrintExportDiagnostics(
324
+ prepared.report,
325
+ compiled.diagnostics,
326
+ new Set(['compiler_pending']),
327
+ )
328
+ : prepared.report
329
+ if (compiled) {
330
+ report = applySemanticPrintFeatureThickness(
331
+ report,
332
+ nodes,
333
+ compiled.sourceNodeIds,
334
+ options.minimumFeatureMm,
335
+ )
336
+ }
337
+ const baseDiagnostics = levelBaseDiagnostics(level, label, sourceBaseMeters, report)
338
+ report = mergePrintExportDiagnostics(report, baseDiagnostics)
339
+ if (compiled) {
340
+ diagnostics.push(
341
+ ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'),
342
+ )
343
+ }
344
+ diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic))
345
+ diagnostics.push(...baseDiagnostics)
346
+ levelArtifacts.push({
347
+ filename,
348
+ objectName,
349
+ bytes:
350
+ format === 'stl' ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene)) : null,
351
+ mesh:
352
+ format === '3mf' && report.bounds && report.invalidTriangleCount === 0
353
+ ? extractPreparedPrintMesh(prepared.scene)
354
+ : null,
355
+ bounds: report.bounds,
356
+ })
357
+ levelParts.push({
358
+ kind: 'level',
359
+ levelId: level.id,
360
+ label,
361
+ objectName,
362
+ filename,
363
+ sourceBaseMeters,
364
+ report,
365
+ })
366
+ } finally {
367
+ if (compiled?.scene) disposeObject3DResources(compiled.scene)
368
+ }
369
+ }
370
+
371
+ let plinthArtifact: PreparedLevelArtifact | null = null
372
+ let plinthPart: PrintLevelPartReport | null = null
373
+ if (options.plinth) {
374
+ const { marginMm, thicknessMm } = options.plinth
375
+ if (
376
+ !Number.isFinite(marginMm) ||
377
+ marginMm < 0 ||
378
+ !Number.isFinite(thicknessMm) ||
379
+ thicknessMm <= 0
380
+ ) {
381
+ diagnostics.push({
382
+ severity: 'error',
383
+ code: 'invalid_plinth_dimensions',
384
+ message: 'Plinth margin must be non-negative and thickness must be positive.',
385
+ })
386
+ } else {
387
+ const buildingIds = new Set(levels.map((level) => level.parentId ?? 'unparented-building'))
388
+ const lowestLevel = levels[0]
389
+ const lowestPart = levelParts[0]
390
+ const bounds = lowestPart?.report.bounds
391
+ if (buildingIds.size > 1) {
392
+ diagnostics.push({
393
+ severity: 'error',
394
+ code: 'multiple_building_plinth',
395
+ message: 'A plinth currently requires the print scope to contain exactly one building.',
396
+ })
397
+ } else if (!lowestLevel || !lowestPart || !bounds) {
398
+ diagnostics.push({
399
+ severity: 'error',
400
+ code: 'plinth_missing_footprint',
401
+ message: 'The lowest visible level has no structural bounds for plinth generation.',
402
+ })
403
+ } else {
404
+ const widthMeters = ((bounds.width + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER
405
+ const depthMeters = ((bounds.depth + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER
406
+ const thicknessMeters = (thicknessMm * options.scale) / MILLIMETERS_PER_METER
407
+ const mesh = new THREE.Mesh(
408
+ new THREE.BoxGeometry(widthMeters, thicknessMeters, depthMeters),
409
+ )
410
+ try {
411
+ const prepared = prepareSceneForPrint(mesh, { ...options, format })
412
+ const report = applyPrintFeatureThickness(
413
+ prepared.report,
414
+ {
415
+ features: [{ nodeId: lowestLevel.id, thicknessMm }],
416
+ unmeasuredNodeIds: [],
417
+ },
418
+ options.minimumFeatureMm,
419
+ )
420
+ const filename = format === 'stl' ? '00_plinth.stl' : null
421
+ const objectName = '00 Plinth'
422
+ plinthArtifact = {
423
+ filename,
424
+ objectName,
425
+ bytes:
426
+ format === 'stl'
427
+ ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene))
428
+ : null,
429
+ mesh:
430
+ format === '3mf' &&
431
+ prepared.report.bounds &&
432
+ prepared.report.invalidTriangleCount === 0
433
+ ? extractPreparedPrintMesh(prepared.scene)
434
+ : null,
435
+ bounds: report.bounds,
436
+ }
437
+ plinthPart = {
438
+ kind: 'plinth',
439
+ levelId: lowestLevel.id,
440
+ label: 'Plinth',
441
+ objectName,
442
+ filename,
443
+ sourceBaseMeters: null,
444
+ report,
445
+ }
446
+ diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic))
447
+ diagnostics.push({
448
+ severity: 'info',
449
+ code: 'rectangular_plinth_experimental',
450
+ message:
451
+ 'The plinth is a separate rectangular part derived from the lowest level bounds; footprint shaping and connectors are not implemented yet.',
452
+ })
453
+ } finally {
454
+ disposeObject3DResources(mesh)
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ diagnostics.push({
461
+ severity: 'info',
462
+ code: 'level_parts_experimental',
463
+ message: options.compileShells
464
+ ? options.compileShell
465
+ ? 'Level parts use stored level bases and worker-backed Manifold semantic shell compilation; known wall, slab, roof, and plinth dimensions are measured, while mesh-observed thin features and self-intersections remain pending.'
466
+ : 'Level parts use stored level bases and the experimental synchronous semantic shell compiler; known wall, slab, roof, and plinth dimensions are measured, while worker execution, mesh-observed thin features, and self-intersections remain pending.'
467
+ : 'Level parts use stored level bases and semantic separation but are not boolean-unioned printable shells yet.',
468
+ })
469
+
470
+ const files: Zippable = {}
471
+ const parts: PrintLevelPartReport[] = []
472
+ const packageParts: Print3mfPart[] = []
473
+ if (plinthArtifact && plinthPart) {
474
+ if (plinthArtifact.filename && plinthArtifact.bytes) {
475
+ files[plinthArtifact.filename] = [plinthArtifact.bytes, { level: 0, mtime: ZIP_MTIME }]
476
+ }
477
+ if (plinthArtifact.mesh && plinthArtifact.bounds) {
478
+ packageParts.push({
479
+ name: plinthArtifact.objectName,
480
+ mesh: plinthArtifact.mesh,
481
+ bounds: plinthArtifact.bounds,
482
+ })
483
+ }
484
+ parts.push(plinthPart)
485
+ }
486
+ for (const [index, artifact] of levelArtifacts.entries()) {
487
+ if (artifact.filename && artifact.bytes) {
488
+ files[artifact.filename] = [artifact.bytes, { level: 0, mtime: ZIP_MTIME }]
489
+ }
490
+ if (artifact.mesh && artifact.bounds) {
491
+ packageParts.push({
492
+ name: artifact.objectName,
493
+ mesh: artifact.mesh,
494
+ bounds: artifact.bounds,
495
+ })
496
+ }
497
+ const part = levelParts[index]
498
+ if (part) parts.push(part)
499
+ }
500
+
501
+ return {
502
+ data:
503
+ format === '3mf'
504
+ ? createPrint3mf(packageParts, 'Pascal level parts')
505
+ : zipSync(files, { level: 0 }),
506
+ report: {
507
+ kind: 'print-level-export-report',
508
+ version: 2,
509
+ format,
510
+ scale: options.scale,
511
+ units: 'millimeter',
512
+ orientation: 'z-up',
513
+ status: bundleStatus(diagnostics, parts),
514
+ partCount: parts.length,
515
+ parts,
516
+ excludedNodeIds: Array.from(excludedIds).sort(),
517
+ diagnostics,
518
+ },
519
+ }
520
+ }
521
+
522
+ export function isPrintLevelBundleReport(value: unknown): value is PrintLevelBundleReport {
523
+ if (!value || typeof value !== 'object') return false
524
+ const report = value as Partial<PrintLevelBundleReport>
525
+ return report.kind === 'print-level-export-report' && report.version === 2
526
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getLinearDisplay } from './linear-display'
3
+
4
+ describe('linear property control units', () => {
5
+ test('keeps meter storage while displaying and accepting millimeters', () => {
6
+ const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05)
7
+ expect(display.displayUnit).toBe('mm')
8
+ expect(display.parseUnit).toBe('mm')
9
+ expect(display.toDisplay(2.5)).toBe(2500)
10
+ expect(display.toStored(1800)).toBe(1.8)
11
+ expect(display.toStored(-250)).toBe(-0.25)
12
+ expect(display.precision).toBe(0)
13
+ expect(display.step).toBe(50)
14
+ expect(display.toStored(display.step)).toBe(0.05)
15
+ })
16
+
17
+ test('retains sub-millimeter precision when the field supports it', () => {
18
+ const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001)
19
+ expect(display.precision).toBe(1)
20
+ expect(display.step).toBe(0.1)
21
+ expect(display.toDisplay(0.0125)).toBe(12.5)
22
+ expect(display.roundStored(0.01254)).toBe(0.0125)
23
+ })
24
+
25
+ test('imperial preference takes precedence over the saved metric notation', () => {
26
+ const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05)
27
+ expect(display.displayUnit).toBe('ft')
28
+ expect(display.parseUnit).toBe('ft')
29
+ expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10)
30
+ expect(display.toStored(7)).toBeCloseTo(2.1336, 10)
31
+ expect(display.precision).toBe(2)
32
+ expect(display.step).toBe(0.05)
33
+ })
34
+
35
+ test('switching notation round-trips the same stored dimensions', () => {
36
+ for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) {
37
+ for (const [unit, notation] of [
38
+ ['metric', 'meters'],
39
+ ['metric', 'millimeters'],
40
+ ['imperial', 'meters'],
41
+ ] as const) {
42
+ const display = getLinearDisplay('m', unit, notation, 2, 0.01)
43
+ expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12)
44
+ }
45
+ }
46
+ })
47
+
48
+ test('leaves non-meter fields and their gesture steps unchanged', () => {
49
+ for (const unit of ['°', 'rad', '%', '', 'in', 'cm']) {
50
+ const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05)
51
+ expect(display.displayUnit).toBe(unit)
52
+ expect(display.parseUnit).toBeUndefined()
53
+ expect(display.toDisplay(12.5)).toBe(12.5)
54
+ expect(display.toStored(12.5)).toBe(12.5)
55
+ expect(display.precision).toBe(2)
56
+ expect(display.step).toBe(0.05)
57
+ }
58
+ })
59
+ })
@@ -0,0 +1,39 @@
1
+ import {
2
+ getLinearUnitLabel,
3
+ type LinearUnit,
4
+ linearUnitToMeters,
5
+ type MetricNotation,
6
+ metersToLinearUnit,
7
+ } from './measurements'
8
+
9
+ export function getLinearDisplay(
10
+ unit: string,
11
+ viewerUnit: LinearUnit,
12
+ metricNotation: MetricNotation,
13
+ precision: number,
14
+ step: number,
15
+ ) {
16
+ const isImperial = unit === 'm' && viewerUnit === 'imperial'
17
+ const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters'
18
+ const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit
19
+ const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision
20
+ const displayStep = isMillimeters ? step * 1000 : step
21
+ const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined
22
+ const toDisplay = (stored: number) =>
23
+ isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored
24
+ const toStored = (display: number) =>
25
+ isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display
26
+ const roundStored = (stored: number) =>
27
+ toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision)))
28
+
29
+ return {
30
+ isImperial,
31
+ displayUnit,
32
+ parseUnit,
33
+ precision: displayPrecision,
34
+ step: displayStep,
35
+ toDisplay,
36
+ toStored,
37
+ roundStored,
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ import { expect, mock, test } from 'bun:test'
2
+ import * as core from '@pascal-app/core'
3
+
4
+ const saveAsset = mock(async () => 'asset://stored-scan')
5
+
6
+ mock.module('@pascal-app/core', () => ({ ...core, saveAsset }))
7
+
8
+ const { createLocalScan } = await import('./local-guide-image')
9
+
10
+ test('createLocalScan stores and attaches a scan asset to its level', async () => {
11
+ const createNode = mock()
12
+ const file = new File(['scan data'], 'living-room.glb', { type: 'model/gltf-binary' })
13
+
14
+ const { scan, url } = await createLocalScan({ createNode, file, levelId: 'level_ground' })
15
+
16
+ expect(saveAsset).toHaveBeenCalledWith(file)
17
+ expect(scan).toMatchObject({
18
+ name: 'living-room',
19
+ type: 'scan',
20
+ url: 'asset://stored-scan',
21
+ })
22
+ expect(url).toBe('asset://stored-scan')
23
+ expect(createNode).toHaveBeenCalledWith(scan, 'level_ground')
24
+ })
@@ -2,13 +2,23 @@ import {
2
2
  type AnyNodeId,
3
3
  GuideNode,
4
4
  type GuideNode as GuideNodeType,
5
+ ScanNode,
6
+ type ScanNode as ScanNodeType,
5
7
  saveAsset,
6
8
  } from '@pascal-app/core'
7
9
 
8
10
  export function getGuideImageName(filename: string) {
11
+ return getAssetName(filename, 'Guide image')
12
+ }
13
+
14
+ export function getScanName(filename: string) {
15
+ return getAssetName(filename, 'Scan')
16
+ }
17
+
18
+ function getAssetName(filename: string, fallback: string) {
9
19
  const trimmed = filename.trim()
10
20
  if (!trimmed) {
11
- return 'Guide image'
21
+ return fallback
12
22
  }
13
23
 
14
24
  const dotIndex = trimmed.lastIndexOf('.')
@@ -40,3 +50,26 @@ export async function createLocalGuideImage({
40
50
  createNode(guide, levelId as AnyNodeId)
41
51
  return guide
42
52
  }
53
+
54
+ export async function createLocalScan({
55
+ createNode,
56
+ file,
57
+ levelId,
58
+ }: {
59
+ createNode: (node: ScanNodeType, parentId: AnyNodeId) => void
60
+ file: File
61
+ levelId: string
62
+ }) {
63
+ const assetUrl = await saveAsset(file)
64
+ const scan = ScanNode.parse({
65
+ name: getScanName(file.name),
66
+ url: assetUrl,
67
+ position: [0, 0, 0],
68
+ rotation: [0, 0, 0],
69
+ scale: 1,
70
+ opacity: 100,
71
+ })
72
+
73
+ createNode(scan, levelId as AnyNodeId)
74
+ return { scan, url: assetUrl }
75
+ }