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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,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
+ }