@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ isRegionBigEnough,
4
+ rankInRegion,
5
+ rectFromDrag,
6
+ regionFractions,
7
+ regionSizeMetres,
8
+ } from './region'
9
+
10
+ // A region is a view, not a selection (spec 2.3): a rectangle dragged on the canvas, its size in
11
+ // metres shown live, what lies inside it ranked for the agent. Written first: a rectangle dragged
12
+ // right to left coming out inverted; a rectangle past the canvas edge; a speck of a drag taken as
13
+ // a region; a size measured through a ray that points at the sky; the ranking leaning on elements
14
+ // that merely touch the rectangle.
15
+
16
+ const canvas = { x0: 0, y0: 0, x1: 1000, y1: 600 }
17
+
18
+ describe('rectFromDrag', () => {
19
+ test('is the same rectangle whichever corner the drag starts from', () => {
20
+ const rect = { x0: 100, y0: 80, x1: 300, y1: 200 }
21
+
22
+ expect(rectFromDrag({ x: 100, y: 80 }, { x: 300, y: 200 }, canvas)).toEqual(rect)
23
+ expect(rectFromDrag({ x: 300, y: 200 }, { x: 100, y: 80 }, canvas)).toEqual(rect)
24
+ expect(rectFromDrag({ x: 300, y: 80 }, { x: 100, y: 200 }, canvas)).toEqual(rect)
25
+ })
26
+
27
+ test('is kept inside the canvas', () => {
28
+ expect(rectFromDrag({ x: -40, y: 20 }, { x: 1200, y: 700 }, canvas)).toEqual({
29
+ x0: 0,
30
+ y0: 20,
31
+ x1: 1000,
32
+ y1: 600,
33
+ })
34
+ })
35
+ })
36
+
37
+ describe('isRegionBigEnough', () => {
38
+ test('wants 24 px on both sides, so a click that wobbled is not a region', () => {
39
+ expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 24, y1: 24 })).toBe(true)
40
+ expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 23, y1: 200 })).toBe(false)
41
+ expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 200, y1: 23.9 })).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('regionFractions', () => {
46
+ test('is [left, top, right, bottom] as 0-1 of the full frame', () => {
47
+ const box = { left: 100, top: 50, width: 800, height: 400 }
48
+
49
+ expect(regionFractions({ x0: 300, y0: 150, x1: 700, y1: 350 }, box)).toEqual([
50
+ 0.25, 0.25, 0.75, 0.75,
51
+ ])
52
+ })
53
+
54
+ test('is clamped to the frame', () => {
55
+ const box = { left: 100, top: 50, width: 800, height: 400 }
56
+
57
+ expect(regionFractions({ x0: 0, y0: 0, x1: 2000, y1: 2000 }, box)).toEqual([0, 0, 1, 1])
58
+ })
59
+ })
60
+
61
+ describe('regionSizeMetres', () => {
62
+ // A camera straight above: the screen point (x, y) looks down at the world point (x / 10, 0, y / 10).
63
+ const down = (x: number, y: number) => ({
64
+ origin: [x / 10, 10, y / 10] as [number, number, number],
65
+ direction: [0, -1, 0] as [number, number, number],
66
+ })
67
+ const rect = { x0: 100, y0: 100, x1: 300, y1: 200 }
68
+
69
+ test('is the distance between the left and right edge hits, and top and bottom, on the plane', () => {
70
+ expect(regionSizeMetres(rect, down, 0)).toEqual({ width: 20, depth: 10 })
71
+ })
72
+
73
+ test('measures on the plane it is given', () => {
74
+ // At y = 5 the same rays hit the same x and z (they are straight down): the size does not change.
75
+ expect(regionSizeMetres(rect, down, 5)).toEqual({ width: 20, depth: 10 })
76
+ })
77
+
78
+ test('is null when a ray misses the plane: it points up, along it, or does not exist', () => {
79
+ expect(
80
+ regionSizeMetres(rect, () => ({ origin: [0, 1, 0], direction: [0, 1, 0] }), 0),
81
+ ).toBeNull()
82
+ expect(
83
+ regionSizeMetres(rect, () => ({ origin: [0, 1, 0], direction: [1, 0, 0] }), 0),
84
+ ).toBeNull()
85
+ expect(regionSizeMetres(rect, () => null, 0)).toBeNull()
86
+ })
87
+
88
+ test('is null when only one of the four rays misses (the sky in a corner of the frame)', () => {
89
+ const skyAtTop = (x: number, y: number) =>
90
+ y <= 100
91
+ ? {
92
+ origin: [0, 1, 0] as [number, number, number],
93
+ direction: [0, 1, 0] as [number, number, number],
94
+ }
95
+ : down(x, y)
96
+
97
+ expect(regionSizeMetres(rect, skyAtTop, 0)).toBeNull()
98
+ })
99
+ })
100
+
101
+ describe('rankInRegion', () => {
102
+ const region = { x0: 100, y0: 100, x1: 300, y1: 300 }
103
+
104
+ test('ranks by the area of overlap with the region, biggest first', () => {
105
+ const ranked = rankInRegion(
106
+ [
107
+ { id: 'small', rect: { x0: 250, y0: 250, x1: 350, y1: 350 } }, // 50 x 50
108
+ { id: 'big', rect: { x0: 0, y0: 0, x1: 400, y1: 400 } }, // the whole region
109
+ { id: 'mid', rect: { x0: 100, y0: 100, x1: 200, y1: 300 } }, // 100 x 200
110
+ ],
111
+ region,
112
+ )
113
+
114
+ expect(ranked).toEqual({ ids: ['big', 'mid', 'small'], more: 0 })
115
+ })
116
+
117
+ test('leaves out what is outside, what only touches an edge, and what has no screen box', () => {
118
+ const ranked = rankInRegion(
119
+ [
120
+ { id: 'outside', rect: { x0: 500, y0: 500, x1: 600, y1: 600 } },
121
+ { id: 'touching', rect: { x0: 300, y0: 100, x1: 400, y1: 300 } },
122
+ { id: 'hidden', rect: null },
123
+ { id: 'inside', rect: { x0: 150, y0: 150, x1: 160, y1: 160 } },
124
+ ],
125
+ region,
126
+ )
127
+
128
+ expect(ranked).toEqual({ ids: ['inside'], more: 0 })
129
+ })
130
+
131
+ test('ties go by id, so the same view always ranks the same way', () => {
132
+ const same = { x0: 120, y0: 120, x1: 140, y1: 140 }
133
+
134
+ expect(
135
+ rankInRegion(
136
+ [
137
+ { id: 'b', rect: same },
138
+ { id: 'a', rect: same },
139
+ ],
140
+ region,
141
+ ).ids,
142
+ ).toEqual(['a', 'b'])
143
+ })
144
+
145
+ test('keeps the top few and counts the rest', () => {
146
+ const many = Array.from({ length: 15 }, (_, i) => ({
147
+ id: `e${String(i).padStart(2, '0')}`,
148
+ rect: { x0: 110, y0: 110, x1: 110 + 10 + i, y1: 120 },
149
+ }))
150
+ const ranked = rankInRegion(many, region)
151
+
152
+ expect(ranked.ids).toHaveLength(12)
153
+ expect(ranked.more).toBe(3)
154
+ expect(ranked.ids[0]).toBe('e14')
155
+ expect(rankInRegion(many, region, 5)).toMatchObject({ more: 10 })
156
+ })
157
+ })
@@ -0,0 +1,136 @@
1
+ import { formatLinearMeasurement } from '../measurements'
2
+ import type { Rect, ScreenPoint } from './types'
3
+
4
+ // A region is a view, not a selection (spec 2.3, "A region is a view, not a selection"): a
5
+ // rectangle dragged on the canvas, its size shown live in metres, the crop it frames, and what lies
6
+ // inside it ranked for the agent. Pure: the camera is handed in as a ray function.
7
+
8
+ const MIN_SIDE_PX = 24
9
+ const RAY_EPSILON = 1e-6
10
+
11
+ const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
12
+
13
+ /** The rectangle between two drag points, whichever way it was dragged, kept inside `bounds`. */
14
+ export function rectFromDrag(a: ScreenPoint, b: ScreenPoint, bounds: Rect): Rect {
15
+ return {
16
+ x0: clamp(Math.min(a.x, b.x), bounds.x0, bounds.x1),
17
+ y0: clamp(Math.min(a.y, b.y), bounds.y0, bounds.y1),
18
+ x1: clamp(Math.max(a.x, b.x), bounds.x0, bounds.x1),
19
+ y1: clamp(Math.max(a.y, b.y), bounds.y0, bounds.y1),
20
+ }
21
+ }
22
+
23
+ /** Under 24 px on a side it is a click that wobbled, not a region. */
24
+ export function isRegionBigEnough(rect: Rect): boolean {
25
+ return rect.x1 - rect.x0 >= MIN_SIDE_PX && rect.y1 - rect.y0 >= MIN_SIDE_PX
26
+ }
27
+
28
+ /** The rectangle as 0-1 fractions of the canvas's full frame: [left, top, right, bottom]. */
29
+ export function regionFractions(
30
+ rect: Rect,
31
+ canvas: { left: number; top: number; width: number; height: number },
32
+ ): [number, number, number, number] {
33
+ const fraction = (value: number, origin: number, extent: number) =>
34
+ clamp((value - origin) / extent, 0, 1)
35
+ return [
36
+ fraction(rect.x0, canvas.left, canvas.width),
37
+ fraction(rect.y0, canvas.top, canvas.height),
38
+ fraction(rect.x1, canvas.left, canvas.width),
39
+ fraction(rect.y1, canvas.top, canvas.height),
40
+ ]
41
+ }
42
+
43
+ type Ray = { origin: [number, number, number]; direction: [number, number, number] }
44
+
45
+ /** Where a ray meets the horizontal plane `y = planeY` in front of it; null when it points up or along it. */
46
+ function hitPlane(ray: Ray | null, planeY: number): [number, number, number] | null {
47
+ if (!ray || ray.direction[1] >= -RAY_EPSILON) return null
48
+ const t = (planeY - ray.origin[1]) / ray.direction[1]
49
+ if (!Number.isFinite(t) || t < 0) return null
50
+ return [ray.origin[0] + ray.direction[0] * t, planeY, ray.origin[2] + ray.direction[2] * t]
51
+ }
52
+
53
+ const distance = (a: readonly number[], b: readonly number[]) =>
54
+ Math.hypot((a[0] ?? 0) - (b[0] ?? 0), (a[1] ?? 0) - (b[1] ?? 0), (a[2] ?? 0) - (b[2] ?? 0))
55
+
56
+ /**
57
+ * What the rectangle spans on the ground, in metres: the rays through the middle of its four sides
58
+ * meet the plane at `planeY` (the level's floor), and the width and depth are the distances between
59
+ * opposite hits. Null when any of the four misses the plane (the sky in a corner of the frame).
60
+ */
61
+ export function regionSizeMetres(
62
+ rect: Rect,
63
+ cameraRay: (x: number, y: number) => Ray | null,
64
+ planeY: number,
65
+ ): { width: number; depth: number } | null {
66
+ const midX = (rect.x0 + rect.x1) / 2
67
+ const midY = (rect.y0 + rect.y1) / 2
68
+ const left = hitPlane(cameraRay(rect.x0, midY), planeY)
69
+ const right = hitPlane(cameraRay(rect.x1, midY), planeY)
70
+ const top = hitPlane(cameraRay(midX, rect.y0), planeY)
71
+ const bottom = hitPlane(cameraRay(midX, rect.y1), planeY)
72
+ if (!(left && right && top && bottom)) return null
73
+ return { width: distance(left, right), depth: distance(top, bottom) }
74
+ }
75
+
76
+ /** "4.2 × 3.1 m", or feet and inches as the editor writes them. */
77
+ export function formatRegionSize(
78
+ size: { width: number; depth: number },
79
+ unit: 'metric' | 'imperial',
80
+ ): string {
81
+ if (unit === 'imperial')
82
+ return `${formatLinearMeasurement(size.width, 'imperial')} × ${formatLinearMeasurement(size.depth, 'imperial')}`
83
+ return `${size.width.toFixed(1)} × ${size.depth.toFixed(1)} m`
84
+ }
85
+
86
+ const overlapArea = (a: Rect, b: Rect) =>
87
+ Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)) *
88
+ Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0))
89
+
90
+ /**
91
+ * What the region shows, by how much of it each element covers, the biggest first, ties by id so
92
+ * the same view ranks the same way; elements that only touch an edge, or have no box on screen,
93
+ * are not in it. The top `limit` are named, the rest only counted.
94
+ */
95
+ export function rankInRegion(
96
+ candidates: readonly { id: string; rect: Rect | null }[],
97
+ region: Rect,
98
+ limit = 12,
99
+ ): { ids: string[]; more: number } {
100
+ const ranked = candidates
101
+ .flatMap(({ id, rect }) => {
102
+ const area = rect ? overlapArea(rect, region) : 0
103
+ return area > 0 ? [{ id, area }] : []
104
+ })
105
+ .sort((a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
106
+ return {
107
+ ids: ranked.slice(0, limit).map((entry) => entry.id),
108
+ more: Math.max(0, ranked.length - limit),
109
+ }
110
+ }
111
+
112
+ /** The region chip's words: "Area · 6 elements". */
113
+ export function regionLabel(count: number): string {
114
+ if (count <= 0) return 'Area'
115
+ return `Area · ${count} element${count === 1 ? '' : 's'}`
116
+ }
117
+
118
+ /**
119
+ * Where the bubble's tail points on the rectangle: the release point put on its nearest edge. A
120
+ * point outside is clamped onto the rectangle; one inside is pushed out to the closest side.
121
+ */
122
+ export function nearestEdgePoint(rect: Rect, to: ScreenPoint): ScreenPoint {
123
+ const x = clamp(to.x, rect.x0, rect.x1)
124
+ const y = clamp(to.y, rect.y0, rect.y1)
125
+ const inside = to.x > rect.x0 && to.x < rect.x1 && to.y > rect.y0 && to.y < rect.y1
126
+ if (!inside) return { x, y }
127
+ const toLeft = x - rect.x0
128
+ const toRight = rect.x1 - x
129
+ const toTop = y - rect.y0
130
+ const toBottom = rect.y1 - y
131
+ const nearest = Math.min(toLeft, toRight, toTop, toBottom)
132
+ if (nearest === toRight) return { x: rect.x1, y }
133
+ if (nearest === toLeft) return { x: rect.x0, y }
134
+ if (nearest === toTop) return { x, y: rect.y0 }
135
+ return { x, y: rect.y1 }
136
+ }
@@ -0,0 +1,217 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ BuildingNode,
4
+ clearSceneHistory,
5
+ emitter,
6
+ LevelNode,
7
+ type PointAskSubmit,
8
+ registerPointAskHandler,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { type BubbleTarget, type PinRecord, usePointAsk } from '../../store/use-point-ask'
13
+ import { createPin } from './pin-machine'
14
+ import { planRetry } from './retry'
15
+ import { retryAsk, sendBubble, startPointAskSession } from './session'
16
+
17
+ // Retrying an ask from the chat: the same words, targets, camera and crop as a new ask with its own
18
+ // pin. Architect's Retry goes through here; a finished ask keeps its pin (its compare and Undo
19
+ // stay), an errored one is replaced, as "Try again" does.
20
+
21
+ const submit: PointAskSubmit = {
22
+ askId: 'ask_old',
23
+ context: {
24
+ askId: 'ask_old',
25
+ camera: {
26
+ aspect: 1.6,
27
+ fov: 50,
28
+ position: [0, 1, 5],
29
+ projection: 'perspective',
30
+ target: [0, 1, 0],
31
+ },
32
+ capturedAt: '2026-10-12T10:00:00.000Z',
33
+ gesture: 'click',
34
+ image: { dataUrl: 'data:image/jpeg;base64,AAAA', height: 320, marked: true, width: 512 },
35
+ kind: 'scene-point',
36
+ level: { id: 'level_0', name: 'Ground floor' },
37
+ source: 'editor',
38
+ targets: [],
39
+ version: 1,
40
+ },
41
+ from: { height: 24, width: 120, x: 600, y: 300 },
42
+ text: 'make this window wider',
43
+ }
44
+
45
+ function pinIn(state: 'error' | 'done'): PinRecord {
46
+ const model = createPin({ askId: 'ask_old', targetIds: ['window_kitchen_n1'], text: submit.text })
47
+ return {
48
+ anchor: [0, 1, 0],
49
+ createdAt: 1,
50
+ label: 'Window · Kitchen',
51
+ levelId: 'level_0',
52
+ local: null,
53
+ model: { ...model, state } as PinRecord['model'],
54
+ retireAt: 123,
55
+ submit,
56
+ }
57
+ }
58
+
59
+ describe('planRetry', () => {
60
+ test('the new ask has its own id, the same words, targets, camera and crop', () => {
61
+ const plan = planRetry(pinIn('done'), 'ask_new', 5)
62
+ expect(plan?.submit.askId).toBe('ask_new')
63
+ expect(plan?.submit.context.askId).toBe('ask_new')
64
+ expect(plan?.submit.text).toBe('make this window wider')
65
+ expect(plan?.submit.context.camera).toEqual(submit.context.camera)
66
+ expect(plan?.submit.context.image).toEqual(submit.context.image)
67
+ expect(plan?.pin.model.askId).toBe('ask_new')
68
+ expect(plan?.pin.model.targetIds).toEqual(['window_kitchen_n1'])
69
+ expect(plan?.pin.retireAt).toBeNull()
70
+ expect(plan?.pin.createdAt).toBe(5)
71
+ })
72
+
73
+ test('an errored pin is replaced (Try again); any other state keeps its pin, with its compare and Undo', () => {
74
+ expect(planRetry(pinIn('error'), 'ask_new', 5)?.keepOld).toBe(false)
75
+ expect(planRetry(pinIn('done'), 'ask_new', 5)?.keepOld).toBe(true)
76
+ })
77
+
78
+ test('a pin that kept no submit cannot be retried', () => {
79
+ const { submit: _gone, ...noSubmit } = pinIn('error')
80
+ expect(planRetry(noSubmit as PinRecord, 'ask_new', 5)).toBeNull()
81
+ })
82
+ })
83
+
84
+ const building = BuildingNode.parse({ id: 'building_retry' })
85
+ const level = LevelNode.parse({ id: 'level_retry', parentId: building.id })
86
+ const wall = WallNode.parse({
87
+ id: 'wall_retry',
88
+ parentId: level.id,
89
+ start: [0, 0],
90
+ end: [4, 0],
91
+ height: 2.6,
92
+ })
93
+ building.children = [level.id]
94
+ level.children = [wall.id]
95
+ const target = {
96
+ id: wall.id,
97
+ type: 'wall',
98
+ name: 'Wall',
99
+ levelId: level.id,
100
+ size: { length: 4, height: 2.6, thickness: 0.2 },
101
+ box: { min: [0, 0, -0.1], max: [4, 2.6, 0.1] },
102
+ } as unknown as BubbleTarget
103
+
104
+ const original = useScene.getState()
105
+ const originalWindow = globalThis.window
106
+ const originalDocument = globalThis.document
107
+ const originalRaf = globalThis.requestAnimationFrame
108
+ const originalCancelRaf = globalThis.cancelAnimationFrame
109
+ let stopSession = () => {}
110
+ let unregister = () => {}
111
+ let handed: PointAskSubmit[] = []
112
+
113
+ beforeEach(() => {
114
+ const stub = { addEventListener() {}, removeEventListener() {} }
115
+ globalThis.window = { ...stub, innerWidth: 1280, innerHeight: 800 } as never
116
+ globalThis.document = { ...stub, hidden: false } as never
117
+ globalThis.requestAnimationFrame = (callback) => {
118
+ callback(0)
119
+ return 0
120
+ }
121
+ globalThis.cancelAnimationFrame = () => {}
122
+ useScene.setState({
123
+ nodes: { [building.id]: building, [level.id]: level, [wall.id]: wall },
124
+ rootNodeIds: [building.id],
125
+ dirtyNodes: new Set(),
126
+ readOnly: false,
127
+ } as never)
128
+ clearSceneHistory()
129
+ usePointAsk.setState({ pins: {}, pinOrder: [], bubble: null, comparing: null, busy: false })
130
+ handed = []
131
+ unregister = registerPointAskHandler(async (sent) => {
132
+ handed.push(sent)
133
+ return { ok: true }
134
+ })
135
+ stopSession = startPointAskSession()
136
+ })
137
+
138
+ afterEach(() => {
139
+ stopSession()
140
+ unregister()
141
+ useScene.setState(original, true)
142
+ clearSceneHistory()
143
+ globalThis.window = originalWindow
144
+ globalThis.document = originalDocument
145
+ if (originalRaf) globalThis.requestAnimationFrame = originalRaf
146
+ else Reflect.deleteProperty(globalThis, 'requestAnimationFrame')
147
+ if (originalCancelRaf) globalThis.cancelAnimationFrame = originalCancelRaf
148
+ else Reflect.deleteProperty(globalThis, 'cancelAnimationFrame')
149
+ })
150
+
151
+ async function sendAsk() {
152
+ usePointAsk.getState().openBubble({
153
+ targets: [target],
154
+ anchor: [2, 1, 0],
155
+ hit: { point: [2, 1, 0], normal: [0, 0, 1] },
156
+ gesture: 'click',
157
+ capturedAt: new Date().toISOString(),
158
+ camera: null,
159
+ crop: { state: 'failed' },
160
+ draft: '',
161
+ error: null,
162
+ })
163
+ await sendBubble('make this wall taller', { x: 0, y: 0, width: 10, height: 10 })
164
+ const askId = usePointAsk.getState().pinOrder[0]
165
+ if (!askId) throw new Error('the ask did not plant a pin')
166
+ return askId
167
+ }
168
+
169
+ const status = (askId: string, rest: Record<string, unknown>) =>
170
+ emitter.emit('point-ask:status', { askId, ...rest } as never)
171
+
172
+ describe('retryAsk', () => {
173
+ test('hands the same words and targets to the chat as a new ask, and says it did', async () => {
174
+ const askId = await sendAsk()
175
+ status(askId, { status: 'working' })
176
+ status(askId, { status: 'ended', outcome: 'completed', asked: false, wroteScene: true })
177
+ expect(await retryAsk(askId)).toBe(true)
178
+ expect(handed).toHaveLength(2)
179
+ expect(handed[1]?.askId).not.toBe(askId)
180
+ expect(handed[1]?.context.askId).toBe(handed[1]?.askId)
181
+ expect(handed[1]?.text).toBe('make this wall taller')
182
+ expect(handed[1]?.context.targets.map((t) => t.id)).toEqual([wall.id])
183
+ })
184
+
185
+ test('a finished ask keeps its pin, so its compare and Undo stay', async () => {
186
+ const askId = await sendAsk()
187
+ status(askId, { status: 'working' })
188
+ status(askId, { status: 'ended', outcome: 'completed', asked: false, wroteScene: true })
189
+ await retryAsk(askId)
190
+ const { pinOrder, pins } = usePointAsk.getState()
191
+ expect(pinOrder).toHaveLength(2)
192
+ expect(pins[askId]).toBeDefined()
193
+ const next = pinOrder.find((id) => id !== askId)
194
+ expect(next && pins[next]?.model.state).toBe('sent')
195
+ })
196
+
197
+ test('an errored ask is replaced by the retry, as "Try again" does', async () => {
198
+ const askId = await sendAsk()
199
+ status(askId, { status: 'working' })
200
+ status(askId, {
201
+ status: 'ended',
202
+ outcome: 'errored',
203
+ asked: false,
204
+ wroteScene: false,
205
+ error: 'x',
206
+ })
207
+ await retryAsk(askId)
208
+ const { pinOrder, pins } = usePointAsk.getState()
209
+ expect(pinOrder).toHaveLength(1)
210
+ expect(pins[askId]).toBeUndefined()
211
+ })
212
+
213
+ test("says it could not when the pin is gone (a reload, a collaborator's ask)", async () => {
214
+ expect(await retryAsk('ask_unknown')).toBe(false)
215
+ expect(handed).toHaveLength(0)
216
+ })
217
+ })
@@ -0,0 +1,37 @@
1
+ import type { PointAskSubmit } from '@pascal-app/core'
2
+ import type { PinRecord } from '../../store/use-point-ask'
3
+ import { createPin } from './pin-machine'
4
+
5
+ /**
6
+ * What retrying an ask puts in the world: the same words, context and crop under a new ask id, planted
7
+ * where the old pin was. An errored ask is replaced (the pin's own "Try again"); any other is kept, so
8
+ * a retry from the chat of a finished ask does not take away that result's compare and Undo.
9
+ * Null when the pin kept no submit to send again.
10
+ */
11
+ export function planRetry(
12
+ old: PinRecord,
13
+ nextAskId: string,
14
+ now: number,
15
+ ): { pin: PinRecord; submit: PointAskSubmit; keepOld: boolean } | null {
16
+ if (!old.submit) return null
17
+ const submit: PointAskSubmit = {
18
+ ...old.submit,
19
+ askId: nextAskId,
20
+ context: { ...old.submit.context, askId: nextAskId },
21
+ }
22
+ return {
23
+ pin: {
24
+ ...old,
25
+ model: createPin({
26
+ askId: nextAskId,
27
+ text: old.model.text,
28
+ targetIds: old.model.targetIds,
29
+ }),
30
+ submit,
31
+ retireAt: null,
32
+ createdAt: now,
33
+ },
34
+ submit,
35
+ keepOld: old.model.state !== 'error',
36
+ }
37
+ }