@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,484 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, RoofSegmentNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
+ import { generateRoofSegmentGeometry } from '@pascal-app/viewer'
4
+ import { XMLParser } from 'fast-xml-parser'
5
+ import { strFromU8, unzipSync } from 'fflate'
6
+ import * as THREE from 'three'
7
+ import { prepareSceneForExport } from './glb-export'
8
+ import { exportSceneLevelsForPrint } from './level-print-export'
9
+ import { filterPreparedSceneForPrintContent } from './print-content-scope'
10
+ import { compileSemanticPrintShell } from './print-shell-compiler'
11
+
12
+ function registerFixtureKind(category: 'structure' | 'furnish'): string {
13
+ const kind = `print-level-${category}-${crypto.randomUUID()}`
14
+ registerNode({
15
+ kind,
16
+ schemaVersion: 1,
17
+ category,
18
+ defaults: () => ({}),
19
+ capabilities: {},
20
+ } as never)
21
+ return kind
22
+ }
23
+
24
+ function binaryStlBounds(buffer: Uint8Array): {
25
+ triangles: number
26
+ min: THREE.Vector3
27
+ max: THREE.Vector3
28
+ size: THREE.Vector3
29
+ } {
30
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
31
+ const triangles = view.getUint32(80, true)
32
+ const bounds = new THREE.Box3()
33
+ const point = new THREE.Vector3()
34
+ let offset = 84
35
+
36
+ for (let triangle = 0; triangle < triangles; triangle += 1) {
37
+ offset += 12
38
+ for (let vertex = 0; vertex < 3; vertex += 1) {
39
+ point.set(
40
+ view.getFloat32(offset, true),
41
+ view.getFloat32(offset + 4, true),
42
+ view.getFloat32(offset + 8, true),
43
+ )
44
+ bounds.expandByPoint(point)
45
+ offset += 12
46
+ }
47
+ offset += 2
48
+ }
49
+
50
+ return {
51
+ triangles,
52
+ min: bounds.min.clone(),
53
+ max: bounds.max.clone(),
54
+ size: bounds.getSize(new THREE.Vector3()),
55
+ }
56
+ }
57
+
58
+ function asArray<T>(value: T | T[]): T[] {
59
+ return Array.isArray(value) ? value : [value]
60
+ }
61
+
62
+ function twoLevelFixture() {
63
+ const root = new THREE.Group()
64
+ const building = new THREE.Group()
65
+ const ground = new THREE.Group()
66
+ const upper = new THREE.Group()
67
+ const groundStructure = new THREE.Group()
68
+ const upperStructure = new THREE.Group()
69
+ const groundSolid = new THREE.Mesh(new THREE.BoxGeometry(10, 3, 8))
70
+ const upperSolid = new THREE.Mesh(new THREE.BoxGeometry(8, 2, 6))
71
+ groundSolid.position.y = 1.5
72
+ upperSolid.position.y = 1
73
+ groundStructure.add(groundSolid)
74
+ upperStructure.add(upperSolid)
75
+ ground.add(groundStructure)
76
+ upper.add(upperStructure)
77
+ upper.position.y = 3
78
+ root.add(building)
79
+ building.add(ground, upper)
80
+
81
+ const structureKind = registerFixtureKind('structure')
82
+ sceneRegistry.nodes.set('building_main', building)
83
+ sceneRegistry.nodes.set('level_ground', ground)
84
+ sceneRegistry.nodes.set('level_upper', upper)
85
+ sceneRegistry.nodes.set('structure_ground', groundStructure)
86
+ sceneRegistry.nodes.set('structure_upper', upperStructure)
87
+
88
+ const nodes: Record<string, AnyNode> = {
89
+ building_main: {
90
+ object: 'node',
91
+ id: 'building_main',
92
+ type: 'building',
93
+ parentId: null,
94
+ children: ['level_ground', 'level_upper'],
95
+ } as unknown as AnyNode,
96
+ level_ground: {
97
+ object: 'node',
98
+ id: 'level_ground',
99
+ type: 'level',
100
+ name: 'Ground',
101
+ level: 0,
102
+ height: 3,
103
+ parentId: 'building_main',
104
+ children: ['structure_ground'],
105
+ visible: true,
106
+ } as unknown as AnyNode,
107
+ level_upper: {
108
+ object: 'node',
109
+ id: 'level_upper',
110
+ type: 'level',
111
+ name: 'Upper',
112
+ level: 1,
113
+ height: 2,
114
+ parentId: 'building_main',
115
+ children: ['structure_upper'],
116
+ visible: true,
117
+ } as unknown as AnyNode,
118
+ structure_ground: {
119
+ object: 'node',
120
+ id: 'structure_ground',
121
+ type: structureKind,
122
+ parentId: 'level_ground',
123
+ visible: true,
124
+ } as unknown as AnyNode,
125
+ structure_upper: {
126
+ object: 'node',
127
+ id: 'structure_upper',
128
+ type: structureKind,
129
+ parentId: 'level_upper',
130
+ visible: true,
131
+ } as unknown as AnyNode,
132
+ }
133
+
134
+ return { root, building, ground, upper, groundStructure, upperStructure, nodes }
135
+ }
136
+
137
+ describe('per-level print STL export', () => {
138
+ afterEach(() => {
139
+ sceneRegistry.nodes.clear()
140
+ })
141
+
142
+ test('packages one bed-normalized, scale-correct STL per visible level', async () => {
143
+ const fixture = twoLevelFixture()
144
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
145
+
146
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 })
147
+ const files = unzipSync(bundle.data)
148
+ const ground = binaryStlBounds(files['01_ground.stl']!)
149
+ const upper = binaryStlBounds(files['02_upper.stl']!)
150
+
151
+ expect(Object.keys(files)).toEqual(['01_ground.stl', '02_upper.stl'])
152
+ expect(bundle.report.status).toBe('pass')
153
+ expect(bundle.report.partCount).toBe(2)
154
+ expect(bundle.report.parts.map((part) => part.kind)).toEqual(['level', 'level'])
155
+ expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([0, 3])
156
+ expect(ground.triangles).toBe(12)
157
+ expect(ground.min.z).toBeCloseTo(0, 6)
158
+ expect(ground.size.x).toBeCloseTo(100, 4)
159
+ expect(ground.size.y).toBeCloseTo(80, 4)
160
+ expect(ground.size.z).toBeCloseTo(30, 4)
161
+ expect(upper.triangles).toBe(12)
162
+ expect(upper.min.z).toBeCloseTo(0, 6)
163
+ expect(upper.size.x).toBeCloseTo(80, 4)
164
+ expect(upper.size.y).toBeCloseTo(60, 4)
165
+ expect(upper.size.z).toBeCloseTo(20, 4)
166
+ })
167
+
168
+ test('blocks geometry that crosses or floats above its stored level base', async () => {
169
+ const fixture = twoLevelFixture()
170
+ fixture.groundStructure.position.y = -0.25
171
+ fixture.upperStructure.position.y = 0.5
172
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
173
+
174
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 })
175
+ const [ground, upper] = bundle.report.parts
176
+
177
+ expect(bundle.report.status).toBe('blocked')
178
+ expect(ground?.sourceBaseMeters).toBe(0)
179
+ expect(ground?.report.bounds?.min.z).toBeCloseTo(-2.5, 5)
180
+ expect(ground?.report.diagnostics).toContainEqual(
181
+ expect.objectContaining({
182
+ code: 'level_geometry_below_base',
183
+ nodeIds: ['level_ground'],
184
+ }),
185
+ )
186
+ expect(upper?.sourceBaseMeters).toBe(3)
187
+ expect(upper?.report.bounds?.min.z).toBeCloseTo(5, 5)
188
+ expect(upper?.report.diagnostics).toContainEqual(
189
+ expect.objectContaining({
190
+ code: 'level_geometry_detached_from_base',
191
+ nodeIds: ['level_upper'],
192
+ }),
193
+ )
194
+ })
195
+
196
+ test('orders a basement first and honors additive stored base elevation', async () => {
197
+ const fixture = twoLevelFixture()
198
+ fixture.nodes.level_ground = {
199
+ ...fixture.nodes.level_ground!,
200
+ name: 'Basement',
201
+ level: -1,
202
+ baseElevation: -0.4,
203
+ } as AnyNode
204
+ fixture.nodes.level_upper = {
205
+ ...fixture.nodes.level_upper!,
206
+ name: 'Ground',
207
+ level: 0,
208
+ } as AnyNode
209
+ fixture.ground.position.y = -0.4
210
+ fixture.upper.position.y = 2.6
211
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
212
+
213
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 })
214
+ const files = unzipSync(bundle.data)
215
+
216
+ expect(Object.keys(files)).toEqual(['01_basement.stl', '02_ground.stl'])
217
+ expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground', 'level_upper'])
218
+ expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([-0.4, 2.6])
219
+ expect(binaryStlBounds(files['01_basement.stl']!).min.z).toBeCloseTo(0, 6)
220
+ expect(binaryStlBounds(files['02_ground.stl']!).min.z).toBeCloseTo(0, 6)
221
+ })
222
+
223
+ test('omits and blocks an unsplit stair that spans two levels', async () => {
224
+ const fixture = twoLevelFixture()
225
+ const stair = new THREE.Group()
226
+ stair.add(new THREE.Mesh(new THREE.BoxGeometry(1, 3, 2)))
227
+ fixture.ground.add(stair)
228
+ sceneRegistry.nodes.set('stair_main', stair)
229
+ fixture.nodes.stair_main = {
230
+ object: 'node',
231
+ id: 'stair_main',
232
+ type: 'stair',
233
+ parentId: 'level_ground',
234
+ fromLevelId: 'level_ground',
235
+ toLevelId: 'level_upper',
236
+ children: [],
237
+ visible: true,
238
+ } as unknown as AnyNode
239
+
240
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
241
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 })
242
+
243
+ expect(bundle.report.status).toBe('blocked')
244
+ expect(bundle.report.excludedNodeIds).toEqual(['stair_main'])
245
+ expect(bundle.report.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
246
+ 'unsplit_spanning_node',
247
+ )
248
+ expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12])
249
+ })
250
+
251
+ test('does not create a part for a semantically hidden level', async () => {
252
+ const fixture = twoLevelFixture()
253
+ fixture.nodes.level_upper = {
254
+ ...fixture.nodes.level_upper!,
255
+ visible: false,
256
+ } as AnyNode
257
+
258
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
259
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 })
260
+ const files = unzipSync(bundle.data)
261
+
262
+ expect(Object.keys(files)).toEqual(['01_ground.stl'])
263
+ expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground'])
264
+ })
265
+
266
+ test('applies structure scope before partitioning level files', async () => {
267
+ const fixture = twoLevelFixture()
268
+ const furniture = new THREE.Group()
269
+ furniture.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)))
270
+ fixture.ground.add(furniture)
271
+ sceneRegistry.nodes.set('chair_ground', furniture)
272
+ fixture.nodes.chair_ground = {
273
+ object: 'node',
274
+ id: 'chair_ground',
275
+ type: registerFixtureKind('furnish'),
276
+ parentId: 'level_ground',
277
+ visible: true,
278
+ } as unknown as AnyNode
279
+
280
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
281
+ const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure')
282
+ const bundle = await exportSceneLevelsForPrint(structure, fixture.nodes, { scale: 100 })
283
+
284
+ expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12])
285
+ expect(bundle.report.status).toBe('pass')
286
+ })
287
+
288
+ test('uses the asynchronous shell compiler before exporting a level part', async () => {
289
+ const root = new THREE.Group()
290
+ const building = new THREE.Group()
291
+ building.userData = { pascalId: 'building_roof-print' }
292
+ const level = new THREE.Group()
293
+ level.userData = { pascalId: 'level_roof-print' }
294
+ const roof = RoofSegmentNode.parse({
295
+ id: 'rseg_level-print',
296
+ parentId: 'level_roof-print',
297
+ roofType: 'gable',
298
+ width: 4,
299
+ depth: 3,
300
+ wallHeight: 0.5,
301
+ pitch: 30,
302
+ wallThickness: 0.15,
303
+ deckThickness: 0.1,
304
+ overhang: 0.3,
305
+ shingleThickness: 0.05,
306
+ })
307
+ const roofRoot = new THREE.Group()
308
+ roofRoot.userData = { pascalId: roof.id }
309
+ roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof)))
310
+ root.add(building)
311
+ building.add(level)
312
+ level.add(roofRoot)
313
+
314
+ const nodes: Record<string, AnyNode> = {
315
+ 'building_roof-print': {
316
+ object: 'node',
317
+ id: 'building_roof-print',
318
+ type: 'building',
319
+ parentId: null,
320
+ children: ['level_roof-print'],
321
+ } as unknown as AnyNode,
322
+ 'level_roof-print': {
323
+ object: 'node',
324
+ id: 'level_roof-print',
325
+ type: 'level',
326
+ name: 'Roof',
327
+ level: 0,
328
+ parentId: 'building_roof-print',
329
+ children: [roof.id],
330
+ visible: true,
331
+ } as unknown as AnyNode,
332
+ [roof.id]: roof,
333
+ }
334
+
335
+ let compileCalls = 0
336
+ const raw = await exportSceneLevelsForPrint(root, nodes, { scale: 100 })
337
+ const compiled = await exportSceneLevelsForPrint(root, nodes, {
338
+ scale: 100,
339
+ compileShells: true,
340
+ compileShell: async (source, compilerNodes) => {
341
+ compileCalls += 1
342
+ return compileSemanticPrintShell(source, compilerNodes)
343
+ },
344
+ })
345
+ const part = compiled.report.parts[0]!
346
+
347
+ expect(raw.report.parts[0]?.report.status).toBe('blocked')
348
+ expect(compileCalls).toBe(1)
349
+ expect(compiled.report.status).toBe('pass')
350
+ expect(part.report.status).toBe('pass')
351
+ expect(part.report.bounds?.width).toBeCloseTo(46.6962, 3)
352
+ expect(part.report.bounds?.depth).toBeCloseTo(37.1962, 3)
353
+ expect(part.report.bounds?.height).toBeCloseTo(15.3923, 3)
354
+ expect(part.report.boundaryEdgeCount).toBe(0)
355
+ expect(part.report.nonManifoldEdgeCount).toBe(0)
356
+ expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(
357
+ expect.arrayContaining(['baseline_compiler', 'compiler_limits']),
358
+ )
359
+ expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain(
360
+ 'compiler_pending',
361
+ )
362
+ })
363
+
364
+ test('produces deterministic archive bytes for the same level parts', async () => {
365
+ const fixture = twoLevelFixture()
366
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
367
+
368
+ const first = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 })
369
+ const second = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 })
370
+
371
+ expect(first.data).toEqual(second.data)
372
+ })
373
+
374
+ test('prepends an optional physical-size plinth derived from the lowest level bounds', async () => {
375
+ const fixture = twoLevelFixture()
376
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
377
+
378
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, {
379
+ scale: 100,
380
+ plinth: { marginMm: 2, thicknessMm: 3 },
381
+ })
382
+ const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, {
383
+ scale: 100,
384
+ plinth: { marginMm: 2, thicknessMm: 3 },
385
+ })
386
+ const files = unzipSync(bundle.data)
387
+ const plinth = binaryStlBounds(files['00_plinth.stl']!)
388
+
389
+ expect(Object.keys(files)).toEqual(['00_plinth.stl', '01_ground.stl', '02_upper.stl'])
390
+ expect(bundle.report.parts.map((part) => part.kind)).toEqual(['plinth', 'level', 'level'])
391
+ expect(bundle.report.parts[0]?.levelId).toBe('level_ground')
392
+ expect(plinth.triangles).toBe(12)
393
+ expect(plinth.size.x).toBeCloseTo(104, 4)
394
+ expect(plinth.size.y).toBeCloseTo(84, 4)
395
+ expect(plinth.size.z).toBeCloseTo(3, 4)
396
+ expect(bundle.data).toEqual(repeated.data)
397
+ })
398
+
399
+ test('packages named parts in one non-overlapping millimeter-unit 3MF plate mesh', async () => {
400
+ const fixture = twoLevelFixture()
401
+ fixture.nodes.level_ground = {
402
+ ...fixture.nodes.level_ground!,
403
+ name: 'Ground & Entry',
404
+ } as AnyNode
405
+ const prepared = prepareSceneForExport(fixture.root, fixture.nodes)
406
+ const options = {
407
+ scale: 100,
408
+ format: '3mf' as const,
409
+ plinth: { marginMm: 2, thicknessMm: 3 },
410
+ }
411
+
412
+ const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options)
413
+ const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options)
414
+ const files = unzipSync(bundle.data)
415
+ const xml = strFromU8(files['3D/3dmodel.model']!)
416
+ const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse(
417
+ xml,
418
+ ).model
419
+ const object = asArray<Record<string, unknown>>(model.resources.object)[0]!
420
+ const items = asArray<Record<string, string>>(model.build.item)
421
+ const metadata = asArray<Record<string, string>>(model.metadata)
422
+ const partManifest = JSON.parse(
423
+ metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!,
424
+ ) as Array<{
425
+ name: string
426
+ vertexStart: number
427
+ vertexCount: number
428
+ triangleStart: number
429
+ triangleCount: number
430
+ }>
431
+ const mesh = object.mesh as {
432
+ vertices: { vertex: Record<string, string> | Record<string, string>[] }
433
+ triangles: { triangle: Record<string, string> | Record<string, string>[] }
434
+ }
435
+ const vertices = asArray(mesh.vertices.vertex)
436
+ const triangles = asArray(mesh.triangles.triangle)
437
+
438
+ expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model'])
439
+ expect(model.unit).toBe('millimeter')
440
+ expect(object.name).toBe('Pascal level parts')
441
+ expect(partManifest.map((part) => part.name)).toEqual([
442
+ '00 Plinth',
443
+ '01 Ground & Entry',
444
+ '02 Upper',
445
+ ])
446
+ expect(items.map((item) => item.objectid)).toEqual(['1'])
447
+ expect(bundle.report.format).toBe('3mf')
448
+ expect(bundle.report.parts.map((part) => part.filename)).toEqual([null, null, null])
449
+ expect(bundle.report.parts.map((part) => part.objectName)).toEqual([
450
+ '00 Plinth',
451
+ '01 Ground & Entry',
452
+ '02 Upper',
453
+ ])
454
+
455
+ const expectedSizes = [
456
+ [104, 84, 3],
457
+ [100, 80, 30],
458
+ [80, 60, 20],
459
+ ]
460
+ let previousMaxX = Number.NEGATIVE_INFINITY
461
+ for (const [index, part] of partManifest.entries()) {
462
+ const bounds = new THREE.Box3()
463
+ for (const vertex of vertices.slice(part.vertexStart, part.vertexStart + part.vertexCount)) {
464
+ bounds.expandByPoint(
465
+ new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)),
466
+ )
467
+ }
468
+ const size = bounds.getSize(new THREE.Vector3())
469
+
470
+ expect(part.triangleCount).toBe(12)
471
+ expect(
472
+ triangles.slice(part.triangleStart, part.triangleStart + part.triangleCount),
473
+ ).toHaveLength(12)
474
+ expect(size.x).toBeCloseTo(expectedSizes[index]![0]!, 5)
475
+ expect(size.y).toBeCloseTo(expectedSizes[index]![1]!, 5)
476
+ expect(size.z).toBeCloseTo(expectedSizes[index]![2]!, 5)
477
+ expect(bounds.min.z).toBeCloseTo(0, 9)
478
+ if (index > 0) expect(bounds.min.x - previousMaxX).toBeCloseTo(5, 5)
479
+ previousMaxX = bounds.max.x
480
+ }
481
+ expect(items[0]?.transform).toBeUndefined()
482
+ expect(bundle.data).toEqual(repeated.data)
483
+ })
484
+ })