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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,5 +1,4 @@
1
1
  import { Keyboard } from 'lucide-react'
2
- import { useEffect, useState } from 'react'
3
2
  import { Button } from './../../../../../components/ui/primitives/button'
4
3
  import {
5
4
  Dialog,
@@ -9,7 +8,10 @@ import {
9
8
  DialogTitle,
10
9
  DialogTrigger,
11
10
  } from './../../../../../components/ui/primitives/dialog'
12
- import { ShortcutToken } from './../../../../../components/ui/primitives/shortcut-token'
11
+ import {
12
+ ShortcutToken,
13
+ shortcutDisplayValue,
14
+ } from './../../../../../components/ui/primitives/shortcut-token'
13
15
 
14
16
  type Shortcut = {
15
17
  keys: string[]
@@ -22,14 +24,6 @@ type ShortcutCategory = {
22
24
  shortcuts: Shortcut[]
23
25
  }
24
26
 
25
- const KEY_DISPLAY_MAP: Record<string, string> = {
26
- 'Arrow Up': '↑',
27
- 'Arrow Down': '↓',
28
- Esc: '⎋',
29
- Shift: '⇧',
30
- Space: '␣',
31
- }
32
-
33
27
  const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
34
28
  {
35
29
  title: 'Editor Navigation',
@@ -60,10 +54,12 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
60
54
  {
61
55
  keys: ['Esc'],
62
56
  action: 'Cancel the active tool and return to Select mode',
57
+ note: 'Mid-draw it cancels only the chain in progress and keeps the tool armed; press it again to leave the tool.',
63
58
  },
64
59
  { keys: ['Delete / Backspace'], action: 'Delete selected objects' },
65
60
  { keys: ['Cmd/Ctrl', 'Z'], action: 'Undo' },
66
61
  { keys: ['Cmd/Ctrl', 'Shift', 'Z'], action: 'Redo' },
62
+ { keys: ['Cmd/Ctrl', 'S'], action: 'Save' },
67
63
  ],
68
64
  },
69
65
  {
@@ -147,6 +143,19 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
147
143
  {
148
144
  title: 'Drawing Tools',
149
145
  shortcuts: [
146
+ // Shift and Ctrl each mean one thing held and another tapped, and only
147
+ // the hold was documented — which read as the taps not existing. Both
148
+ // taps are listed first because they are the ones nobody discovers.
149
+ {
150
+ keys: ['Shift'],
151
+ action: 'Cycle the snapping mode',
152
+ note: 'Tap and release without pressing anything else, while a drawing or move gesture is available.',
153
+ },
154
+ {
155
+ keys: ['Cmd/Ctrl'],
156
+ action: 'Cycle the grid step: 0.5 m → 0.25 m → 0.1 m → 0.05 m',
157
+ note: 'Tap and release on its own. Use it when the default half-metre grid is too coarse — placing a window, for instance.',
158
+ },
150
159
  {
151
160
  keys: ['Shift'],
152
161
  action: 'Bypass guided snapping and angle constraints',
@@ -199,25 +208,13 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
199
208
  },
200
209
  ]
201
210
 
202
- function getDisplayKey(key: string, isMac: boolean): string {
203
- if (key === 'Cmd/Ctrl') return isMac ? '⌘' : 'Ctrl'
204
- if (key === 'Delete / Backspace') return isMac ? '⌫' : 'Backspace'
205
- return KEY_DISPLAY_MAP[key] ?? key
206
- }
207
-
208
211
  function ShortcutKeys({ keys }: { keys: string[] }) {
209
- const [isMac, setIsMac] = useState(true)
210
-
211
- useEffect(() => {
212
- setIsMac(navigator.platform.toUpperCase().indexOf('MAC') >= 0)
213
- }, [])
214
-
215
212
  return (
216
213
  <div className="flex flex-wrap items-center gap-1">
217
214
  {keys.map((key, index) => (
218
215
  <div className="flex items-center gap-1" key={`${key}-${index}`}>
219
216
  {index > 0 ? <span className="text-[10px] text-muted-foreground">+</span> : null}
220
- <ShortcutToken displayValue={getDisplayKey(key, isMac)} value={key} />
217
+ <ShortcutToken displayValue={shortcutDisplayValue(key)} value={key} />
221
218
  </div>
222
219
  ))}
223
220
  </div>
@@ -0,0 +1,167 @@
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, 'print-3mf')
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('uses the same safe profile for printable STL files', async () => {
62
+ const calls: { format?: string; options?: ModelExportOptions }[] = []
63
+ const stlReport: PrintExportReport = { ...report, format: 'stl' }
64
+ const artifact = { blob: new Blob(['stl']), filename: 'house.zip', metadata: stlReport }
65
+ const modelExport: ModelExport = async (format, options) => {
66
+ calls.push({ format, options })
67
+ return artifact
68
+ }
69
+
70
+ const prepared = await preparePrintExport(modelExport, false, 'print-stl')
71
+
72
+ expect(calls).toEqual([
73
+ {
74
+ format: 'print-stl',
75
+ options: {
76
+ onlyVisible: false,
77
+ download: false,
78
+ printScale: 100,
79
+ printScope: 'levels',
80
+ printContent: 'structure',
81
+ printBase: 'none',
82
+ },
83
+ },
84
+ ])
85
+ expect(prepared).toEqual({ artifact, report: stlReport })
86
+ })
87
+
88
+ test('blocks the download when preflight finds invalid geometry', async () => {
89
+ const blockedReport: PrintExportReport = {
90
+ ...report,
91
+ status: 'blocked',
92
+ diagnostics: [
93
+ {
94
+ severity: 'error',
95
+ code: 'open_boundary',
96
+ message: 'One wall has an open edge.',
97
+ },
98
+ ],
99
+ }
100
+ const modelExport: ModelExport = async () => ({
101
+ blob: new Blob(['3mf']),
102
+ filename: 'house.3mf',
103
+ metadata: blockedReport,
104
+ })
105
+
106
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
107
+ 'One wall has an open edge.',
108
+ )
109
+ })
110
+
111
+ test('shows a per-level preflight error when the bundle has no top-level error', async () => {
112
+ const blockedPartReport: PrintExportReport = {
113
+ ...report,
114
+ status: 'blocked',
115
+ diagnostics: [
116
+ {
117
+ severity: 'error',
118
+ code: 'open_boundary',
119
+ message: 'The upper level has an open edge.',
120
+ },
121
+ ],
122
+ }
123
+ const blockedBundleReport: PrintLevelBundleReport = {
124
+ kind: 'print-level-export-report',
125
+ version: 2,
126
+ format: '3mf',
127
+ scale: 100,
128
+ units: 'millimeter',
129
+ orientation: 'z-up',
130
+ status: 'blocked',
131
+ partCount: 1,
132
+ parts: [
133
+ {
134
+ kind: 'level',
135
+ levelId: 'upper-level',
136
+ label: 'Upper level',
137
+ objectName: 'Upper level',
138
+ filename: null,
139
+ sourceBaseMeters: 3,
140
+ report: blockedPartReport,
141
+ },
142
+ ],
143
+ excludedNodeIds: [],
144
+ diagnostics: [],
145
+ }
146
+ const modelExport: ModelExport = async () => ({
147
+ blob: new Blob(['3mf']),
148
+ filename: 'house.zip',
149
+ metadata: blockedBundleReport,
150
+ })
151
+
152
+ await expect(preparePrintExport(modelExport, true, 'print-3mf')).rejects.toThrow(
153
+ 'The upper level has an open edge.',
154
+ )
155
+ })
156
+
157
+ test('rejects an exporter response without print metadata', async () => {
158
+ const modelExport: ModelExport = async () => ({
159
+ blob: new Blob(['3mf']),
160
+ filename: 'house.3mf',
161
+ })
162
+
163
+ await expect(preparePrintExport(modelExport, false, 'print-3mf')).rejects.toThrow(
164
+ 'did not return a valid file',
165
+ )
166
+ })
167
+ })
@@ -0,0 +1,128 @@
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 {
9
+ ModelExport,
10
+ ModelExportArtifact,
11
+ ModelExportFormat,
12
+ } from '../../../../../lib/model-export'
13
+ import {
14
+ isPrintExportReport,
15
+ type PrintExportReport,
16
+ } from '../../../../../lib/print-export'
17
+ import useEditor from '../../../../../store/use-editor'
18
+
19
+ type PreparedPrintExport = {
20
+ artifact: ModelExportArtifact
21
+ report: PrintExportReport | PrintLevelBundleReport
22
+ }
23
+
24
+ type PrintModelExportFormat = Extract<ModelExportFormat, 'print-3mf' | 'print-stl'>
25
+
26
+ function downloadArtifact(artifact: ModelExportArtifact) {
27
+ const url = URL.createObjectURL(artifact.blob)
28
+ const link = document.createElement('a')
29
+ link.href = url
30
+ link.download = artifact.filename
31
+ link.click()
32
+ URL.revokeObjectURL(url)
33
+ }
34
+
35
+ function firstBlockingMessage(report: PrintExportReport | PrintLevelBundleReport) {
36
+ const bundleDiagnostic = report.diagnostics.find((item) => item.severity === 'error')
37
+ if (bundleDiagnostic || !isPrintLevelBundleReport(report)) return bundleDiagnostic?.message
38
+
39
+ for (const part of report.parts) {
40
+ const partDiagnostic = part.report.diagnostics.find((item) => item.severity === 'error')
41
+ if (partDiagnostic) return partDiagnostic.message
42
+ }
43
+ }
44
+
45
+ export async function preparePrintExport(
46
+ modelExport: ModelExport,
47
+ onlyVisible: boolean,
48
+ format: PrintModelExportFormat,
49
+ ): Promise<PreparedPrintExport> {
50
+ const artifact = await modelExport(format, {
51
+ onlyVisible,
52
+ download: false,
53
+ printScale: 100,
54
+ printScope: 'levels',
55
+ printContent: 'structure',
56
+ printBase: 'none',
57
+ })
58
+
59
+ if (
60
+ !artifact ||
61
+ (!isPrintExportReport(artifact.metadata) && !isPrintLevelBundleReport(artifact.metadata))
62
+ ) {
63
+ throw new Error('The 3D print exporter did not return a valid file.')
64
+ }
65
+
66
+ if (artifact.metadata.status === 'blocked') {
67
+ throw new Error(
68
+ firstBlockingMessage(artifact.metadata) ??
69
+ 'This project cannot be exported as printable parts.',
70
+ )
71
+ }
72
+
73
+ return { artifact, report: artifact.metadata }
74
+ }
75
+
76
+ export function PrintExportButton({ onlyVisible }: { onlyVisible: boolean }) {
77
+ const modelExport = useEditor((state) => state.modelExport)
78
+ const [exportingFormat, setExportingFormat] = useState<PrintModelExportFormat | null>(null)
79
+ const [error, setError] = useState<string | null>(null)
80
+
81
+ const handleExport = async (format: PrintModelExportFormat) => {
82
+ if (!modelExport) return
83
+
84
+ setExportingFormat(format)
85
+ setError(null)
86
+ try {
87
+ const prepared = await preparePrintExport(modelExport, onlyVisible, format)
88
+ downloadArtifact(prepared.artifact)
89
+ } catch (reason) {
90
+ setError(reason instanceof Error ? reason.message : '3D print export failed.')
91
+ } finally {
92
+ setExportingFormat(null)
93
+ }
94
+ }
95
+
96
+ const isExporting = exportingFormat !== null
97
+
98
+ return (
99
+ <>
100
+ <Button
101
+ aria-busy={exportingFormat === 'print-3mf'}
102
+ className="w-full justify-start gap-2"
103
+ disabled={isExporting || !modelExport}
104
+ onClick={() => void handleExport('print-3mf')}
105
+ variant="outline"
106
+ >
107
+ <Printer className="size-4" />
108
+ Export 3D print 3MF
109
+ </Button>
110
+ <Button
111
+ aria-busy={exportingFormat === 'print-stl'}
112
+ className="w-full justify-start gap-2"
113
+ disabled={isExporting || !modelExport}
114
+ onClick={() => void handleExport('print-stl')}
115
+ variant="outline"
116
+ >
117
+ <Printer className="size-4" />
118
+ Export 3D print STL
119
+ </Button>
120
+ {error && (
121
+ <div className="flex gap-2 text-destructive text-xs">
122
+ <AlertTriangle className="mt-0.5 size-4 shrink-0" />
123
+ <span>{error}</span>
124
+ </div>
125
+ )}
126
+ </>
127
+ )
128
+ }
@@ -26,8 +26,15 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
26
26
  const createNode = useScene((state) => state.createNode)
27
27
  const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
28
28
  const name = useScene((s) => s.nodes[nodeId]?.name)
29
+ // Levels and elevators first, units after, so the units read as one group.
29
30
  const children = useScene(
30
- useShallow((s) => (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []),
31
+ useShallow((s) => {
32
+ const ids = (s.nodes[nodeId] as BuildingNode | undefined)?.children ?? []
33
+ return [
34
+ ...ids.filter((id) => s.nodes[id]?.type !== 'unit'),
35
+ ...ids.filter((id) => s.nodes[id]?.type === 'unit'),
36
+ ]
37
+ }),
31
38
  )
32
39
  const isSelected = useViewer((state) => state.selection.buildingId === nodeId)
33
40
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
@@ -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
  })