@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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,173 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, PointContext } from '@pascal-app/core'
3
+ import { house, LEVEL_ID } from './__fixtures__/house'
4
+ import { type BuildPointContextInput, buildPointContext } from './build-context'
5
+
6
+ // What the agent receives when the person points (spec section 5): ids first, in metres, the camera
7
+ // the person saw. Written first: an id the agent cannot read back; a size in the viewer's unit
8
+ // instead of metres; a target dropped past a preview limit; an orthographic camera with no width, a
9
+ // perspective one with a stray one; fields present as `undefined` where there is nothing to say.
10
+
11
+ const CAMERA = {
12
+ position: [6.2, 4.1, 7.5] as [number, number, number],
13
+ target: [2, 1, 1.5] as [number, number, number],
14
+ fov: 50,
15
+ aspect: 1.6,
16
+ projection: 'perspective' as const,
17
+ }
18
+
19
+ const nodes = house()
20
+ const when = new Date('2026-10-08T01:02:03.000Z')
21
+
22
+ const build = (over: Partial<BuildPointContextInput> = {}): PointContext =>
23
+ buildPointContext({
24
+ askId: 'ask_1',
25
+ capturedAt: when,
26
+ gesture: 'click',
27
+ nodes,
28
+ levelId: LEVEL_ID,
29
+ camera: CAMERA,
30
+ targets: [{ id: 'zone_kitchen' }],
31
+ ...over,
32
+ })
33
+
34
+ describe('the context of a click on a room', () => {
35
+ const context = build()
36
+
37
+ test('names itself, its ask, its moment and its surface', () => {
38
+ expect(context).toMatchObject({
39
+ kind: 'scene-point',
40
+ version: 1,
41
+ askId: 'ask_1',
42
+ capturedAt: '2026-10-08T01:02:03.000Z',
43
+ source: 'editor',
44
+ gesture: 'click',
45
+ level: { id: 'level_0', name: 'Level 0' },
46
+ })
47
+ })
48
+
49
+ test('carries the room by id, with its name, level, size in metres and box', () => {
50
+ const [kitchen] = context.targets
51
+
52
+ expect(kitchen).toMatchObject({
53
+ id: 'zone_kitchen',
54
+ type: 'zone',
55
+ name: 'Kitchen',
56
+ levelId: 'level_0',
57
+ size: { area: 12, width: 4, depth: 3 },
58
+ })
59
+ expect(kitchen!.box.min[0]).toBeCloseTo(0, 6)
60
+ expect(kitchen!.box.max[0]).toBeCloseTo(4, 6)
61
+ expect(kitchen!.box.min[2]).toBeCloseTo(0, 6)
62
+ expect(kitchen!.box.max[2]).toBeCloseTo(3, 6)
63
+ expect(kitchen!.box.max[1]).toBeGreaterThan(kitchen!.box.min[1])
64
+ })
65
+
66
+ test('the camera is the one the person saw, as view_scene takes it', () => {
67
+ expect(context.camera).toEqual(CAMERA)
68
+ expect('viewWidth' in context.camera).toBe(false)
69
+ })
70
+
71
+ test('says nothing it has nothing to say about', () => {
72
+ for (const key of ['inRegion', 'region', 'image', 'suggestion'])
73
+ expect(key in context, key).toBe(false)
74
+ expect('hit' in context.targets[0]!).toBe(false)
75
+ })
76
+ })
77
+
78
+ describe('targets', () => {
79
+ test('a window: its wall as parent, its room, the finger’s hit, its sill', () => {
80
+ const hit = {
81
+ point: [2, 1.4, 0] as [number, number, number],
82
+ normal: [0, 0, -1] as [number, number, number],
83
+ face: 'exterior' as const,
84
+ }
85
+ const [window] = build({ targets: [{ id: 'window_north', hit }] }).targets
86
+
87
+ expect(window).toMatchObject({
88
+ id: 'window_north',
89
+ type: 'window',
90
+ name: 'Window',
91
+ parentId: 'wall_north',
92
+ zoneId: 'zone_kitchen',
93
+ hit,
94
+ })
95
+ expect(window!.size.sill).toBeCloseTo(0.7, 6)
96
+ })
97
+
98
+ test('a wall and an item', () => {
99
+ const [wall, sofa] = build({ targets: [{ id: 'wall_north' }, { id: 'item_sofa' }] }).targets
100
+
101
+ expect(wall).toMatchObject({ type: 'wall', zoneId: 'zone_kitchen' })
102
+ expect(wall!.size.length).toBeCloseTo(4, 6)
103
+ expect(sofa).toMatchObject({ type: 'item', name: 'Sofa' })
104
+ expect(sofa!.size).toEqual({ width: 2.1, depth: 0.95, height: 0.85 })
105
+ })
106
+
107
+ test('the viewer’s unit never reaches the context: sizes are metres', () => {
108
+ const [window] = build({ targets: [{ id: 'window_north' }] }).targets
109
+
110
+ expect(window!.size.width).toBeCloseTo(1.2, 6)
111
+ })
112
+
113
+ test('a dozen and more all arrive, each by its own id (no preview limit)', () => {
114
+ const extra: AnyNode[] = Array.from(
115
+ { length: 14 },
116
+ (_, i) =>
117
+ ({
118
+ ...(nodes.item_sofa as object),
119
+ id: `item_chair_${i}`,
120
+ name: 'Chair',
121
+ }) as unknown as AnyNode,
122
+ )
123
+ const scene = house(extra)
124
+ const context = build({
125
+ nodes: scene,
126
+ gesture: 'multi',
127
+ targets: extra.map((node) => ({ id: node.id })),
128
+ })
129
+
130
+ expect(context.targets).toHaveLength(14)
131
+ expect(new Set(context.targets.map((t) => t.id)).size).toBe(14)
132
+ expect(context.targets.every((t) => t.name === 'Chair')).toBe(true)
133
+ })
134
+
135
+ test('an element that is gone is left out, never guessed', () => {
136
+ const context = build({ targets: [{ id: 'zone_kitchen' }, { id: 'wall_gone' }] })
137
+
138
+ expect(context.targets.map((t) => t.id)).toEqual(['zone_kitchen'])
139
+ })
140
+ })
141
+
142
+ describe('the camera and the picture', () => {
143
+ test('an orthographic camera carries its view width, a perspective one does not', () => {
144
+ const ortho = build({ camera: { ...CAMERA, projection: 'orthographic', viewWidth: 14 } })
145
+
146
+ expect(ortho.camera).toMatchObject({ projection: 'orthographic', viewWidth: 14 })
147
+ expect('viewWidth' in build({ camera: { ...CAMERA, viewWidth: 14 } }).camera).toBe(false)
148
+ })
149
+
150
+ test('a region is a view, not a selection: its ids ride beside empty targets', () => {
151
+ const context = build({
152
+ gesture: 'region',
153
+ targets: [],
154
+ region: [0.2, 0.3, 0.6, 0.8],
155
+ inRegion: { ids: ['wall_north', 'window_north'], more: 3 },
156
+ })
157
+
158
+ expect(context).toMatchObject({
159
+ gesture: 'region',
160
+ targets: [],
161
+ region: [0.2, 0.3, 0.6, 0.8],
162
+ inRegion: { ids: ['wall_north', 'window_north'], more: 3 },
163
+ })
164
+ })
165
+
166
+ test('the crop and the suggestion pass through as given', () => {
167
+ const image = { dataUrl: 'data:image/png;base64,AAAA', width: 640, height: 480, marked: true }
168
+ const context = build({ image, suggestion: 'make-it-wider' })
169
+
170
+ expect(context.image).toEqual(image)
171
+ expect(context.suggestion).toBe('make-it-wider')
172
+ })
173
+ })
@@ -0,0 +1,94 @@
1
+ import type {
2
+ AnyNode,
3
+ LevelNode,
4
+ PointCamera,
5
+ PointContext,
6
+ PointTarget as PointContextTarget,
7
+ } from '@pascal-app/core'
8
+ import { sceneViewBounds } from '@pascal-app/core/agent-operations'
9
+ import { describeTarget } from './describe-target'
10
+
11
+ // What the agent receives when the person points (point-and-ask-spec.md section 5): ids first, in
12
+ // metres, from the camera the person saw. The words stay the person's; this is metadata.
13
+
14
+ export type BuildPointContextInput = {
15
+ /** The pin, the chat chip and the turn share it. */
16
+ askId: string
17
+ /** The moment of pointing (pointer-down); now when absent. */
18
+ capturedAt?: Date | string
19
+ gesture: PointContext['gesture']
20
+ nodes: Readonly<Record<string, AnyNode>>
21
+ /** The level in view. */
22
+ levelId: string
23
+ /** What the finger is on, each by id, with where it landed. */
24
+ targets: { id: string; hit?: PointContextTarget['hit'] }[]
25
+ camera: PointCamera
26
+ inRegion?: PointContext['inRegion']
27
+ region?: PointContext['region']
28
+ image?: PointContext['image']
29
+ suggestion?: string
30
+ }
31
+
32
+ type Box = PointContextTarget['box']
33
+
34
+ /** The element's box as view_scene frames it; a point at its position when that cannot say. */
35
+ function boxOf(nodes: Readonly<Record<string, AnyNode>>, node: AnyNode): Box {
36
+ try {
37
+ return sceneViewBounds(nodes, node.id)
38
+ } catch {
39
+ const at = (node as { position?: number[] }).position ?? [0, 0, 0]
40
+ const point: [number, number, number] = [at[0] ?? 0, at[1] ?? 0, at[2] ?? 0]
41
+ return { min: point, max: point }
42
+ }
43
+ }
44
+
45
+ export function buildPointContext(input: BuildPointContextInput): PointContext {
46
+ const { nodes } = input
47
+ const level = nodes[input.levelId] as LevelNode | undefined
48
+ const camera: PointCamera = {
49
+ position: input.camera.position,
50
+ target: input.camera.target,
51
+ fov: input.camera.fov,
52
+ aspect: input.camera.aspect,
53
+ projection: input.camera.projection,
54
+ ...(input.camera.projection === 'orthographic' && input.camera.viewWidth !== undefined
55
+ ? { viewWidth: input.camera.viewWidth }
56
+ : {}),
57
+ }
58
+
59
+ const targets: PointContextTarget[] = []
60
+ for (const { id, hit } of input.targets) {
61
+ const node = nodes[id]
62
+ // An element deleted between the pointing and the send is not a target any more.
63
+ if (!node) continue
64
+ const described = describeTarget(node, nodes, { unit: 'metric' })
65
+ targets.push({
66
+ id: node.id,
67
+ type: node.type,
68
+ name: described.name,
69
+ levelId: described.levelId,
70
+ ...(described.zoneId ? { zoneId: described.zoneId } : {}),
71
+ ...(described.parentId ? { parentId: described.parentId } : {}),
72
+ ...(hit ? { hit } : {}),
73
+ box: boxOf(nodes, node),
74
+ size: described.sizes,
75
+ })
76
+ }
77
+
78
+ const capturedAt = input.capturedAt ?? new Date()
79
+ return {
80
+ kind: 'scene-point',
81
+ version: 1,
82
+ askId: input.askId,
83
+ capturedAt: typeof capturedAt === 'string' ? capturedAt : capturedAt.toISOString(),
84
+ source: 'editor',
85
+ gesture: input.gesture,
86
+ level: { id: input.levelId, name: level?.name?.trim() || `Level ${level?.level ?? 0}` },
87
+ targets,
88
+ camera,
89
+ ...(input.inRegion ? { inRegion: input.inRegion } : {}),
90
+ ...(input.region ? { region: input.region } : {}),
91
+ ...(input.image ? { image: input.image } : {}),
92
+ ...(input.suggestion ? { suggestion: input.suggestion } : {}),
93
+ }
94
+ }
@@ -0,0 +1,129 @@
1
+ import {
2
+ emitter,
3
+ type SnapshotCapturedEvent,
4
+ type SnapshotCaptureFailedEvent,
5
+ } from '@pascal-app/core'
6
+ import { useViewer } from '@pascal-app/viewer'
7
+ import { canvasRect } from './projector'
8
+ import type { Rect } from './types'
9
+
10
+ // The picture an ask carries as evidence (spec 2.4): the targets' screen box padded 25%, at least
11
+ // 512 px on its long side, rendered by the snapshot pipeline as an ephemeral frame so nothing is
12
+ // stored. Started at pointer-down, so it is ready by the time the person has typed.
13
+
14
+ const CROP_LONG_SIDE_MIN = 512
15
+ const CROP_LONG_SIDE_MAX = 768
16
+ const CAPTURE_TIMEOUT_MS = 12_000
17
+
18
+ export type CapturedCrop = { dataUrl: string; width: number; height: number }
19
+
20
+ /** The target's box padded by `pad` of its size on every side, and kept inside the canvas. */
21
+ export function paddedCropRect(
22
+ box: Rect,
23
+ canvas: { width: number; height: number },
24
+ pad = 0.25,
25
+ ): Rect {
26
+ const w = Math.max(48, box.x1 - box.x0)
27
+ const h = Math.max(48, box.y1 - box.y0)
28
+ const cx = (box.x0 + box.x1) / 2
29
+ const cy = (box.y0 + box.y1) / 2
30
+ const half = { x: (w * (1 + pad * 2)) / 2, y: (h * (1 + pad * 2)) / 2 }
31
+ return {
32
+ x0: Math.max(0, cx - half.x),
33
+ y0: Math.max(0, cy - half.y),
34
+ x1: Math.min(canvas.width, cx + half.x),
35
+ y1: Math.min(canvas.height, cy + half.y),
36
+ }
37
+ }
38
+
39
+ /** A rectangle as 0-1 fractions of the frame, for the snapshot pipeline's `cropRegion`. */
40
+ export function toCropRegion(rect: Rect, canvas: { width: number; height: number }) {
41
+ return {
42
+ x: rect.x0 / canvas.width,
43
+ y: rect.y0 / canvas.height,
44
+ width: (rect.x1 - rect.x0) / canvas.width,
45
+ height: (rect.y1 - rect.y0) / canvas.height,
46
+ }
47
+ }
48
+
49
+ async function blobToScaledDataUrl(blob: Blob): Promise<CapturedCrop> {
50
+ const bitmap = await createImageBitmap(blob)
51
+ const long = Math.max(bitmap.width, bitmap.height)
52
+ const scale =
53
+ long > CROP_LONG_SIDE_MAX ? CROP_LONG_SIDE_MAX / long : long < CROP_LONG_SIDE_MIN ? 1 : 1
54
+ const width = Math.round(bitmap.width * scale)
55
+ const height = Math.round(bitmap.height * scale)
56
+ const canvas = document.createElement('canvas')
57
+ canvas.width = width
58
+ canvas.height = height
59
+ const context = canvas.getContext('2d')
60
+ if (!context) throw new Error('No 2D canvas to scale the picture')
61
+ context.drawImage(bitmap, 0, 0, width, height)
62
+ bitmap.close()
63
+ return { dataUrl: canvas.toDataURL('image/jpeg', 0.84), width, height }
64
+ }
65
+
66
+ const BUSY = 'Another snapshot is being captured'
67
+ const BUSY_RETRY_MS = 1_500
68
+
69
+ /**
70
+ * One crop of the current view over `screenRect` (viewport pixels); once more after 1.5 s when
71
+ * another capture (the agent's own view) holds the renderer. Rejects when the 3D canvas cannot
72
+ * answer in time (a hidden tab, a busy renderer): the ask then goes without a picture.
73
+ */
74
+ export async function captureViewCrop(
75
+ screenRect: Rect,
76
+ options: { pad?: number } = {},
77
+ ): Promise<CapturedCrop> {
78
+ try {
79
+ return await captureOnce(screenRect, options)
80
+ } catch (error) {
81
+ if (!(error instanceof Error && error.message.startsWith(BUSY))) throw error
82
+ await new Promise((resolve) => setTimeout(resolve, BUSY_RETRY_MS))
83
+ return captureOnce(screenRect, options)
84
+ }
85
+ }
86
+
87
+ function captureOnce(screenRect: Rect, options: { pad?: number }): Promise<CapturedCrop> {
88
+ const rect = canvasRect()
89
+ const projectId = useViewer.getState().projectId
90
+ if (!rect || !projectId) return Promise.reject(new Error('The 3D view is not ready'))
91
+ const local: Rect = {
92
+ x0: screenRect.x0 - rect.left,
93
+ y0: screenRect.y0 - rect.top,
94
+ x1: screenRect.x1 - rect.left,
95
+ y1: screenRect.y1 - rect.top,
96
+ }
97
+ const cropRegion = toCropRegion(paddedCropRect(local, rect, options.pad ?? 0.25), rect)
98
+ const requestId = `point-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
99
+ return new Promise((resolve, reject) => {
100
+ const stop = () => {
101
+ clearTimeout(timer)
102
+ emitter.off('snapshot:captured', onCaptured)
103
+ emitter.off('snapshot:capture-failed', onFailed)
104
+ }
105
+ const onCaptured = (event: SnapshotCapturedEvent) => {
106
+ if (event.requestId !== requestId) return
107
+ stop()
108
+ blobToScaledDataUrl(event.blob).then(resolve, reject)
109
+ }
110
+ const onFailed = (event: SnapshotCaptureFailedEvent) => {
111
+ if (event.requestId !== requestId) return
112
+ stop()
113
+ reject(new Error(event.error))
114
+ }
115
+ const timer = setTimeout(() => {
116
+ stop()
117
+ reject(new Error('No picture came back from the 3D view'))
118
+ }, CAPTURE_TIMEOUT_MS)
119
+ emitter.on('snapshot:captured', onCaptured)
120
+ emitter.on('snapshot:capture-failed', onFailed)
121
+ emitter.emit('camera-controls:generate-thumbnail', {
122
+ projectId,
123
+ requestId,
124
+ ephemeral: true,
125
+ captureMode: 'area',
126
+ cropRegion,
127
+ })
128
+ })
129
+ }
@@ -0,0 +1,69 @@
1
+ // The numbers Point and ask moves by, in one place: the direction's tokens
2
+ // (chat-juice-direction.md), the choreography section of point-and-ask-spec.md, and where the two
3
+ // differ, the prototype's (pascal-builds.html), which the owner played and called the bar.
4
+
5
+ export const EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)'
6
+ export const EASE_IN_OUT = 'cubic-bezier(0.77, 0, 0.175, 1)'
7
+
8
+ export type SpringTokens = { damping: number; response: number }
9
+
10
+ /** Critically damped: the default for anything a person can touch. */
11
+ export const SPRING: SpringTokens = { damping: 1, response: 0.35 }
12
+ /** A little bounce, only for things that carry momentum. */
13
+ export const MOMENTUM: SpringTokens = { damping: 0.8, response: 0.35 }
14
+ /** The outline's four corners travelling between targets. */
15
+ export const CORNER_SPRING: SpringTokens = { damping: 1, response: 0.16 }
16
+
17
+ // Durations (ms).
18
+ export const PRESS_MS = 120
19
+ export const ENTER_MS = 180
20
+ export const CARD_MS = 220
21
+ /** An exit is quicker than an entrance. */
22
+ export const BUBBLE_CLOSE_MS = 150
23
+ export const CARD_CLOSE_MS = 120
24
+ export const PIN_RETIRE_FADE_MS = 180
25
+ export const CHECK_STROKE_MS = 240
26
+ export const CHECK_PULSE_MS = 380
27
+ export const PIN_DROP_MS = 320
28
+ export const PIN_RING_MS = 560
29
+ export const PIN_RING_DELAY_MS = 160
30
+ // The prototype's flash, longer than the spec's 120 ms: a shutter has to be seen to be felt.
31
+ export const REGION_FLASH_MS = 280
32
+ export const SUGGESTION_STAGGER_MS = 30
33
+ export const SUGGESTION_DELAY_MS = 40
34
+ export const CHIP_FADE_MS = 120
35
+
36
+ // Thresholds and dwell (ms or px).
37
+ export const CLICK_SLOP_PX = 6
38
+ export const DWELL_MS = 120
39
+ export const SIDE_HYSTERESIS_MS = 150
40
+ export const TAP_LATCH_MS = 200
41
+ export const CARD_AUTO_MS = 4000
42
+ export const CARD_CLOSE_DELAY_MS = 400
43
+ // A card the camera carried from under a still pointer stays this long, for the pointer to reach it.
44
+ export const CARD_CARRIED_MS = 6000
45
+ export const PIN_RETIRE_MS = 8000
46
+ // A press on a done pin that stays down this long shows the scene as it was before the turn.
47
+ export const PIN_HOLD_MS = 220
48
+ // While held, the pointer may drift this far from where it pressed before the hold ends.
49
+ export const PIN_HOLD_SLOP_PX = 36
50
+ export const THIN_TARGET_PX = 6
51
+
52
+ // Layout (px).
53
+ export const BUBBLE_W = 320
54
+ export const BUBBLE_GAP = 16
55
+ export const VIEWPORT_INSET = 12
56
+ export const BIG_BOX_DISC = 48
57
+ export const MAX_TARGETS = 12
58
+ export const PIN_DISC = 24
59
+ export const PIN_HIT = 32
60
+ export const PIN_HIT_TOUCH = 44
61
+ export const CARD_W = 250
62
+ export const CHIP_OFFSET = 14
63
+
64
+ // Motion shapes.
65
+ /** The bubble grows from the click: 0.92 -> 1 with opacity and a 4 px blur (the prototype's, not the spec's 0.96). */
66
+ export const BUBBLE_OPEN_SCALE = 0.92
67
+ export const BUBBLE_OPEN_BLUR_PX = 4
68
+ export const PIN_DROP_PX = 8
69
+ export const CHIP_RISE_PX = 4
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createCompare } from './compare'
3
+
4
+ // Hold a control, see the scene as it was before the turn; let go, and it is back. The preview is
5
+ // the core's (it writes nothing: its own tests say so); what this decides is when one may start,
6
+ // and that it always ends. Written first: a hold that starts under a turn that is writing, or with
7
+ // no state to show; a second hold that leaves the first up; a release that is missed.
8
+
9
+ const snapshot = { nodes: {} } as never
10
+
11
+ function setup(options: { before?: unknown; working?: boolean; begin?: unknown } = {}) {
12
+ const ended: string[] = []
13
+ const changes: (string | null)[] = []
14
+ let began = 0
15
+ const compare = createCompare({
16
+ before: () => ('before' in options ? (options.before as never) : snapshot),
17
+ turnsWorking: () => options.working ?? false,
18
+ begin: () => {
19
+ began++
20
+ if (options.begin === null) return null
21
+ return { end: () => ended.push('end') }
22
+ },
23
+ onChange: (askId) => changes.push(askId),
24
+ })
25
+ return { compare, ended, changes, began: () => began }
26
+ }
27
+
28
+ describe('why a hold is refused', () => {
29
+ test('says a turn is writing', () => {
30
+ const { compare } = setup({ working: true })
31
+ compare.start('ask_1')
32
+ expect(compare.refusal()).toBe('turn-working')
33
+ })
34
+
35
+ test('says history cannot show the before', () => {
36
+ const { compare } = setup({ before: null })
37
+ compare.start('ask_1')
38
+ expect(compare.refusal()).toBe('no-before')
39
+ })
40
+
41
+ test('says the scene is held by something else', () => {
42
+ const { compare } = setup({ begin: null })
43
+ compare.start('ask_1')
44
+ expect(compare.refusal()).toBe('history-held')
45
+ })
46
+
47
+ test('says nothing after a hold that started', () => {
48
+ const { compare } = setup({ working: true })
49
+ compare.start('ask_1')
50
+ const next = setup()
51
+ next.compare.start('ask_1')
52
+ expect(next.compare.refusal()).toBeNull()
53
+ })
54
+ })
55
+
56
+ describe('start', () => {
57
+ test('shows the before of that ask and says whose', () => {
58
+ const { compare, changes } = setup()
59
+
60
+ expect(compare.start('ask_1')).toBe(true)
61
+
62
+ expect(compare.current()).toBe('ask_1')
63
+ expect(changes).toEqual(['ask_1'])
64
+ })
65
+
66
+ test('does not start while a turn is writing: it would be swallowed or land on the wrong state', () => {
67
+ const { compare, began } = setup({ working: true })
68
+
69
+ expect(compare.start('ask_1')).toBe(false)
70
+
71
+ expect(began()).toBe(0)
72
+ expect(compare.current()).toBeNull()
73
+ })
74
+
75
+ test('does not start when history cannot say what it was before', () => {
76
+ const { compare, began } = setup({ before: null })
77
+
78
+ expect(compare.start('ask_1')).toBe(false)
79
+
80
+ expect(began()).toBe(0)
81
+ })
82
+
83
+ test('does not start when the scene would not take a preview (a gesture holds it)', () => {
84
+ const { compare, changes } = setup({ begin: null })
85
+
86
+ expect(compare.start('ask_1')).toBe(false)
87
+
88
+ expect(compare.current()).toBeNull()
89
+ expect(changes).toEqual([])
90
+ })
91
+
92
+ test('a second hold ends the first before it starts', () => {
93
+ const { compare, ended, changes } = setup()
94
+ compare.start('ask_1')
95
+
96
+ compare.start('ask_2')
97
+
98
+ expect(ended).toEqual(['end'])
99
+ expect(compare.current()).toBe('ask_2')
100
+ expect(changes).toEqual(['ask_1', null, 'ask_2'])
101
+ })
102
+ })
103
+
104
+ describe('end', () => {
105
+ test('lets the scene go and clears the tag', () => {
106
+ const { compare, ended, changes } = setup()
107
+ compare.start('ask_1')
108
+
109
+ compare.end()
110
+
111
+ expect(ended).toEqual(['end'])
112
+ expect(compare.current()).toBeNull()
113
+ expect(changes).toEqual(['ask_1', null])
114
+ })
115
+
116
+ test('is idempotent: a pointer-up, a blur and a key can all arrive', () => {
117
+ const { compare, ended, changes } = setup()
118
+ compare.start('ask_1')
119
+
120
+ compare.end()
121
+ compare.end()
122
+ compare.end()
123
+
124
+ expect(ended).toEqual(['end'])
125
+ expect(changes).toEqual(['ask_1', null])
126
+ })
127
+
128
+ test('with nothing held is nothing', () => {
129
+ const { compare, changes } = setup()
130
+
131
+ compare.end()
132
+
133
+ expect(changes).toEqual([])
134
+ })
135
+
136
+ test('when the scene let go by itself (a write), the tag follows', () => {
137
+ const { compare, changes } = setup()
138
+ compare.start('ask_1')
139
+
140
+ compare.released()
141
+
142
+ expect(compare.current()).toBeNull()
143
+ expect(changes).toEqual(['ask_1', null])
144
+ })
145
+ })
@@ -0,0 +1,73 @@
1
+ import type { SceneSnapshot } from '@pascal-app/core'
2
+
3
+ // Hold a control on a pin or its card to see the scene as it was before that turn (the owner,
4
+ // 8 October: "I hold and I see the previous state in 3D"). The core's scene preview does the
5
+ // showing and writes nothing; this decides when a hold may start and keeps the person's view of it
6
+ // (whose, for the "Before" tag) in step with the preview, which ends on its own when a write is
7
+ // about to land.
8
+
9
+ export type CompareDeps = {
10
+ /** The scene as it was before the ask's turn, from the history Undo would restore; null when unknown. */
11
+ before: (askId: string) => SceneSnapshot | null
12
+ /** A turn is writing the scene: it would swallow the preview's writes or land on the wrong state. */
13
+ turnsWorking: () => boolean
14
+ /** The core's `beginScenePreview`: null when the scene will not take one. */
15
+ begin: (snapshot: SceneSnapshot) => { end: () => void } | null
16
+ /** Whose before is on screen, or null: the tag follows. */
17
+ onChange: (askId: string | null) => void
18
+ }
19
+
20
+ /** Why a hold did not start, so the person is told instead of left holding a button that does nothing. */
21
+ export type CompareRefusal = 'turn-working' | 'no-before' | 'history-held'
22
+
23
+ export type Compare = {
24
+ /** Starts showing that ask's before; false when it may not. Ends a hold already up. */
25
+ start: (askId: string) => boolean
26
+ /** Why the last `start` returned false; null after one that started. */
27
+ refusal: () => CompareRefusal | null
28
+ /** Lets go. Idempotent. */
29
+ end: () => void
30
+ /** The preview ended by itself (a write was about to land): clears the tag. */
31
+ released: () => void
32
+ current: () => string | null
33
+ }
34
+
35
+ export function createCompare(deps: CompareDeps): Compare {
36
+ let held: { askId: string; preview: { end: () => void } } | null = null
37
+ let refused: CompareRefusal | null = null
38
+
39
+ const set = (askId: string | null) => deps.onChange(askId)
40
+
41
+ return {
42
+ start(askId) {
43
+ const refuse = (reason: CompareRefusal) => {
44
+ refused = reason
45
+ return false
46
+ }
47
+ if (deps.turnsWorking()) return refuse('turn-working')
48
+ const snapshot = deps.before(askId)
49
+ if (!snapshot) return refuse('no-before')
50
+ if (held) this.end()
51
+ const preview = deps.begin(snapshot)
52
+ if (!preview) return refuse('history-held')
53
+ refused = null
54
+ held = { askId, preview }
55
+ set(askId)
56
+ return true
57
+ },
58
+ end() {
59
+ if (!held) return
60
+ const { preview } = held
61
+ held = null
62
+ preview.end()
63
+ set(null)
64
+ },
65
+ released() {
66
+ if (!held) return
67
+ held = null
68
+ set(null)
69
+ },
70
+ refusal: () => refused,
71
+ current: () => held?.askId ?? null,
72
+ }
73
+ }