@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
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { LocalAppError, probeLocalApp, sendGlbToLocalApp, waitForLocalImport } from './send-to-app'
3
+
4
+ type Handler = (url: string, init?: RequestInit) => Response | Promise<Response>
5
+
6
+ function fakeFetch(handler: Handler): typeof fetch {
7
+ return ((input: RequestInfo | URL, init?: RequestInit) =>
8
+ Promise.resolve(handler(String(input), init))) as typeof fetch
9
+ }
10
+
11
+ const json = (body: unknown, status = 200) =>
12
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
13
+
14
+ describe('probeLocalApp', () => {
15
+ test('reports unreachable when nothing answers on any port', async () => {
16
+ const tried: string[] = []
17
+ const probe = await probeLocalApp(
18
+ fakeFetch((url) => {
19
+ tried.push(url)
20
+ throw new TypeError('Failed to fetch')
21
+ }),
22
+ 27412,
23
+ 3,
24
+ )
25
+ expect(probe).toEqual({ status: 'unreachable' })
26
+ expect(tried).toEqual([
27
+ 'http://127.0.0.1:27412/pascal/health',
28
+ 'http://127.0.0.1:27413/pascal/health',
29
+ 'http://127.0.0.1:27414/pascal/health',
30
+ ])
31
+ })
32
+
33
+ test('finds the app on a fallback port and tells whether it allows this origin', async () => {
34
+ const health = { app: 'blender', version: '5.2.1', addon: '0.1.0', port: 27413, allowed: false }
35
+ const probe = await probeLocalApp(
36
+ fakeFetch((url) => {
37
+ if (url.startsWith('http://127.0.0.1:27413/')) return json(health)
38
+ throw new TypeError('Failed to fetch')
39
+ }),
40
+ )
41
+ expect(probe).toEqual({ status: 'refused', base: 'http://127.0.0.1:27413', health })
42
+ })
43
+ })
44
+
45
+ describe('sendGlbToLocalApp', () => {
46
+ test('posts the GLB with percent-encoded project headers', async () => {
47
+ let seen: RequestInit | undefined
48
+ const status = await sendGlbToLocalApp(
49
+ 'http://127.0.0.1:27412',
50
+ new Blob([new Uint8Array([1, 2, 3])]),
51
+ { name: 'Maison été', projectId: 'project_1' },
52
+ fakeFetch((_url, init) => {
53
+ seen = init
54
+ return json({ id: 'abc', state: 'queued' }, 202)
55
+ }),
56
+ )
57
+ expect(status).toEqual({ id: 'abc', state: 'queued' })
58
+ expect(seen?.method).toBe('POST')
59
+ expect(seen?.headers).toEqual({
60
+ 'Content-Type': 'model/gltf-binary',
61
+ 'X-Pascal-Project-Name': 'Maison%20%C3%A9t%C3%A9',
62
+ 'X-Pascal-Project-Id': 'project_1',
63
+ })
64
+ })
65
+
66
+ test('maps a 403 to a refused error', async () => {
67
+ await expect(
68
+ sendGlbToLocalApp(
69
+ 'http://127.0.0.1:27412',
70
+ new Blob([]),
71
+ {},
72
+ fakeFetch(() => json({ error: 'origin_not_allowed' }, 403)),
73
+ ),
74
+ ).rejects.toMatchObject({ name: 'LocalAppError', reason: 'refused' })
75
+ })
76
+ })
77
+
78
+ describe('waitForLocalImport', () => {
79
+ test('polls until the import is done', async () => {
80
+ let calls = 0
81
+ const status = await waitForLocalImport(
82
+ 'http://127.0.0.1:27412',
83
+ 'abc',
84
+ fakeFetch(() => {
85
+ calls += 1
86
+ return json(
87
+ calls < 3
88
+ ? { id: 'abc', state: 'queued' }
89
+ : { id: 'abc', state: 'done', summary: 'Imported' },
90
+ )
91
+ }),
92
+ { intervalMs: 1 },
93
+ )
94
+ expect(status.state).toBe('done')
95
+ expect(calls).toBe(3)
96
+ })
97
+
98
+ test('surfaces a failed import', async () => {
99
+ const error = await waitForLocalImport(
100
+ 'http://127.0.0.1:27412',
101
+ 'abc',
102
+ fakeFetch(() => json({ id: 'abc', state: 'failed', error: 'boom' })),
103
+ { intervalMs: 1 },
104
+ ).catch((e: unknown) => e)
105
+ expect(error).toBeInstanceOf(LocalAppError)
106
+ expect((error as LocalAppError).reason).toBe('failed')
107
+ })
108
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Hand a scene to a desktop app listening on loopback (the Pascal Blender add-on
3
+ * today). The app answers `GET /pascal/health`, accepts a GLB on
4
+ * `POST /pascal/import`, and reports the import on `GET /pascal/import/<id>`.
5
+ * Protocol reference: https://github.com/pascalorg/blender-addon#send-to-blender-from-the-editor
6
+ */
7
+
8
+ export const LOCAL_APP_PORT = 27412
9
+ export const LOCAL_APP_PORT_RANGE = 5
10
+ const PROBE_TIMEOUT_MS = 1000
11
+
12
+ export type LocalAppHealth = {
13
+ app: string
14
+ version: string
15
+ addon: string
16
+ port: number
17
+ allowed: boolean
18
+ }
19
+
20
+ export type LocalAppProbe =
21
+ | { status: 'listening'; base: string; health: LocalAppHealth }
22
+ | { status: 'refused'; base: string; health: LocalAppHealth }
23
+ | { status: 'unreachable' }
24
+
25
+ export type LocalImportStatus = {
26
+ id: string
27
+ state: 'queued' | 'done' | 'failed'
28
+ summary?: string
29
+ error?: string
30
+ }
31
+
32
+ export class LocalAppError extends Error {
33
+ constructor(
34
+ readonly reason: 'refused' | 'rejected' | 'failed' | 'timeout',
35
+ message: string,
36
+ ) {
37
+ super(message)
38
+ this.name = 'LocalAppError'
39
+ }
40
+ }
41
+
42
+ type FetchLike = typeof fetch
43
+
44
+ function isHealth(value: unknown): value is LocalAppHealth {
45
+ const health = value as Partial<LocalAppHealth> | null
46
+ return Boolean(health && typeof health.app === 'string' && typeof health.allowed === 'boolean')
47
+ }
48
+
49
+ /** Try the app's port and a few after it, one second each. */
50
+ export async function probeLocalApp(
51
+ fetchImpl: FetchLike = fetch,
52
+ port = LOCAL_APP_PORT,
53
+ range = LOCAL_APP_PORT_RANGE,
54
+ ): Promise<LocalAppProbe> {
55
+ for (let candidate = port; candidate < port + range; candidate++) {
56
+ const base = `http://127.0.0.1:${candidate}`
57
+ try {
58
+ const response = await fetchImpl(`${base}/pascal/health`, {
59
+ cache: 'no-store',
60
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
61
+ })
62
+ if (!response.ok) continue
63
+ const health: unknown = await response.json()
64
+ if (!isHealth(health)) continue
65
+ return { status: health.allowed ? 'listening' : 'refused', base, health }
66
+ } catch {
67
+ // Nothing listening there; try the next port.
68
+ }
69
+ }
70
+ return { status: 'unreachable' }
71
+ }
72
+
73
+ export type SendSceneMeta = {
74
+ name?: string
75
+ projectId?: string
76
+ version?: string
77
+ }
78
+
79
+ /** POST the GLB; resolves with the queued import id. */
80
+ export async function sendGlbToLocalApp(
81
+ base: string,
82
+ blob: Blob,
83
+ meta: SendSceneMeta = {},
84
+ fetchImpl: FetchLike = fetch,
85
+ ): Promise<LocalImportStatus> {
86
+ const headers: Record<string, string> = { 'Content-Type': 'model/gltf-binary' }
87
+ // Header values are Latin-1; the listener percent-decodes these.
88
+ if (meta.name) headers['X-Pascal-Project-Name'] = encodeURIComponent(meta.name)
89
+ if (meta.projectId) headers['X-Pascal-Project-Id'] = meta.projectId
90
+ if (meta.version) headers['X-Pascal-Version'] = meta.version
91
+ const response = await fetchImpl(`${base}/pascal/import`, { method: 'POST', headers, body: blob })
92
+ if (response.status === 403) {
93
+ throw new LocalAppError('refused', 'The app has not allowed this site to send scenes.')
94
+ }
95
+ if (!response.ok) {
96
+ throw new LocalAppError('rejected', `The app rejected the scene (HTTP ${response.status}).`)
97
+ }
98
+ return (await response.json()) as LocalImportStatus
99
+ }
100
+
101
+ /** Poll until the app has imported (or failed to import) the queued scene. */
102
+ export async function waitForLocalImport(
103
+ base: string,
104
+ id: string,
105
+ fetchImpl: FetchLike = fetch,
106
+ { timeoutMs = 120_000, intervalMs = 500 } = {},
107
+ ): Promise<LocalImportStatus> {
108
+ const deadline = Date.now() + timeoutMs
109
+ while (Date.now() < deadline) {
110
+ const response = await fetchImpl(`${base}/pascal/import/${id}`, { cache: 'no-store' })
111
+ if (response.ok) {
112
+ const status = (await response.json()) as LocalImportStatus
113
+ if (status.state === 'done') return status
114
+ if (status.state === 'failed') {
115
+ throw new LocalAppError('failed', status.error ?? 'The app could not import the scene.')
116
+ }
117
+ }
118
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
119
+ }
120
+ throw new LocalAppError('timeout', 'The app did not finish importing in time.')
121
+ }
@@ -1,3 +1,5 @@
1
+ import { sfxEmitter } from '../sfx-bus'
2
+
1
3
  const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1
2
4
 
3
5
  type MovementSfxStepKeyArgs = {
@@ -16,3 +18,38 @@ export function movementSfxStepKey({
16
18
  const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep
17
19
  return coords.map((coord) => Math.round(coord / step)).join(',')
18
20
  }
21
+
22
+ export function createMovementSfxTick() {
23
+ let previousKey: string | null = null
24
+ let pending: (() => void) | null = null
25
+ let timer: ReturnType<typeof setTimeout> | undefined
26
+ const tick = (args: MovementSfxStepKeyArgs, emitInitialStep = false) => {
27
+ const nextKey = movementSfxStepKey(args)
28
+ if (nextKey !== previousKey && (previousKey !== null || emitInitialStep)) {
29
+ sfxEmitter.emit('sfx:grid-snap')
30
+ }
31
+ previousKey = nextKey
32
+ }
33
+ const cancel = () => {
34
+ clearTimeout(timer)
35
+ timer = undefined
36
+ pending = null
37
+ }
38
+ const flush = () => {
39
+ const apply = pending
40
+ cancel()
41
+ apply?.()
42
+ }
43
+ return {
44
+ tick,
45
+ schedule(args: MovementSfxStepKeyArgs, shouldPlay: () => boolean, emitInitialStep = false) {
46
+ pending = () => {
47
+ if (shouldPlay()) tick(args, emitInitialStep)
48
+ }
49
+ // Wait for R3F host dispatch after the native grid listener, without delaying the move.
50
+ if (timer === undefined) timer = setTimeout(flush, 0)
51
+ },
52
+ flush,
53
+ cancel,
54
+ }
55
+ }
@@ -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,77 @@
1
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
2
+ import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
3
+ import { Euler, Quaternion, Vector3 } from 'three'
4
+
5
+ type Pose = { position: [number, number, number]; rotation: [number, number, number] }
6
+
7
+ export function surfaceAttachmentId(node: Pick<AnyNode, 'id' | 'parentId'>): string | null {
8
+ const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined
9
+ return isProceduralItem(parent) ? (parent.attachments[node.id] ?? null) : null
10
+ }
11
+
12
+ export function surfaceAttachmentUpdates(
13
+ id: AnyNodeId,
14
+ parentId: string | null | undefined,
15
+ surfaceId: string | null,
16
+ ) {
17
+ const nodes = useScene.getState().nodes
18
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
19
+ for (const hostId of new Set([nodes[id]?.parentId, parentId])) {
20
+ const host = hostId ? nodes[hostId as AnyNodeId] : undefined
21
+ if (!isProceduralItem(host)) continue
22
+ const nextId = host.id === parentId ? surfaceId : null
23
+ if ((host.attachments[id] ?? null) === nextId) continue
24
+ const attachments = { ...host.attachments }
25
+ if (nextId === null) delete attachments[id]
26
+ else attachments[id] = nextId
27
+ updates.push({ id: host.id, data: { attachments } })
28
+ }
29
+ return updates
30
+ }
31
+
32
+ export function updateSurfaceNode(
33
+ id: AnyNodeId,
34
+ data: Partial<AnyNode>,
35
+ surfaceId?: string | null,
36
+ ) {
37
+ const node = useScene.getState().nodes[id]
38
+ const parentId = data.parentId === undefined ? node?.parentId : data.parentId
39
+ const selected =
40
+ surfaceId === undefined && node && node.parentId === parentId
41
+ ? surfaceAttachmentId(node)
42
+ : (surfaceId ?? null)
43
+ useScene
44
+ .getState()
45
+ .updateNodes([{ id, data }, ...surfaceAttachmentUpdates(id, parentId, selected)])
46
+ }
47
+
48
+ export function surfaceFramePose(
49
+ parentId: string | null | undefined,
50
+ surfaceId: string | null,
51
+ pose: Pose,
52
+ toStorage: boolean,
53
+ ): Pose {
54
+ const host = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
55
+ if (!isProceduralItem(host) || surfaceId === null)
56
+ return { position: pose.position, rotation: pose.rotation }
57
+ const surface = evaluateRecipe(host.recipe, host.parameters).surfaces.find(
58
+ (s) => s.id === surfaceId,
59
+ )
60
+ if (!surface) throw new Error('Missing attachment surface')
61
+ const q = new Quaternion().setFromEuler(new Euler(...surface.rotation))
62
+ const position = new Vector3(...pose.position)
63
+ if (toStorage) position.sub(new Vector3(...surface.position)).applyQuaternion(q.clone().invert())
64
+ else position.applyQuaternion(q).add(new Vector3(...surface.position))
65
+ if (!surface.rotation[0] && !surface.rotation[2] && !pose.rotation[0] && !pose.rotation[2]) {
66
+ return {
67
+ position: position.toArray(),
68
+ rotation: [0, pose.rotation[1] + (toStorage ? -1 : 1) * surface.rotation[1], 0],
69
+ }
70
+ }
71
+ const rotation = new Euler().setFromQuaternion(
72
+ new Quaternion()
73
+ .setFromEuler(new Euler(...pose.rotation))
74
+ .premultiply(toStorage ? q.invert() : q),
75
+ )
76
+ return { position: position.toArray(), rotation: [rotation.x, rotation.y, rotation.z] }
77
+ }