@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,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,6 +51,7 @@ 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'
@@ -350,8 +354,25 @@ const ReferenceItem = memo(function ReferenceItem({
350
354
  handleDelete: (id: string, e: React.MouseEvent) => void
351
355
  }) {
352
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
+
353
373
  const handleSelect = () => {
354
374
  setSelectedReferenceId(refNode.id)
375
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
355
376
  }
356
377
 
357
378
  const handleDoubleClick = () => {
@@ -359,53 +380,98 @@ const ReferenceItem = memo(function ReferenceItem({
359
380
  }
360
381
 
361
382
  return (
362
- <div
363
- 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"
364
- onClick={handleSelect}
365
- onDoubleClick={handleDoubleClick}
366
- >
383
+ <div className="relative flex flex-col">
367
384
  <div
368
385
  className={cn(
369
- 'pointer-events-none absolute z-10 w-px bg-border/50',
370
- 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',
371
389
  )}
372
- style={{ left: 45 }}
373
- />
374
- <div
375
- className="pointer-events-none absolute top-1/2 z-10 h-px bg-border/50"
376
- style={{ left: 45, width: 8 }}
377
- />
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
+ />
378
404
 
379
- <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">
380
- {refNode.type === 'scan' ? (
381
- <img
382
- alt="Scan"
383
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
384
- src="/icons/mesh.webp"
385
- />
386
- ) : (
387
- <img
388
- alt="Guide"
389
- className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
390
- 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)}
391
447
  />
392
- )}
393
- <InlineRenameInput
394
- defaultName={refNode.type === 'scan' ? '3D Scan' : 'Guide Image'}
395
- isEditing={isEditing}
396
- nodeId={refNode.id}
397
- onStartEditing={() => setIsEditing(true)}
398
- onStopEditing={() => setIsEditing(false)}
399
- />
400
- </div>
448
+ </div>
401
449
 
402
- <button
403
- 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"
404
- onClick={(e) => handleDelete(refNode.id, e)}
405
- title="Delete"
406
- >
407
- <Trash2 className="h-3 w-3" />
408
- </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}
409
475
  </div>
410
476
  )
411
477
  })
@@ -643,7 +709,8 @@ const LevelItem = memo(function LevelItem({
643
709
  ? (level.parentId as BuildingNode['id'])
644
710
  : undefined
645
711
 
646
- const selectLevel = (levelId: LevelNode['id']) => {
712
+ const selectLevel = (levelId: LevelNode['id'], measure = true) => {
713
+ if (measure && selectedLevelId !== levelId) markPerfAction('level-switch', levelId)
647
714
  setSelection(buildingId ? { buildingId, levelId } : { levelId })
648
715
  }
649
716
 
@@ -682,7 +749,7 @@ const LevelItem = memo(function LevelItem({
682
749
  )
683
750
  }
684
751
  createNodes(createOps)
685
- selectLevel(newLevelId as LevelNode['id'])
752
+ selectLevel(newLevelId as LevelNode['id'], false)
686
753
  setDuplicateDialogOpen(false)
687
754
  }
688
755
 
@@ -1332,7 +1399,10 @@ const ContentSection = memo(function ContentSection() {
1332
1399
  if (!selectedLevelId) return []
1333
1400
  const lvl = s.nodes[selectedLevelId] as LevelNode | undefined
1334
1401
  if (!lvl) return []
1335
- 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
+ })
1336
1406
  }),
1337
1407
  )
1338
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
+ })