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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { PrintLevelBundleReport } from '../../../../../lib/level-print-export'
3
+ import type { ModelExport, ModelExportOptions } from '../../../../../lib/model-export'
4
+ import type { PrintExportReport } from '../../../../../lib/print-export'
5
+ import { preparePrintExport } from './print-export-button'
6
+
7
+ const report: PrintExportReport = {
8
+ kind: 'print-export-report',
9
+ version: 2,
10
+ format: '3mf',
11
+ scale: 100,
12
+ units: 'millimeter',
13
+ orientation: 'z-up',
14
+ status: 'pass',
15
+ bounds: {
16
+ min: { x: -25, y: -15, z: 0 },
17
+ max: { x: 25, y: 15, z: 20 },
18
+ width: 50,
19
+ depth: 30,
20
+ height: 20,
21
+ },
22
+ triangleCount: 12,
23
+ invalidTriangleCount: 0,
24
+ degenerateTriangleCount: 0,
25
+ boundaryEdgeCount: 0,
26
+ nonManifoldEdgeCount: 0,
27
+ connectedComponentCount: 1,
28
+ solidComponentCount: 1,
29
+ invertedWinding: false,
30
+ volumeMm3: 30_000,
31
+ diagnostics: [],
32
+ }
33
+
34
+ describe('simple 3D print export', () => {
35
+ test('uses one fixed safe print profile', async () => {
36
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
37
+ const artifact = { blob: new Blob(['3mf']), filename: 'house.3mf', metadata: report }
38
+ const modelExport: ModelExport = async (format, options) => {
39
+ calls.push({ format, options })
40
+ return artifact
41
+ }
42
+
43
+ const prepared = await preparePrintExport(modelExport, true)
44
+
45
+ expect(calls).toEqual([
46
+ {
47
+ format: 'print-3mf',
48
+ options: {
49
+ onlyVisible: true,
50
+ download: false,
51
+ printScale: 100,
52
+ printScope: 'levels',
53
+ printContent: 'structure',
54
+ printBase: 'none',
55
+ },
56
+ },
57
+ ])
58
+ expect(prepared).toEqual({ artifact, report })
59
+ })
60
+
61
+ test('blocks the download when preflight finds invalid geometry', async () => {
62
+ const blockedReport: PrintExportReport = {
63
+ ...report,
64
+ status: 'blocked',
65
+ diagnostics: [
66
+ {
67
+ severity: 'error',
68
+ code: 'open_boundary',
69
+ message: 'One wall has an open edge.',
70
+ },
71
+ ],
72
+ }
73
+ const modelExport: ModelExport = async () => ({
74
+ blob: new Blob(['3mf']),
75
+ filename: 'house.3mf',
76
+ metadata: blockedReport,
77
+ })
78
+
79
+ await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
80
+ 'One wall has an open edge.',
81
+ )
82
+ })
83
+
84
+ test('shows a per-level preflight error when the bundle has no top-level error', async () => {
85
+ const blockedPartReport: PrintExportReport = {
86
+ ...report,
87
+ status: 'blocked',
88
+ diagnostics: [
89
+ {
90
+ severity: 'error',
91
+ code: 'open_boundary',
92
+ message: 'The upper level has an open edge.',
93
+ },
94
+ ],
95
+ }
96
+ const blockedBundleReport: PrintLevelBundleReport = {
97
+ kind: 'print-level-export-report',
98
+ version: 2,
99
+ format: '3mf',
100
+ scale: 100,
101
+ units: 'millimeter',
102
+ orientation: 'z-up',
103
+ status: 'blocked',
104
+ partCount: 1,
105
+ parts: [
106
+ {
107
+ kind: 'level',
108
+ levelId: 'upper-level',
109
+ label: 'Upper level',
110
+ objectName: 'Upper level',
111
+ filename: null,
112
+ sourceBaseMeters: 3,
113
+ report: blockedPartReport,
114
+ },
115
+ ],
116
+ excludedNodeIds: [],
117
+ diagnostics: [],
118
+ }
119
+ const modelExport: ModelExport = async () => ({
120
+ blob: new Blob(['3mf']),
121
+ filename: 'house.zip',
122
+ metadata: blockedBundleReport,
123
+ })
124
+
125
+ await expect(preparePrintExport(modelExport, true)).rejects.toThrow(
126
+ 'The upper level has an open edge.',
127
+ )
128
+ })
129
+
130
+ test('rejects an exporter response without print metadata', async () => {
131
+ const modelExport: ModelExport = async () => ({
132
+ blob: new Blob(['3mf']),
133
+ filename: 'house.3mf',
134
+ })
135
+
136
+ await expect(preparePrintExport(modelExport, false)).rejects.toThrow(
137
+ 'did not return a valid file',
138
+ )
139
+ })
140
+ })
@@ -0,0 +1,109 @@
1
+ import { AlertTriangle, Printer } from 'lucide-react'
2
+ import { useState } from 'react'
3
+ import { Button } from '../../../../../components/ui/primitives/button'
4
+ import {
5
+ isPrintLevelBundleReport,
6
+ type PrintLevelBundleReport,
7
+ } from '../../../../../lib/level-print-export'
8
+ import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export'
9
+ import {
10
+ isPrintExportReport,
11
+ type PrintExportReport,
12
+ } from '../../../../../lib/print-export'
13
+ import useEditor from '../../../../../store/use-editor'
14
+
15
+ type PreparedPrintExport = {
16
+ artifact: ModelExportArtifact
17
+ report: PrintExportReport | PrintLevelBundleReport
18
+ }
19
+
20
+ function downloadArtifact(artifact: ModelExportArtifact) {
21
+ const url = URL.createObjectURL(artifact.blob)
22
+ const link = document.createElement('a')
23
+ link.href = url
24
+ link.download = artifact.filename
25
+ link.click()
26
+ URL.revokeObjectURL(url)
27
+ }
28
+
29
+ function firstBlockingMessage(report: PrintExportReport | PrintLevelBundleReport) {
30
+ const bundleDiagnostic = report.diagnostics.find((item) => item.severity === 'error')
31
+ if (bundleDiagnostic || !isPrintLevelBundleReport(report)) return bundleDiagnostic?.message
32
+
33
+ for (const part of report.parts) {
34
+ const partDiagnostic = part.report.diagnostics.find((item) => item.severity === 'error')
35
+ if (partDiagnostic) return partDiagnostic.message
36
+ }
37
+ }
38
+
39
+ export async function preparePrintExport(
40
+ modelExport: ModelExport,
41
+ onlyVisible: boolean,
42
+ ): Promise<PreparedPrintExport> {
43
+ const artifact = await modelExport('print-3mf', {
44
+ onlyVisible,
45
+ download: false,
46
+ printScale: 100,
47
+ printScope: 'levels',
48
+ printContent: 'structure',
49
+ printBase: 'none',
50
+ })
51
+
52
+ if (
53
+ !artifact ||
54
+ (!isPrintExportReport(artifact.metadata) && !isPrintLevelBundleReport(artifact.metadata))
55
+ ) {
56
+ throw new Error('The 3D print exporter did not return a valid file.')
57
+ }
58
+
59
+ if (artifact.metadata.status === 'blocked') {
60
+ throw new Error(
61
+ firstBlockingMessage(artifact.metadata) ??
62
+ 'This project cannot be exported as printable parts.',
63
+ )
64
+ }
65
+
66
+ return { artifact, report: artifact.metadata }
67
+ }
68
+
69
+ export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
70
+ const modelExport = useEditor((state) => state.modelExport)
71
+ const [isExporting, setIsExporting] = useState(false)
72
+ const [error, setError] = useState<string | null>(null)
73
+
74
+ const handleExport = async () => {
75
+ if (!modelExport) return
76
+
77
+ setIsExporting(true)
78
+ setError(null)
79
+ try {
80
+ const prepared = await preparePrintExport(modelExport, onlyVisible)
81
+ downloadArtifact(prepared.artifact)
82
+ } catch (reason) {
83
+ setError(reason instanceof Error ? reason.message : '3D print export failed.')
84
+ } finally {
85
+ setIsExporting(false)
86
+ }
87
+ }
88
+
89
+ return (
90
+ <>
91
+ <Button
92
+ aria-busy={isExporting}
93
+ className="w-full justify-start gap-2"
94
+ disabled={isExporting || !modelExport}
95
+ onClick={handleExport}
96
+ variant="outline"
97
+ >
98
+ <Printer className="size-4" />
99
+ Export 3D print files
100
+ </Button>
101
+ {error && (
102
+ <div className="flex gap-2 text-destructive text-xs">
103
+ <AlertTriangle className="mt-0.5 size-4 shrink-0" />
104
+ <span>{error}</span>
105
+ </div>
106
+ )}
107
+ </>
108
+ )
109
+ }
@@ -2,10 +2,11 @@ import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
4
  import { memo, useCallback, useState } from 'react'
5
+ import { useShallow } from 'zustand/react/shallow'
5
6
  import { SnapTargetIcon } from '../../../snap-target-badge'
6
7
  import useEditor from './../../../../../store/use-editor'
7
8
  import { InlineRenameInput } from './inline-rename-input'
8
- import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
9
+ import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
9
10
  import { TreeNodeActions } from './tree-node-actions'
10
11
 
11
12
  interface DormerTreeNodeProps {
@@ -26,8 +27,12 @@ export const DormerTreeNode = memo(function DormerTreeNode({
26
27
  isLast,
27
28
  }: DormerTreeNodeProps) {
28
29
  const [isEditing, setIsEditing] = useState(false)
30
+ const [expanded, setExpanded] = useState(true)
29
31
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
30
32
  const node = useScene((s) => s.nodes[nodeId] as DormerNode | undefined)
33
+ const children = useScene(
34
+ useShallow((s) => (s.nodes[nodeId] as DormerNode | undefined)?.children ?? []),
35
+ )
31
36
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
32
37
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
33
38
  const setSelection = useViewer((state) => state.setSelection)
@@ -55,8 +60,8 @@ export const DormerTreeNode = memo(function DormerTreeNode({
55
60
  <TreeNodeWrapper
56
61
  actions={<TreeNodeActions nodeId={nodeId} />}
57
62
  depth={depth}
58
- expanded={false}
59
- hasChildren={false}
63
+ expanded={expanded}
64
+ hasChildren={children.length > 0}
60
65
  icon={
61
66
  <SnapTargetIcon target="roof">
62
67
  <Image
@@ -86,7 +91,16 @@ export const DormerTreeNode = memo(function DormerTreeNode({
86
91
  onDoubleClick={() => focusTreeNode(nodeId)}
87
92
  onMouseEnter={() => setHoveredId(nodeId)}
88
93
  onMouseLeave={() => setHoveredId(null)}
89
- onToggle={() => {}}
90
- />
94
+ onToggle={() => setExpanded((value) => !value)}
95
+ >
96
+ {children.map((childId, index) => (
97
+ <TreeNode
98
+ depth={depth + 1}
99
+ isLast={index === children.length - 1}
100
+ key={childId}
101
+ nodeId={childId as AnyNodeId}
102
+ />
103
+ ))}
104
+ </TreeNodeWrapper>
91
105
  )
92
106
  })
@@ -11,11 +11,14 @@ import {
11
11
  useScene,
12
12
  type ZoneNode,
13
13
  } from '@pascal-app/core'
14
- import { useViewer } from '@pascal-app/viewer'
14
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
15
15
  import {
16
16
  Camera,
17
17
  ChevronDown,
18
+ ChevronRight,
18
19
  Copy,
20
+ Eye,
21
+ EyeOff,
19
22
  Loader2,
20
23
  MoreHorizontal,
21
24
  Pencil,
@@ -25,7 +28,7 @@ import {
25
28
  X,
26
29
  } from 'lucide-react'
27
30
  import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
28
- import { memo, useEffect, useRef, useState } from 'react'
31
+ import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react'
29
32
  import { useShallow } from 'zustand/react/shallow'
30
33
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
31
34
  import {
@@ -48,9 +51,11 @@ import {
48
51
  squareMetersToAreaUnit,
49
52
  } from './../../../../../lib/measurements'
50
53
  import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
54
+ import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children'
51
55
  import { cn } from './../../../../../lib/utils'
52
56
  import useEditor from './../../../../../store/use-editor'
53
57
  import { useUploadStore } from '../../../../../store/use-upload'
58
+ import { MetricControl } from '../../../controls/metric-control'
54
59
  import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
55
60
  import { InlineRenameInput } from './inline-rename-input'
56
61
  import { focusTreeNode, TreeNode } from './tree-node'
@@ -349,8 +354,25 @@ const ReferenceItem = memo(function ReferenceItem({
349
354
  handleDelete: (id: string, e: React.MouseEvent) => void
350
355
  }) {
351
356
  const [isEditing, setIsEditing] = useState(false)
357
+ const [isExpanded, setIsExpanded] = useState(true)
358
+ const updateNode = useScene((state) => state.updateNode)
359
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
360
+ const isCapture = refNode.type === 'scan'
361
+ const isVisible = refNode.visible !== false
362
+ useSyncExternalStore(
363
+ editorHostTreeChildrenRegistry.subscribe,
364
+ editorHostTreeChildrenRegistry.getSnapshot,
365
+ editorHostTreeChildrenRegistry.getSnapshot,
366
+ )
367
+ const hostChildren = isCapture
368
+ ? editorHostTreeChildrenRegistry.childrenForKind(refNode.type)
369
+ : undefined
370
+ const hasHostChildren = Boolean(hostChildren?.hasChildren(refNode))
371
+ const HostChildren = hasHostChildren ? hostChildren?.component : undefined
372
+
352
373
  const handleSelect = () => {
353
374
  setSelectedReferenceId(refNode.id)
375
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
354
376
  }
355
377
 
356
378
  const handleDoubleClick = () => {
@@ -358,53 +380,98 @@ const ReferenceItem = memo(function ReferenceItem({
358
380
  }
359
381
 
360
382
  return (
361
- <div
362
- className="group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30"
363
- onClick={handleSelect}
364
- onDoubleClick={handleDoubleClick}
365
- >
383
+ <div className="relative flex flex-col">
366
384
  <div
367
385
  className={cn(
368
- 'pointer-events-none absolute z-10 w-px bg-border/50',
369
- isLastRow ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
386
+ 'group/ref relative flex h-8 cursor-pointer select-none items-center border-border/50 border-b pr-2 text-xs transition-colors hover:bg-accent/30',
387
+ selectedReferenceId === refNode.id && 'bg-accent/50 text-foreground',
388
+ !isVisible && 'opacity-50',
370
389
  )}
371
- style={{ left: 45 }}
372
- />
373
- <div
374
- className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
375
- style={{ left: 45, width: 8 }}
376
- />
390
+ onClick={handleSelect}
391
+ onDoubleClick={handleDoubleClick}
392
+ >
393
+ <div
394
+ className={cn(
395
+ 'pointer-events-none absolute z-10 w-px bg-border/50',
396
+ isLastRow && !(hasHostChildren && isExpanded) ? 'top-0 bottom-1/2' : 'top-0 bottom-0',
397
+ )}
398
+ style={{ left: 45 }}
399
+ />
400
+ <div
401
+ className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
402
+ style={{ left: 45, width: 8 }}
403
+ />
377
404
 
378
- <div className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 pl-[60px] text-muted-foreground group-hover/ref:text-foreground">
379
- {refNode.type === 'scan' ? (
380
- <img
381
- alt="Scan"
382
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
383
- src="/icons/mesh.webp"
384
- />
385
- ) : (
386
- <img
387
- alt="Guide"
388
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
389
- src="/icons/floorplan.webp"
405
+ {isCapture ? (
406
+ <button
407
+ className="z-20 ml-[52px] flex h-4 w-4 shrink-0 items-center justify-center"
408
+ onClick={(event) => {
409
+ event.stopPropagation()
410
+ if (hasHostChildren) setIsExpanded((expanded) => !expanded)
411
+ }}
412
+ type="button"
413
+ >
414
+ {hasHostChildren ? (
415
+ <ChevronRight
416
+ className={cn('h-3 w-3 transition-transform', isExpanded && 'rotate-90')}
417
+ />
418
+ ) : null}
419
+ </button>
420
+ ) : null}
421
+
422
+ <div
423
+ className={cn(
424
+ 'flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 py-0 text-muted-foreground group-hover/ref:text-foreground',
425
+ !isCapture && 'pl-[60px]',
426
+ )}
427
+ >
428
+ {isCapture ? (
429
+ <img
430
+ alt="Capture"
431
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
432
+ src="/icons/mesh.webp"
433
+ />
434
+ ) : (
435
+ <img
436
+ alt="Guide"
437
+ className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
438
+ src="/icons/floorplan.webp"
439
+ />
440
+ )}
441
+ <InlineRenameInput
442
+ defaultName={isCapture ? 'Capture' : 'Guide Image'}
443
+ isEditing={isEditing}
444
+ nodeId={refNode.id}
445
+ onStartEditing={() => setIsEditing(true)}
446
+ onStopEditing={() => setIsEditing(false)}
390
447
  />
391
- )}
392
- <InlineRenameInput
393
- defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
394
- isEditing={isEditing}
395
- nodeId={refNode.id}
396
- onStartEditing={() => setIsEditing(true)}
397
- onStopEditing={() => setIsEditing(false)}
398
- />
399
- </div>
448
+ </div>
400
449
 
401
- <button
402
- className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
403
- onClick={(e) => handleDelete(refNode.id, e)}
404
- title="Delete"
405
- >
406
- <Trash2 className="h-3 w-3" />
407
- </button>
450
+ {isCapture ? (
451
+ <button
452
+ className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
453
+ onClick={(event) => {
454
+ event.stopPropagation()
455
+ updateNode(refNode.id, { visible: !isVisible })
456
+ }}
457
+ title={isVisible ? 'Hide' : 'Show'}
458
+ type="button"
459
+ >
460
+ {isVisible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
461
+ </button>
462
+ ) : null}
463
+ <button
464
+ className="z-20 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-black/5 hover:text-foreground group-hover/ref:opacity-100 dark:hover:bg-white/10"
465
+ onClick={(e) => handleDelete(refNode.id, e)}
466
+ title="Delete"
467
+ type="button"
468
+ >
469
+ <Trash2 className="h-3 w-3" />
470
+ </button>
471
+ </div>
472
+ {isExpanded && HostChildren ? (
473
+ <HostChildren depth={3} nodeId={refNode.id} parentVisible={isVisible} />
474
+ ) : null}
408
475
  </div>
409
476
  )
410
477
  })
@@ -642,7 +709,8 @@ const LevelItem = memo(function LevelItem({
642
709
  ? (level.parentId as BuildingNode['id'])
643
710
  : undefined
644
711
 
645
- const selectLevel = (levelId: LevelNode['id']) => {
712
+ const selectLevel = (levelId: LevelNode['id'], measure = true) => {
713
+ if (measure && selectedLevelId !== levelId) markPerfAction('level-switch', levelId)
646
714
  setSelection(buildingId ? { buildingId, levelId } : { levelId })
647
715
  }
648
716
 
@@ -681,7 +749,7 @@ const LevelItem = memo(function LevelItem({
681
749
  )
682
750
  }
683
751
  createNodes(createOps)
684
- selectLevel(newLevelId as LevelNode['id'])
752
+ selectLevel(newLevelId as LevelNode['id'], false)
685
753
  setDuplicateDialogOpen(false)
686
754
  }
687
755
 
@@ -873,6 +941,17 @@ const LevelItem = memo(function LevelItem({
873
941
  initial={{ height: 0, opacity: 0 }}
874
942
  transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
875
943
  >
944
+ <div className="relative border-border/50 border-b py-2 pr-3 pl-[60px]">
945
+ <div className="pointer-events-none absolute top-0 bottom-0 left-[45px] z-10 w-px bg-border/50" />
946
+ <MetricControl
947
+ label="Base elevation"
948
+ onChange={(value) => updateNode(level.id, { baseElevation: value })}
949
+ precision={2}
950
+ step={0.05}
951
+ unit="m"
952
+ value={Math.round((level.baseElevation ?? 0) * 100) / 100}
953
+ />
954
+ </div>
876
955
  <LevelReferences
877
956
  isLastLevel={isLast}
878
957
  levelId={level.id}
@@ -1320,7 +1399,10 @@ const ContentSection = memo(function ContentSection() {
1320
1399
  if (!selectedLevelId) return []
1321
1400
  const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
1322
1401
  if (!lvl) return []
1323
- return lvl.children.filter((childId) => s.nodes[childId]?.type !== 'zone')
1402
+ return lvl.children.filter((childId) => {
1403
+ const type = s.nodes[childId]?.type
1404
+ return type !== 'zone' && type !== 'scan'
1405
+ })
1324
1406
  }),
1325
1407
  )
1326
1408
 
@@ -1,5 +1,5 @@
1
1
  import { type LevelNode, useScene } from '@pascal-app/core'
2
- import { useViewer } from '@pascal-app/viewer'
2
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
3
3
  import { Layers } from 'lucide-react'
4
4
  import { memo, useCallback, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
@@ -30,7 +30,10 @@ export const LevelTreeNode = memo(function LevelTreeNode({
30
30
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
31
31
  const setSelection = useViewer((state) => state.setSelection)
32
32
 
33
- const handleClick = useCallback(() => setSelection({ levelId: nodeId }), [nodeId, setSelection])
33
+ const handleClick = useCallback(() => {
34
+ if (!isSelected) markPerfAction('level-switch', nodeId)
35
+ setSelection({ levelId: nodeId })
36
+ }, [isSelected, nodeId, setSelection])
34
37
  const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
35
38
  const handleToggle = useCallback(() => setExpanded((prev) => !prev), [])
36
39
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
@@ -2,8 +2,9 @@ import { Icon as IconifyIcon } from '@iconify/react'
2
2
  import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
3
3
  import { useViewer } from '@pascal-app/viewer'
4
4
  import Image from 'next/image'
5
- import { memo, useCallback, useEffect, useState } from 'react'
5
+ import { memo, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
6
6
  import { useShallow } from 'zustand/react/shallow'
7
+ import { editorHostTreeChildrenRegistry } from '../../../../../lib/host-tree-children'
7
8
  import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
8
9
  import { InlineRenameInput } from './inline-rename-input'
9
10
  import {
@@ -43,9 +44,19 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
43
44
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
44
45
  const setSelection = useViewer((state) => state.setSelection)
45
46
  const setHoveredId = useViewer((state) => state.setHoveredId)
47
+ useSyncExternalStore(
48
+ editorHostTreeChildrenRegistry.subscribe,
49
+ editorHostTreeChildrenRegistry.getSnapshot,
50
+ editorHostTreeChildrenRegistry.getSnapshot,
51
+ )
46
52
 
47
53
  const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
48
54
  const tree = node ? nodeRegistry.get(node.type)?.tree : undefined
55
+ const hostChildren = node
56
+ ? editorHostTreeChildrenRegistry.childrenForKind(node.type)
57
+ : undefined
58
+ const hasHostChildren = Boolean(node && hostChildren?.hasChildren(node))
59
+ const HostChildren = hasHostChildren ? hostChildren?.component : undefined
49
60
  const icon = presentation?.icon
50
61
  const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
51
62
  const iconElement =
@@ -63,7 +74,7 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
63
74
  const snapTarget = resolveNodeSnapTarget(node)
64
75
  const defaultName =
65
76
  node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node'
66
- const hasChildren = children.length > 0
77
+ const hasChildren = children.length > 0 || hasHostChildren
67
78
 
68
79
  useEffect(() => {
69
80
  return useViewer.subscribe((state) => {
@@ -126,15 +137,21 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
126
137
  onMouseLeave={() => setHoveredId(null)}
127
138
  onToggle={() => setExpanded((prev) => !prev)}
128
139
  >
129
- {hasChildren &&
130
- children.map((childId, index) => (
131
- <TreeNode
132
- depth={depth + 1}
133
- isLast={index === children.length - 1}
134
- key={childId}
135
- nodeId={childId as AnyNodeId}
136
- />
137
- ))}
140
+ {hasChildren && (
141
+ <>
142
+ {children.map((childId, index) => (
143
+ <TreeNode
144
+ depth={depth + 1}
145
+ isLast={!hasHostChildren && index === children.length - 1}
146
+ key={childId}
147
+ nodeId={childId as AnyNodeId}
148
+ />
149
+ ))}
150
+ {HostChildren ? (
151
+ <HostChildren depth={depth + 1} nodeId={nodeId} parentVisible={isVisible} />
152
+ ) : null}
153
+ </>
154
+ )}
138
155
  </TreeNodeWrapper>
139
156
  )
140
157
  })
@@ -0,0 +1,8 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getTreeNodeComponent } from './tree-node'
3
+
4
+ describe('site tree node routing', () => {
5
+ test('renders plugin node kinds through the generic tree row', () => {
6
+ expect(getTreeNodeComponent('lean-to-extension')).toBe(getTreeNodeComponent('plugin-kind'))
7
+ })
8
+ })