@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
@@ -1,15 +1,22 @@
1
1
  import {
2
2
  type AnyNode,
3
+ type AnyNodeId,
3
4
  emitter,
4
5
  type ItemNode,
5
6
  nodeRegistry,
6
7
  resolveSelectionProxyId,
8
+ useScene,
7
9
  } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import useEditor from '../store/use-editor'
12
+ import { emitDeleteSFX } from './sfx-bus'
8
13
 
9
14
  export type SelectionModifierKeys = {
10
15
  meta: boolean
11
16
  ctrl: boolean
12
17
  shift: boolean
18
+ /** Alt alone: select one session-group member without expanding. */
19
+ alt: boolean
13
20
  }
14
21
 
15
22
  export type NodeSelectionTarget = {
@@ -18,6 +25,20 @@ export type NodeSelectionTarget = {
18
25
  }
19
26
 
20
27
  export function emitCanvasNodeSelection(node: AnyNode): void {
28
+ if (useEditor.getState().mode === 'delete') {
29
+ const scene = useScene.getState()
30
+ if (scene.readOnly) return
31
+
32
+ emitDeleteSFX(node.type)
33
+ scene.deleteNode(node.id as AnyNodeId)
34
+ if (node.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
35
+ useViewer.getState().setSelection({ selectedIds: [] })
36
+ if (useViewer.getState().hoveredId === node.id) {
37
+ useViewer.setState({ hoveredId: null })
38
+ }
39
+ return
40
+ }
41
+
21
42
  emitter.emit('selection:canvas-node-click', node)
22
43
  }
23
44
 
@@ -59,17 +80,19 @@ export function selectionModifiersFromEvent(
59
80
  metaKey?: boolean
60
81
  ctrlKey?: boolean
61
82
  shiftKey?: boolean
83
+ altKey?: boolean
62
84
  nativeEvent?: {
63
85
  metaKey?: boolean
64
86
  ctrlKey?: boolean
65
87
  shiftKey?: boolean
88
+ altKey?: boolean
66
89
  }
67
90
  } | null,
68
91
  fallback?: Partial<SelectionModifierKeys>,
69
92
  ): SelectionModifierKeys {
70
93
  const fromEvent = (
71
94
  key: keyof SelectionModifierKeys,
72
- eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey',
95
+ eventKey: 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey',
73
96
  ) => {
74
97
  if (typeof event?.[eventKey] === 'boolean') return event[eventKey]
75
98
  if (typeof event?.nativeEvent?.[eventKey] === 'boolean') return event.nativeEvent[eventKey]
@@ -80,6 +103,7 @@ export function selectionModifiersFromEvent(
80
103
  meta: fromEvent('meta', 'metaKey'),
81
104
  ctrl: fromEvent('ctrl', 'ctrlKey'),
82
105
  shift: fromEvent('shift', 'shiftKey'),
106
+ alt: fromEvent('alt', 'altKey'),
83
107
  }
84
108
  }
85
109
 
@@ -88,11 +112,14 @@ export function resolveSelectedIdsForNodeClick({
88
112
  currentSelectedIds,
89
113
  modifierKeys,
90
114
  nodeId,
115
+ expandIdsForNode,
91
116
  }: {
92
117
  baseSelectedIds?: readonly string[]
93
118
  currentSelectedIds: readonly string[]
94
119
  modifierKeys: SelectionModifierKeys
95
120
  nodeId: string
121
+ /** Session-group expand on plain click (not on modifier/Alt). */
122
+ expandIdsForNode?: (nodeId: string) => string[] | null
96
123
  }): string[] {
97
124
  if (isSelectionModifierActive(modifierKeys)) {
98
125
  const selectedIds = baseSelectedIds ?? currentSelectedIds
@@ -102,6 +129,12 @@ export function resolveSelectedIdsForNodeClick({
102
129
  return [...selectedIds, nodeId]
103
130
  }
104
131
 
132
+ if (modifierKeys.alt) {
133
+ return [nodeId]
134
+ }
135
+
136
+ const expanded = expandIdsForNode?.(nodeId)
137
+ if (expanded && expanded.length > 1) return expanded
105
138
  return [nodeId]
106
139
  }
107
140
 
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ canCreateSessionGroup,
4
+ createSessionGroup,
5
+ expandSessionGroupMembers,
6
+ liveSessionGroups,
7
+ nextSessionGroupId,
8
+ resetSessionGroupIdSerial,
9
+ type SessionSelectionGroup,
10
+ selectionIntersectsSessionGroup,
11
+ selectionMatchesSessionGroup,
12
+ ungroupSessionSelection,
13
+ } from './session-groups'
14
+
15
+ function group(id: string, memberIds: string[], label = id): SessionSelectionGroup {
16
+ return { id, memberIds, label }
17
+ }
18
+
19
+ describe('session-groups', () => {
20
+ test('createSessionGroup requires two live members', () => {
21
+ resetSessionGroupIdSerial()
22
+ expect(
23
+ createSessionGroup([], ['a'], { idFactory: () => 'g1', labelFactory: () => 'G1' }).created,
24
+ ).toBeNull()
25
+ })
26
+
27
+ test('create and expand and ungroup', () => {
28
+ resetSessionGroupIdSerial(0)
29
+ const { groups, created } = createSessionGroup([], ['a', 'b', 'c'], {
30
+ idFactory: () => 'g1',
31
+ labelFactory: () => 'Group 1',
32
+ })
33
+ expect(created?.memberIds).toEqual(['a', 'b', 'c'])
34
+ expect(expandSessionGroupMembers(groups, 'b')).toEqual(['b', 'a', 'c'])
35
+ expect(selectionMatchesSessionGroup(groups, ['a', 'b', 'c'])?.label).toBe('Group 1')
36
+ expect(canCreateSessionGroup(groups, ['a', 'b', 'c'])).toBe(false)
37
+ expect(canCreateSessionGroup(groups, ['a', 'b'])).toBe(true)
38
+ expect(ungroupSessionSelection(groups, ['a']).groups).toEqual([])
39
+ })
40
+
41
+ test('liveSessionGroups hides groups that fall under two live members', () => {
42
+ resetSessionGroupIdSerial(0)
43
+ expect(nextSessionGroupId()).toBe('session-group-1')
44
+ expect(liveSessionGroups([group('g1', ['a', 'gone'])], new Set(['a']))).toEqual([])
45
+ expect(liveSessionGroups([group('g1', ['a', 'b', 'gone'])], new Set(['a', 'b']))).toEqual([
46
+ group('g1', ['a', 'b']),
47
+ ])
48
+ expect(selectionIntersectsSessionGroup([group('g1', ['a', 'b'])], ['b'])).toBe(true)
49
+ })
50
+
51
+ test('a deleted member is excluded from reads but kept in membership for undo', () => {
52
+ const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
53
+
54
+ // `a` deleted: reads narrow to the survivors...
55
+ expect(expandSessionGroupMembers(groups, 'b', new Set(['b', 'c']))).toEqual(['b', 'c'])
56
+ // ...and `a` and `b` deleted drops the group below the floor, so it goes inert.
57
+ expect(expandSessionGroupMembers(groups, 'c', new Set(['c']))).toBeNull()
58
+ expect(selectionIntersectsSessionGroup(groups, ['c'], new Set(['c']))).toBe(false)
59
+
60
+ // Undo restores the nodes, and membership was never rewritten — so the whole
61
+ // group comes back. A destructive prune would have lost `a` (or the group).
62
+ expect(expandSessionGroupMembers(groups, 'a', new Set(['a', 'b', 'c']))).toEqual([
63
+ 'a',
64
+ 'b',
65
+ 'c',
66
+ ])
67
+ })
68
+
69
+ test('ungroup leaves inert groups alone so undo can revive them', () => {
70
+ const groups = [group('g1', ['a', 'b'], 'Pair')]
71
+ // Only `a` is live, so the group is invisible to the user — Ctrl+Shift+G on
72
+ // `a` must not dissolve what it cannot see.
73
+ const result = ungroupSessionSelection(groups, ['a'], new Set(['a']))
74
+ expect(result.dissolved).toEqual([])
75
+ expect(result.groups).toEqual(groups)
76
+ })
77
+
78
+ test('grouping a member of an existing group dissolves that group whole', () => {
79
+ resetSessionGroupIdSerial(0)
80
+ const groups = [group('g1', ['a', 'b', 'c'], 'Suite')]
81
+ const { groups: next, created } = createSessionGroup(groups, ['c', 'd'], {
82
+ idFactory: () => 'g2',
83
+ labelFactory: () => 'Group 2',
84
+ })
85
+ expect(created?.memberIds).toEqual(['c', 'd'])
86
+ expect(next).toEqual([group('g2', ['c', 'd'], 'Group 2')])
87
+ })
88
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Pure helpers for editor-only session selection groups.
3
+ * Not scene-graph nodes; not saved with the project.
4
+ * Ctrl/Cmd+G creates, Ctrl/Cmd+Shift+G dissolves; plain click expands.
5
+ *
6
+ * Stored membership is never rewritten to drop deleted nodes — `liveIds`
7
+ * filtering happens on every read instead. A destructive prune would make
8
+ * delete-then-undo lose the restored node's group, and would drop the group
9
+ * outright once it fell under the two-member floor.
10
+ */
11
+
12
+ export type SessionSelectionGroup = {
13
+ id: string
14
+ memberIds: readonly string[]
15
+ label: string
16
+ }
17
+
18
+ export type SessionGroupIdFactory = () => string
19
+
20
+ let sessionGroupSerial = 0
21
+
22
+ export function nextSessionGroupId(): string {
23
+ sessionGroupSerial += 1
24
+ return `session-group-${sessionGroupSerial}`
25
+ }
26
+
27
+ export function nextSessionGroupLabel(): string {
28
+ return `Group ${Math.max(1, sessionGroupSerial)}`
29
+ }
30
+
31
+ export function resetSessionGroupIdSerial(value = 0): void {
32
+ sessionGroupSerial = value
33
+ }
34
+
35
+ function uniquePreserveOrder(ids: readonly string[]): string[] {
36
+ const seen = new Set<string>()
37
+ const out: string[] = []
38
+ for (const id of ids) {
39
+ if (!id || seen.has(id)) continue
40
+ seen.add(id)
41
+ out.push(id)
42
+ }
43
+ return out
44
+ }
45
+
46
+ function isLive(id: string, liveIds?: ReadonlySet<string> | null): boolean {
47
+ if (!liveIds) return true
48
+ return liveIds.has(id)
49
+ }
50
+
51
+ function withLabel(
52
+ group: SessionSelectionGroup,
53
+ memberIds: readonly string[],
54
+ ): SessionSelectionGroup {
55
+ return {
56
+ id: group.id,
57
+ label: group.label || 'Group',
58
+ memberIds,
59
+ }
60
+ }
61
+
62
+ function liveMembers(group: SessionSelectionGroup, liveIds?: ReadonlySet<string> | null): string[] {
63
+ return uniquePreserveOrder(group.memberIds.filter((id) => isLive(id, liveIds)))
64
+ }
65
+
66
+ /**
67
+ * Read-time view: groups narrowed to their live members, dropping any that fall
68
+ * under two. A group below the floor is inert, not gone — deleting members never
69
+ * touches storage, so undo brings it back.
70
+ */
71
+ export function liveSessionGroups(
72
+ groups: readonly SessionSelectionGroup[],
73
+ liveIds?: ReadonlySet<string> | null,
74
+ ): SessionSelectionGroup[] {
75
+ const next: SessionSelectionGroup[] = []
76
+ for (const group of groups) {
77
+ const members = liveMembers(group, liveIds)
78
+ if (members.length < 2) continue
79
+ next.push(withLabel(group, members))
80
+ }
81
+ return next
82
+ }
83
+
84
+ export function createSessionGroup(
85
+ groups: readonly SessionSelectionGroup[],
86
+ memberIds: readonly string[],
87
+ options?: {
88
+ liveIds?: ReadonlySet<string> | null
89
+ idFactory?: SessionGroupIdFactory
90
+ labelFactory?: () => string
91
+ },
92
+ ): {
93
+ groups: SessionSelectionGroup[]
94
+ created: SessionSelectionGroup | null
95
+ alreadyGrouped: boolean
96
+ } {
97
+ const liveIds = options?.liveIds
98
+ const members = uniquePreserveOrder(memberIds.filter((id) => isLive(id, liveIds)))
99
+ if (members.length < 2) {
100
+ return { groups: [...groups], created: null, alreadyGrouped: false }
101
+ }
102
+
103
+ const memberSet = new Set(members)
104
+ const exact = liveSessionGroups(groups, liveIds).find(
105
+ (group) =>
106
+ group.memberIds.length === members.length && group.memberIds.every((id) => memberSet.has(id)),
107
+ )
108
+ if (exact) {
109
+ return { groups: [...groups], created: exact, alreadyGrouped: true }
110
+ }
111
+
112
+ // Grouping a selection takes its members out of whatever group they were in,
113
+ // dissolving that group whole rather than leaving a remnant.
114
+ const kept = groups.filter((group) => !group.memberIds.some((id) => memberSet.has(id)))
115
+ const id = (options?.idFactory ?? nextSessionGroupId)()
116
+ const label =
117
+ options?.labelFactory?.() ??
118
+ (options?.idFactory ? `Group ${kept.length + 1}` : nextSessionGroupLabel())
119
+ const created: SessionSelectionGroup = { id, label, memberIds: members }
120
+ return { groups: [...kept, created], created, alreadyGrouped: false }
121
+ }
122
+
123
+ export function ungroupSessionSelection(
124
+ groups: readonly SessionSelectionGroup[],
125
+ selectedIds: readonly string[],
126
+ liveIds?: ReadonlySet<string> | null,
127
+ ): { groups: SessionSelectionGroup[]; dissolved: SessionSelectionGroup[] } {
128
+ const selected = new Set(selectedIds)
129
+ if (selected.size === 0) {
130
+ return { groups: [...groups], dissolved: [] }
131
+ }
132
+
133
+ const kept: SessionSelectionGroup[] = []
134
+ const dissolved: SessionSelectionGroup[] = []
135
+ for (const group of groups) {
136
+ // Only a live-viable group is visible to the user, so only that can be
137
+ // dissolved. An inert one (members deleted) is left alone for undo.
138
+ const live = liveMembers(group, liveIds)
139
+ if (live.length >= 2 && live.some((id) => selected.has(id))) {
140
+ dissolved.push(withLabel(group, live))
141
+ } else {
142
+ kept.push(group)
143
+ }
144
+ }
145
+ return { groups: kept, dissolved }
146
+ }
147
+
148
+ export function expandSessionGroupMembers(
149
+ groups: readonly SessionSelectionGroup[],
150
+ nodeId: string,
151
+ liveIds?: ReadonlySet<string> | null,
152
+ ): string[] | null {
153
+ if (!nodeId) return null
154
+ for (const group of groups) {
155
+ if (!group.memberIds.includes(nodeId)) continue
156
+ const members = liveMembers(group, liveIds)
157
+ if (members.length < 2) return null
158
+ if (members[0] === nodeId) return members
159
+ return [nodeId, ...members.filter((id) => id !== nodeId)]
160
+ }
161
+ return null
162
+ }
163
+
164
+ export function selectionMatchesSessionGroup(
165
+ groups: readonly SessionSelectionGroup[],
166
+ selectedIds: readonly string[],
167
+ liveIds?: ReadonlySet<string> | null,
168
+ ): SessionSelectionGroup | null {
169
+ if (selectedIds.length < 2) return null
170
+ const selected = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
171
+ if (selected.length < 2) return null
172
+ const selectedSet = new Set(selected)
173
+ for (const group of liveSessionGroups(groups, liveIds)) {
174
+ if (group.memberIds.length !== selected.length) continue
175
+ if (group.memberIds.every((id) => selectedSet.has(id))) return group
176
+ }
177
+ return null
178
+ }
179
+
180
+ export function selectionIntersectsSessionGroup(
181
+ groups: readonly SessionSelectionGroup[],
182
+ selectedIds: readonly string[],
183
+ liveIds?: ReadonlySet<string> | null,
184
+ ): boolean {
185
+ if (selectedIds.length === 0) return false
186
+ const selected = new Set(selectedIds)
187
+ for (const group of liveSessionGroups(groups, liveIds)) {
188
+ if (group.memberIds.some((id) => selected.has(id))) return true
189
+ }
190
+ return false
191
+ }
192
+
193
+ export function canCreateSessionGroup(
194
+ groups: readonly SessionSelectionGroup[],
195
+ selectedIds: readonly string[],
196
+ liveIds?: ReadonlySet<string> | null,
197
+ ): boolean {
198
+ const live = uniquePreserveOrder(selectedIds.filter((id) => isLive(id, liveIds)))
199
+ if (live.length < 2) return false
200
+ return selectionMatchesSessionGroup(groups, live, liveIds) === null
201
+ }
@@ -27,6 +27,7 @@ type SFXEvents = {
27
27
  'sfx:menu-hover': undefined
28
28
  'sfx:menu-click': undefined
29
29
  'sfx:paint-apply': undefined
30
+ 'sfx:success': undefined
30
31
  'sfx:terrain-sculpt-start': TerrainVerb
31
32
  'sfx:terrain-sculpt-stop': undefined
32
33
  }
@@ -56,6 +57,7 @@ const handleSnapshotCapture = () => playSFX('snapshotCapture')
56
57
  const handleMenuHover = () => playSFX('menuHover')
57
58
  const handleMenuClick = () => playSFX('menuClick')
58
59
  const handlePaintApply = () => playSFX('paintApply')
60
+ const handleSuccess = () => playSFX('success')
59
61
  const TERRAIN_LOOP_BY_VERB = {
60
62
  raise: 'terrainRaise',
61
63
  lower: 'terrainLower',
@@ -86,6 +88,7 @@ export function initSFXBus() {
86
88
  sfxEmitter.on('sfx:menu-hover', handleMenuHover)
87
89
  sfxEmitter.on('sfx:menu-click', handleMenuClick)
88
90
  sfxEmitter.on('sfx:paint-apply', handlePaintApply)
91
+ sfxEmitter.on('sfx:success', handleSuccess)
89
92
  sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
90
93
  sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
91
94
  unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
@@ -106,6 +109,7 @@ export function disposeSFXBus() {
106
109
  sfxEmitter.off('sfx:menu-hover', handleMenuHover)
107
110
  sfxEmitter.off('sfx:menu-click', handleMenuClick)
108
111
  sfxEmitter.off('sfx:paint-apply', handlePaintApply)
112
+ sfxEmitter.off('sfx:success', handleSuccess)
109
113
  sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
110
114
  sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
111
115
  unsubscribeAudio?.()
@@ -112,6 +112,14 @@ export const SFX: Record<string, SFXConfig> = {
112
112
  volumeRange: [0.85, 1.0],
113
113
  minIntervalMs: 60,
114
114
  },
115
+ // A three-second jingle for finishing something, not a click cue. No pitch or
116
+ // volume jitter — a fanfare that lands a semitone off reads as broken rather
117
+ // than as varied — and a gap longer than the sound itself, so two milestones
118
+ // that land together play once instead of phasing over each other.
119
+ success: {
120
+ src: '/audios/sfx/success.mp3',
121
+ minIntervalMs: 3_000,
122
+ },
115
123
  } as const
116
124
 
117
125
  export type SFXName = keyof typeof SFX
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
+ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
3
3
  import {
4
4
  cycleSnappingModeIn,
5
5
  DEFAULT_SNAPPING_MODE,
@@ -49,12 +49,24 @@ describe('resolveSnapFlags', () => {
49
49
  })
50
50
 
51
51
  describe('per-context snapping', () => {
52
- it('items default to grid with no angle lock', () => {
53
- expect(defaultSnappingModeFor('item')).toBe('grid')
52
+ it('items default to magnetic alignment with no angle lock', () => {
53
+ expect(defaultSnappingModeFor('item')).toBe('lines')
54
54
  expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
55
55
  expect(snappingModesFor('item')).not.toContain('angles')
56
56
  })
57
57
 
58
+ it('group moves use the magnetic item context by default', () => {
59
+ const context = snapContextOf({
60
+ scope: { kind: 'handle-drag', handle: GROUP_MOVE_DRAG_LABEL },
61
+ mode: 'select',
62
+ tool: null,
63
+ profileOf: () => undefined,
64
+ })
65
+
66
+ expect(context).toBe('item')
67
+ expect(resolveSnapFlags(defaultSnappingModeFor(context!)).magnetic).toBe(true)
68
+ })
69
+
58
70
  it('walls default to grid and expose the angle lock; polygons do NOT', () => {
59
71
  expect(defaultSnappingModeFor('wall')).toBe('grid')
60
72
  expect(defaultSnappingModeFor('polygon')).toBe('grid')
@@ -83,10 +95,17 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
83
95
  ceiling: 'structural',
84
96
  roof: 'structural',
85
97
  zone: 'structural',
98
+ block: 'structural',
86
99
  }
87
100
  const profileOf = (t: string) => declared[t]
88
101
  const profileOfNode = (id: string) =>
89
- id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined
102
+ id === 'cabinet-module_1'
103
+ ? declared.item
104
+ : id === 'wall_1'
105
+ ? declared.wall
106
+ : id === 'block_1'
107
+ ? declared['block']
108
+ : undefined
90
109
  const ctx = (
91
110
  scope: {
92
111
  kind: string
@@ -95,6 +114,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
95
114
  nodeId?: string
96
115
  tool?: string
97
116
  handle?: string
117
+ operator?: string
98
118
  },
99
119
  mode = 'select',
100
120
  tool: string | null = null,
@@ -115,6 +135,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
115
135
  ).toBeNull()
116
136
  })
117
137
 
138
+ it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
139
+ expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1' })).toBe('polygon')
140
+ expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'translate' })).toBe('polygon')
141
+ expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'scale' })).toBe('polygon')
142
+ expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'loop-cut' })).toBe('polygon')
143
+ expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'rotate' })).toBe('wall')
144
+ })
145
+
118
146
  it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
119
147
  expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
120
148
  expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
@@ -4,10 +4,9 @@ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-he
4
4
  /**
5
5
  * Snapping mode is a single global, user-cyclable control that maps onto the
6
6
  * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
7
- * The default `'grid'` resolves to the exact pair the editor shipped with
8
- * before this control existed (grid on, magnetic on), so the default path is
9
- * behaviourally unchanged only when a user opts into `'lines'` or `'off'`
10
- * does any snap math get suppressed.
7
+ * Each context chooses its own default. Item movement defaults to magnetic
8
+ * alignment so a picked-up group catches neighboring geometry; grid and off
9
+ * remain explicit alternatives in the contextual chip.
11
10
  */
12
11
  export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
13
12
 
@@ -82,9 +81,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
82
81
  const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
83
82
  // Wall / fence drafting + endpoint reshape: direction matters → angle lock.
84
83
  wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
85
- // Item placement / move: grid by default; lines = magnetic alignment only (no
86
- // grid lattice), no angle lock (meaningless for a footprint).
87
- item: { modes: ['lines', 'grid', 'off'], default: 'grid' },
84
+ // Item placement / move: magnetic alignment by default; grid is an explicit
85
+ // alternative, and angle lock is meaningless for a footprint.
86
+ item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
88
87
  // Structural / surface, no direction to set: slab / ceiling / roof draft+move,
89
88
  // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
90
89
  // default, NO angle lock.
@@ -148,6 +147,7 @@ export function snapContextOf(args: {
148
147
  nodeId?: string
149
148
  tool?: string
150
149
  handle?: string
150
+ operator?: string
151
151
  }
152
152
  mode: string
153
153
  tool: string | null
@@ -166,6 +166,10 @@ export function snapContextOf(args: {
166
166
  return 'item'
167
167
  }
168
168
  switch (scope.kind) {
169
+ case 'mesh-editing':
170
+ return scope.nodeId
171
+ ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
172
+ : null
169
173
  case 'handle-drag':
170
174
  if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
171
175
  return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+
3
+ const native = globalThis.structuredClone
4
+
5
+ afterEach(() => {
6
+ globalThis.structuredClone = native
7
+ })
8
+
9
+ async function installFallback() {
10
+ // Fresh module instance each time — the shim is a module side effect.
11
+ await import(`./structured-clone-fallback?${Math.random()}`)
12
+ }
13
+
14
+ describe('structuredClone fallback', () => {
15
+ test('leaves a native implementation alone', async () => {
16
+ await installFallback()
17
+ expect(globalThis.structuredClone).toBe(native)
18
+ })
19
+
20
+ test('installs a JSON-based clone when the global is missing', async () => {
21
+ // @ts-expect-error — simulating a browser without the global.
22
+ globalThis.structuredClone = undefined
23
+ await installFallback()
24
+
25
+ const source = { units: [{ factor: 1, id: 'm' }], kind: 'length' }
26
+ const copy = structuredClone(source)
27
+
28
+ expect(copy).toEqual(source)
29
+ expect(copy).not.toBe(source)
30
+ expect(copy.units).not.toBe(source.units)
31
+ })
32
+
33
+ test('lets lingo build its registry without the global', async () => {
34
+ // @ts-expect-error — simulating a browser without the global.
35
+ globalThis.structuredClone = undefined
36
+ await installFallback()
37
+
38
+ // The actual regression: lingo clones its kind table at module-eval time,
39
+ // so this import throws on a browser lacking structuredClone.
40
+ const { parseQuantity } = await import('@pascal-app/lingo')
41
+ const result = parseQuantity('180cm', { kind: 'length', unit: 'm' })
42
+ expect(result.ok).toBe(true)
43
+ if (result.ok) expect(result.quantity.to('m').value).toBeCloseTo(1.8, 6)
44
+ })
45
+ })
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@pascal-app/lingo` builds its unit registry at module-eval time, and
3
+ * `registerKind` deep-copies each kind definition with `structuredClone`. On a
4
+ * browser without that global (Chromium <98 — reported from Honor Browser 9.8
5
+ * as `ReferenceError: structuredClone is not defined`) the throw happens while
6
+ * the module graph is still evaluating, so a component-level guard can never
7
+ * run in time and the whole editor bundle fails to load.
8
+ *
9
+ * This has to be imported for its side effect from the module that pulls lingo
10
+ * in, so it is installed wherever `@pascal-app/editor` is loaded — the OSS app,
11
+ * the hosted app, and any npm consumer. An app-level polyfill would only cover
12
+ * the app that declares it.
13
+ *
14
+ * Scoped deliberately narrowly: the kind definitions lingo clones are plain
15
+ * JSON (strings, numbers, arrays of those), so a JSON round-trip is sufficient
16
+ * and avoids a dependency. This is NOT a spec-compliant `structuredClone` — it
17
+ * has no support for Map/Set/Date/ArrayBuffer/cycles and throws a `TypeError`
18
+ * rather than a `DataCloneError`. Anything needing real structured-clone
19
+ * semantics must not rely on this shim.
20
+ */
21
+ if (typeof globalThis.structuredClone !== 'function') {
22
+ globalThis.structuredClone = (<T>(value: T): T =>
23
+ JSON.parse(JSON.stringify(value)) as T) as typeof structuredClone
24
+ }
@@ -0,0 +1,38 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+ import type { Object3D } from 'three'
3
+ import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
4
+ import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
5
+ import { type GlbExportOptions, preparePortableSceneFromViewer } from './glb-export'
6
+ import { createUsdzScene, disposeExportResources } from './portable-export'
7
+
8
+ export type UsdzExportOptions = Pick<
9
+ GlbExportOptions,
10
+ 'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning'
11
+ >
12
+
13
+ /** Export a native, self-contained USDZ with no glTF conversion fallback. */
14
+ export async function exportSceneToUsdz(
15
+ sceneGroup: Object3D,
16
+ nodes: Record<string, AnyNode>,
17
+ options: UsdzExportOptions = {},
18
+ ): Promise<Uint8Array<ArrayBuffer>> {
19
+ const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, {
20
+ ...options,
21
+ textures: 'embed',
22
+ })
23
+ for (const warning of prepared.warnings) options.onWarning?.(warning)
24
+
25
+ let scene: Object3D | null = null
26
+ try {
27
+ scene = createUsdzScene(prepared.scene)
28
+ const exporter = new USDZExporter()
29
+ exporter.textureUtils = WebGPUTextureUtils
30
+ return await exporter.parseAsync(scene, {
31
+ onlyVisible: options.onlyVisible ?? true,
32
+ quickLookCompatible: true,
33
+ })
34
+ } finally {
35
+ if (scene) disposeExportResources(scene, { textures: false })
36
+ prepared.dispose()
37
+ }
38
+ }