@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,217 @@
1
+ import type { PinState } from './types'
2
+
3
+ /**
4
+ * What the chat tells the pin about its ask's turn (the scene/chat boundary agreed with the chat
5
+ * side: queued, working with the live sentence, and one end with what happened).
6
+ */
7
+ export type PinStatus =
8
+ | { askId: string; turnId?: string; status: 'queued' }
9
+ | { askId: string; turnId?: string; status: 'working'; sentence?: string }
10
+ /** The turn waits on a question to the person; `working` follows once it is answered. */
11
+ | { askId: string; turnId?: string; status: 'asking'; question: string }
12
+ | {
13
+ askId: string
14
+ turnId?: string
15
+ status: 'ended'
16
+ outcome: 'completed' | 'cancelled' | 'errored'
17
+ /** The turn ended on a question to the person. */
18
+ asked: boolean
19
+ /** The turn made at least one scene-writing tool call. */
20
+ wroteScene: boolean
21
+ answer?: string
22
+ error?: string
23
+ }
24
+
25
+ export type PinEvent =
26
+ | { type: 'sent' }
27
+ | { type: 'status'; status: PinStatus }
28
+ /** A scene commit while the ask works; `isInTargets` supplies subtree or room membership. */
29
+ | {
30
+ type: 'commit'
31
+ changedNodeIds: readonly string[]
32
+ isInTargets?: (id: string) => boolean
33
+ /** A real semantic commit can write materials or collections without changing any node. */
34
+ committed?: boolean
35
+ }
36
+ | { type: 'undo' }
37
+ | { type: 'undone'; ok: boolean }
38
+ /** The person brought the undone ask back (Redo, while nothing else was done). */
39
+ | { type: 'redo' }
40
+ | { type: 'keep' }
41
+
42
+ export type PinModel = {
43
+ askId: string
44
+ /** The ask's words, for the pin's tooltip. */
45
+ text: string
46
+ targetIds: string[]
47
+ state: PinState
48
+ /** The live step words while it works. */
49
+ sentence?: string
50
+ /** What Pascal is waiting to be told, while the turn is paused on a question. */
51
+ question?: string
52
+ turnId?: string
53
+ /** The card's title for a done pin, built by the caller from the step sentences. */
54
+ title?: string
55
+ answer?: string
56
+ error?: string
57
+ /** Steps whose commits touched a target: each is a small pop on the pin. */
58
+ landed: number
59
+ changedTargetIds: string[]
60
+ alsoChangedIds: string[]
61
+ /** An observed durable scene change, not merely a scene-writing tool being called. */
62
+ committed: boolean
63
+ ended: boolean
64
+ undone: boolean
65
+ kept: boolean
66
+ }
67
+
68
+ export function createPin(init: { askId: string; text: string; targetIds: string[] }): PinModel {
69
+ return {
70
+ askId: init.askId,
71
+ text: init.text,
72
+ targetIds: [...init.targetIds],
73
+ state: 'sent',
74
+ landed: 0,
75
+ changedTargetIds: [],
76
+ alsoChangedIds: [],
77
+ committed: false,
78
+ ended: false,
79
+ undone: false,
80
+ kept: false,
81
+ }
82
+ }
83
+
84
+ const union = (list: string[], more: readonly string[]) => {
85
+ const next = [...list]
86
+ for (const id of more) if (!next.includes(id)) next.push(id)
87
+ return next.length === list.length ? list : next
88
+ }
89
+
90
+ const IN_FLIGHT: readonly PinState[] = ['sent', 'queued', 'working']
91
+ /** A turn that waits on a question is still open: it resumes, or ends still asking. */
92
+ const OPEN: readonly PinState[] = [...IN_FLIGHT, 'needs-input']
93
+
94
+ function reduceStatus(pin: PinModel, status: PinStatus): PinModel {
95
+ if (status.askId !== pin.askId || pin.ended) return pin
96
+ const turnId = status.turnId ?? pin.turnId
97
+ if (status.status === 'queued') {
98
+ if (pin.state !== 'sent' && pin.state !== 'queued') return pin
99
+ return { ...pin, state: 'queued', turnId }
100
+ }
101
+ if (status.status === 'asking') {
102
+ if (!OPEN.includes(pin.state)) return pin
103
+ return { ...pin, state: 'needs-input', turnId, question: status.question }
104
+ }
105
+ if (status.status === 'working') {
106
+ if (!OPEN.includes(pin.state)) return pin
107
+ const { question: _answered, ...rest } = pin
108
+ return { ...rest, state: 'working', turnId, sentence: status.sentence ?? pin.sentence }
109
+ }
110
+ if (!OPEN.includes(pin.state)) return pin
111
+ let state: PinState
112
+ if (status.outcome === 'errored') state = 'error'
113
+ else if (pin.committed) state = 'done'
114
+ else if (status.asked) state = 'needs-input'
115
+ else if (status.outcome === 'cancelled') state = 'no-change'
116
+ else if (!status.wroteScene && status.answer) state = 'answered'
117
+ else state = 'no-change'
118
+ return {
119
+ ...pin,
120
+ state,
121
+ turnId,
122
+ ended: true,
123
+ sentence: undefined,
124
+ ...(pin.sentence ? { title: pin.sentence } : {}),
125
+ ...(status.answer === undefined ? {} : { answer: status.answer }),
126
+ ...(status.error === undefined ? {} : { error: status.error }),
127
+ }
128
+ }
129
+
130
+ /** The pin's state machine (spec 2.6-2.8): pure, so every transition is a test. */
131
+ export function reducePin(pin: PinModel, event: PinEvent): PinModel {
132
+ switch (event.type) {
133
+ case 'sent':
134
+ return pin
135
+ case 'status':
136
+ return reduceStatus(pin, event.status)
137
+ case 'commit': {
138
+ if (pin.state !== 'working' || (event.changedNodeIds.length === 0 && !event.committed))
139
+ return pin
140
+ const isTarget = (id: string) =>
141
+ pin.targetIds.includes(id) ||
142
+ pin.changedTargetIds.includes(id) ||
143
+ event.isInTargets?.(id) === true
144
+ const hits = event.changedNodeIds.filter(isTarget)
145
+ const others = event.changedNodeIds.filter((id) => !isTarget(id))
146
+ const changedTargetIds = union(pin.changedTargetIds, hits)
147
+ const also = union(pin.alsoChangedIds, others)
148
+ const alsoChangedIds = also.some((id) => changedTargetIds.includes(id))
149
+ ? also.filter((id) => !changedTargetIds.includes(id))
150
+ : also
151
+ if (hits.length === 0 && alsoChangedIds === pin.alsoChangedIds && pin.committed) return pin
152
+ return {
153
+ ...pin,
154
+ landed: hits.length > 0 ? pin.landed + 1 : pin.landed,
155
+ changedTargetIds,
156
+ alsoChangedIds,
157
+ committed: true,
158
+ }
159
+ }
160
+ case 'undo':
161
+ return pin.state === 'done' ? { ...pin, state: 'undone', undone: true } : pin
162
+ case 'undone':
163
+ // A failed undo hands the pin back; a good one is already shown.
164
+ return !event.ok && pin.state === 'undone' && pin.undone
165
+ ? { ...pin, state: 'done', undone: false }
166
+ : pin
167
+ case 'redo':
168
+ return pin.state === 'undone' && pin.undone ? { ...pin, state: 'done', undone: false } : pin
169
+ case 'keep':
170
+ return pin.kept ? pin : { ...pin, kept: true }
171
+ }
172
+ }
173
+
174
+ const firstLines = (text: string, count: number) => text.split('\n').slice(0, count).join('\n')
175
+
176
+ /** The card's headline: the error's words, an answer's first two lines, "Undone", or the title. */
177
+ export function cardTitle(pin: PinModel, fallback = 'Done'): string {
178
+ if (pin.state === 'error' && pin.error) return pin.error
179
+ if (pin.state === 'answered' && pin.answer) return firstLines(pin.answer, 2)
180
+ if (pin.state === 'undone') return 'Undone'
181
+ if (pin.title) return pin.title
182
+ if (pin.state === 'done' && pin.committed) {
183
+ const count = new Set([...pin.changedTargetIds, ...pin.alsoChangedIds]).size
184
+ return count
185
+ ? `Pascal changed ${count} element${count === 1 ? '' : 's'}`
186
+ : 'Pascal updated the scene'
187
+ }
188
+ return fallback
189
+ }
190
+
191
+ /** "Also changed 2 other elements": the objects a prompt names can differ from the ones it changes. */
192
+ export function alsoChangedLine(pin: PinModel): string | null {
193
+ const count = pin.alsoChangedIds.length
194
+ if (count === 0) return null
195
+ return `Also changed ${count} other element${count === 1 ? '' : 's'}`
196
+ }
197
+
198
+ /**
199
+ * The card rides its pin, so a camera flight (an Undo playing the build backwards, say) can carry it
200
+ * out from under a pointer that has not moved. That is not the person leaving, and the card holds
201
+ * the Redo, so it stays for a while. The leave event of a pointer that walked out is dispatched
202
+ * before the move that caused it, so it is told by position: `at` is where the leave says the
203
+ * pointer is, `last` where the last move left it; a pointer that did not move is where it was.
204
+ * A finger leaves without a move whenever it lifts, so only a mouse can be carried.
205
+ */
206
+ export function cardCarriedFromPointer(leave: {
207
+ pointerType: string
208
+ at: { x: number; y: number }
209
+ last: { x: number; y: number } | null
210
+ }): boolean {
211
+ return (
212
+ leave.pointerType === 'mouse' &&
213
+ leave.last !== null &&
214
+ Math.abs(leave.at.x - leave.last.x) < 0.5 &&
215
+ Math.abs(leave.at.y - leave.last.y) < 0.5
216
+ )
217
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type PlacementInput, type PlacementState, placeBubble } from './placement'
3
+ import type { Rect } from './types'
4
+
5
+ // The bubble sits beside its target and never covers it (spec 2.4, placement). Written first: a
6
+ // bubble over the thing it asks about; a side that flips on every frame at the boundary; a bubble
7
+ // that detaches from its anchor; a bubble that leaves the viewport.
8
+
9
+ const VIEWPORT: Rect = { x0: 12, y0: 56, x1: 1000, y1: 700 }
10
+ const SIZE = { w: 320, h: 180 }
11
+ const FRESH: PlacementState = { side: null, badMs: 0 }
12
+
13
+ const input = (more: Partial<PlacementInput> = {}): PlacementInput => ({
14
+ box: { x0: 400, y0: 300, x1: 500, y1: 400 },
15
+ click: { x: 450, y: 350 },
16
+ size: SIZE,
17
+ viewport: VIEWPORT,
18
+ offset: { x: 0, y: 0 },
19
+ first: true,
20
+ ...more,
21
+ })
22
+
23
+ const bubbleRect = (pos: { x: number; y: number }): Rect => ({
24
+ x0: pos.x,
25
+ y0: pos.y,
26
+ x1: pos.x + SIZE.w,
27
+ y1: pos.y + SIZE.h,
28
+ })
29
+
30
+ const touches = (a: Rect, b: Rect) => !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1)
31
+
32
+ describe('which side the bubble takes', () => {
33
+ test('is clamped inside the viewport', () => {
34
+ const box = { x0: 30, y0: 60, x1: 90, y1: 120 }
35
+ const placed = placeBubble(input({ box, click: { x: 60, y: 90 } }), FRESH, 16)
36
+
37
+ expect(placed.pos.x).toBeGreaterThanOrEqual(VIEWPORT.x0)
38
+ expect(placed.pos.y).toBeGreaterThanOrEqual(VIEWPORT.y0)
39
+ expect(placed.pos.x + SIZE.w).toBeLessThanOrEqual(VIEWPORT.x1)
40
+ expect(placed.pos.y + SIZE.h).toBeLessThanOrEqual(VIEWPORT.y1)
41
+ })
42
+
43
+ test('never covers the target when a side fits', () => {
44
+ for (const box of [
45
+ { x0: 400, y0: 300, x1: 500, y1: 400 },
46
+ { x0: 700, y0: 300, x1: 800, y1: 400 },
47
+ { x0: 50, y0: 300, x1: 990, y1: 400 },
48
+ { x0: 50, y0: 560, x1: 990, y1: 690 },
49
+ ]) {
50
+ const click = { x: (box.x0 + box.x1) / 2, y: (box.y0 + box.y1) / 2 }
51
+ const placed = placeBubble(input({ box, click }), FRESH, 16)
52
+
53
+ expect(touches(bubbleRect(placed.pos), box)).toBe(false)
54
+ }
55
+ })
56
+
57
+ test('a target filling the view puts the bubble beside the click, outside a 48 px disc', () => {
58
+ const box = { x0: 12, y0: 56, x1: 900, y1: 690 }
59
+ const click = { x: 450, y: 300 }
60
+ const placed = placeBubble(input({ box, click }), FRESH, 16)
61
+ const disc = { x0: click.x - 48, y0: click.y - 48, x1: click.x + 48, y1: click.y + 48 }
62
+
63
+ expect(placed.side).toBe('right')
64
+ expect(placed.pos.x).toBe(click.x + 48 + 16)
65
+ expect(touches(bubbleRect(placed.pos), disc)).toBe(false)
66
+ })
67
+
68
+ test('the user’s drag offset moves it, and the tail follows', () => {
69
+ const plain = placeBubble(input(), FRESH, 16)
70
+ const moved = placeBubble(input({ offset: { x: 30, y: -20 } }), FRESH, 16)
71
+
72
+ expect(moved.pos).toEqual({ x: plain.pos.x + 30, y: plain.pos.y - 20 })
73
+ expect(moved.tail?.y).toBe((plain.tail?.y ?? 0) + 20)
74
+ })
75
+ })
76
+
77
+ describe('hysteresis: a side is kept until it has been wrong for 150 ms', () => {
78
+ const blockedRight = input({
79
+ box: { x0: 700, y0: 300, x1: 800, y1: 400 },
80
+ click: { x: 750, y: 350 },
81
+ first: false,
82
+ })
83
+
84
+ test('no switch before 150 ms', () => {
85
+ const first = placeBubble(blockedRight, { side: 'right', badMs: 0 }, 100)
86
+
87
+ expect(first.side).toBe('right')
88
+ expect(first.glide).toBe(false)
89
+ expect(first.state).toEqual({ side: 'right', badMs: 100 })
90
+ })
91
+
92
+ test('the switch after 150 ms glides, and resets the clock', () => {
93
+ const first = placeBubble(blockedRight, { side: 'right', badMs: 0 }, 100)
94
+ const second = placeBubble(blockedRight, first.state, 60)
95
+
96
+ expect(second.side).toBe('left')
97
+ expect(second.glide).toBe(true)
98
+ expect(second.state).toEqual({ side: 'left', badMs: 0 })
99
+ })
100
+
101
+ test('a side that is right again clears the count', () => {
102
+ const wrong = placeBubble(blockedRight, { side: 'right', badMs: 0 }, 100)
103
+ const right = placeBubble(input({ first: false }), wrong.state, 16)
104
+
105
+ expect(right.side).toBe('right')
106
+ expect(right.state.badMs).toBe(0)
107
+ })
108
+
109
+ test('with every side blocked it still obeys the 150 ms rule', () => {
110
+ const viewport = { x0: 400, y0: 200, x1: 620, y1: 420 }
111
+ const crowded = input({
112
+ box: { x0: 400, y0: 200, x1: 620, y1: 420 },
113
+ click: { x: 500, y: 300 },
114
+ viewport,
115
+ first: false,
116
+ })
117
+ const early = placeBubble(crowded, { side: 'left', badMs: 0 }, 100)
118
+ const late = placeBubble(crowded, early.state, 100)
119
+
120
+ expect(early.side).toBe('left')
121
+ expect(early.glide).toBe(false)
122
+ expect(late.side).toBe('right')
123
+ expect(late.glide).toBe(true)
124
+ })
125
+ })
126
+
127
+ describe('the tail', () => {
128
+ test('is clamped 16 px from the corners', () => {
129
+ const placed = placeBubble(
130
+ input({ box: { x0: 400, y0: 300, x1: 500, y1: 400 }, click: { x: 450, y: 305 } }),
131
+ FRESH,
132
+ 16,
133
+ )
134
+
135
+ expect(placed.tail?.edge).toBe('left')
136
+ expect(placed.tail?.y).toBeGreaterThanOrEqual(16)
137
+ expect(placed.tail?.y).toBeLessThanOrEqual(180 - 16)
138
+ })
139
+
140
+ test('is hidden when the click is under the bubble', () => {
141
+ const box = { x0: 12, y0: 56, x1: 1000, y1: 700 }
142
+ const placed = placeBubble(
143
+ input({ box, click: { x: 600, y: 300 }, offset: { x: -200, y: 0 } }),
144
+ FRESH,
145
+ 16,
146
+ )
147
+
148
+ expect(placed.tail).toBeNull()
149
+ })
150
+ })
151
+
152
+ describe('docking', () => {
153
+ test('a click off the viewport docks at the nearest edge, the tail turned to it', () => {
154
+ const placed = placeBubble(
155
+ input({ click: { x: -50, y: 300 }, box: { x0: -80, y0: 250, x1: -20, y1: 350 } }),
156
+ FRESH,
157
+ 16,
158
+ )
159
+
160
+ expect(placed.side).toBe('dock')
161
+ expect(placed.pos.x).toBe(VIEWPORT.x0)
162
+ expect(placed.tail).toEqual({ edge: 'left', x: 0, y: 90 })
163
+ })
164
+
165
+ test('docks on the right and at the bottom too', () => {
166
+ const right = placeBubble(
167
+ input({ click: { x: 1300, y: 300 }, box: { x0: 1280, y0: 250, x1: 1320, y1: 350 } }),
168
+ FRESH,
169
+ 16,
170
+ )
171
+ const bottom = placeBubble(
172
+ input({ click: { x: 500, y: 900 }, box: { x0: 480, y0: 880, x1: 520, y1: 920 } }),
173
+ FRESH,
174
+ 16,
175
+ )
176
+
177
+ expect(right.pos.x).toBe(VIEWPORT.x1 - SIZE.w)
178
+ expect(right.tail?.edge).toBe('right')
179
+ expect(bottom.pos.y).toBe(VIEWPORT.y1 - SIZE.h)
180
+ expect(bottom.tail?.edge).toBe('bottom')
181
+ })
182
+
183
+ test('a click that cannot be projected docks inside the view with no tail', () => {
184
+ const placed = placeBubble(input({ click: null }), FRESH, 16)
185
+
186
+ expect(placed.side).toBe('dock')
187
+ expect(placed.tail).toBeNull()
188
+ expect(placed.pos.x).toBeGreaterThanOrEqual(VIEWPORT.x0)
189
+ expect(placed.pos.x + SIZE.w).toBeLessThanOrEqual(VIEWPORT.x1)
190
+ })
191
+
192
+ test('coming back from the dock re-chooses a side and glides', () => {
193
+ const docked = placeBubble(
194
+ input({ click: { x: -50, y: 300 }, box: { x0: -80, y0: 250, x1: -20, y1: 350 } }),
195
+ FRESH,
196
+ 16,
197
+ )
198
+ const back = placeBubble(input({ first: false }), docked.state, 16)
199
+
200
+ expect(back.side).toBe('right')
201
+ expect(back.glide).toBe(true)
202
+ })
203
+ })
@@ -0,0 +1,141 @@
1
+ import { BIG_BOX_DISC, BUBBLE_GAP, SIDE_HYSTERESIS_MS } from './choreography'
2
+ import type { BubbleSide, Rect, ScreenPoint } from './types'
3
+
4
+ export type PlacementInput = {
5
+ /** The targets' screen box. */
6
+ box: Rect
7
+ /** Where the finger landed on screen; null when the anchor cannot be projected (behind the camera). */
8
+ click: ScreenPoint | null
9
+ /** The bubble's size. */
10
+ size: { w: number; h: number }
11
+ /** The free viewport: the canvas minus the chat column and toolbars, already inset. */
12
+ viewport: Rect
13
+ /** Where the person dragged the bubble to by its context row, from its placed position. */
14
+ offset: ScreenPoint
15
+ /** The first placement of this bubble: no hysteresis yet. */
16
+ first: boolean
17
+ }
18
+
19
+ export type PlacementState = {
20
+ side: BubbleSide | null
21
+ /** How long the current side has been invalid, in ms. */
22
+ badMs: number
23
+ }
24
+
25
+ export type TailEdge = 'left' | 'right' | 'top' | 'bottom'
26
+
27
+ export type Placement = {
28
+ /** The bubble's top-left on screen, drag offset included. */
29
+ pos: ScreenPoint
30
+ side: BubbleSide
31
+ /** Where the tail sits on the bubble's edge, relative to the bubble; null when the click is under it. */
32
+ tail: { edge: TailEdge; x: number; y: number } | null
33
+ /** The side just changed: the caller springs to `pos` instead of snapping. */
34
+ glide: boolean
35
+ /** The click inside the bubble, for `transform-origin` on the open motion. */
36
+ origin: ScreenPoint
37
+ state: PlacementState
38
+ }
39
+
40
+ const SIDES = ['right', 'left', 'below', 'above'] as const
41
+ const TAIL_CORNER = 16
42
+ const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
43
+
44
+ const area = (rect: Rect) => Math.max(0, rect.x1 - rect.x0) * Math.max(0, rect.y1 - rect.y0)
45
+
46
+ /**
47
+ * Beside the target, never over it; the anchor is tracked 1:1 by the caller each frame, so this is
48
+ * only the choice of side. A side is kept until it has been invalid for 150 ms, then the next
49
+ * free one is taken and the move glides; with the anchor off screen the bubble docks at the nearest
50
+ * edge (spec 2.4, rules 1-5).
51
+ */
52
+ export function placeBubble(input: PlacementInput, state: PlacementState, dtMs: number): Placement {
53
+ const { box, size, viewport: V, offset } = input
54
+ const { w: bw, h: bh } = size
55
+ const fit = (p: ScreenPoint): ScreenPoint => ({
56
+ x: clamp(p.x, V.x0, Math.max(V.x0, V.x1 - bw)),
57
+ y: clamp(p.y, V.y0, Math.max(V.y0, V.y1 - bh)),
58
+ })
59
+ const anchor =
60
+ input.click ??
61
+ (Number.isFinite(box.x0 + box.x1 + box.y0 + box.y1)
62
+ ? { x: (box.x0 + box.x1) / 2, y: (box.y0 + box.y1) / 2 }
63
+ : { x: (V.x0 + V.x1) / 2, y: (V.y0 + V.y1) / 2 })
64
+ const onScreen =
65
+ input.click !== null &&
66
+ anchor.x >= V.x0 &&
67
+ anchor.x <= V.x1 &&
68
+ anchor.y >= V.y0 &&
69
+ anchor.y <= V.y1
70
+
71
+ let pos: ScreenPoint
72
+ let side: BubbleSide
73
+ let badMs = state.badMs
74
+ if (onScreen) {
75
+ // A target filling the view cannot be avoided: stand clear of the click instead.
76
+ const big = area(box) > 0.5 * area(V)
77
+ const R: Rect = big
78
+ ? {
79
+ x0: anchor.x - BIG_BOX_DISC,
80
+ x1: anchor.x + BIG_BOX_DISC,
81
+ y0: anchor.y - BIG_BOX_DISC,
82
+ y1: anchor.y + BIG_BOX_DISC,
83
+ }
84
+ : box
85
+ const at: Record<(typeof SIDES)[number], ScreenPoint> = {
86
+ right: { x: R.x1 + BUBBLE_GAP, y: anchor.y - bh / 2 },
87
+ left: { x: R.x0 - BUBBLE_GAP - bw, y: anchor.y - bh / 2 },
88
+ below: { x: anchor.x - bw / 2, y: R.y1 + BUBBLE_GAP },
89
+ above: { x: anchor.x - bw / 2, y: R.y0 - BUBBLE_GAP - bh },
90
+ }
91
+ const free = (s: (typeof SIDES)[number]) => {
92
+ const p = fit(at[s])
93
+ return p.x + bw < R.x0 - 2 || p.x > R.x1 + 2 || p.y + bh < R.y0 - 2 || p.y > R.y1 + 2
94
+ }
95
+ const best = SIDES.find(free) ?? 'right'
96
+ const held = state.side && state.side !== 'dock' && !input.first ? state.side : null
97
+ let chosen: (typeof SIDES)[number]
98
+ if (!held) {
99
+ chosen = best
100
+ badMs = 0
101
+ } else if (!free(held)) {
102
+ badMs += dtMs
103
+ if (badMs > SIDE_HYSTERESIS_MS) {
104
+ chosen = best
105
+ badMs = 0
106
+ } else chosen = held
107
+ } else {
108
+ chosen = held
109
+ badMs = 0
110
+ }
111
+ side = chosen
112
+ pos = fit(at[chosen])
113
+ } else {
114
+ side = 'dock'
115
+ badMs = 0
116
+ pos = fit({ x: anchor.x - bw / 2, y: anchor.y - bh / 2 })
117
+ }
118
+
119
+ const glide = !input.first && state.side !== null && side !== state.side
120
+ const placed = { x: pos.x + offset.x, y: pos.y + offset.y }
121
+
122
+ // The tail points at the click from the bubble's nearest edge; under the bubble there is none.
123
+ let tail: Placement['tail'] = null
124
+ const lx = anchor.x - placed.x
125
+ const ly = anchor.y - placed.y
126
+ if (input.click !== null) {
127
+ if (lx < 0) tail = { edge: 'left', x: 0, y: clamp(ly, TAIL_CORNER, bh - TAIL_CORNER) }
128
+ else if (lx > bw) tail = { edge: 'right', x: bw, y: clamp(ly, TAIL_CORNER, bh - TAIL_CORNER) }
129
+ else if (ly < 0) tail = { edge: 'top', x: clamp(lx, TAIL_CORNER, bw - TAIL_CORNER), y: 0 }
130
+ else if (ly > bh) tail = { edge: 'bottom', x: clamp(lx, TAIL_CORNER, bw - TAIL_CORNER), y: bh }
131
+ }
132
+
133
+ return {
134
+ pos: placed,
135
+ side,
136
+ tail,
137
+ glide,
138
+ origin: { x: clamp(lx, 0, bw), y: clamp(ly, 0, bh) },
139
+ state: { side, badMs },
140
+ }
141
+ }
@@ -0,0 +1,34 @@
1
+ // The pointer's last position on the page, shared by whatever follows it (the label chip), so the
2
+ // chip moves on the frame of the pointer event, never after a React render.
3
+
4
+ type PointerFn = (x: number, y: number) => void
5
+
6
+ let x = -1
7
+ let y = -1
8
+ let attached = false
9
+ const subscribers = new Set<PointerFn>()
10
+
11
+ function onMove(event: PointerEvent) {
12
+ x = event.clientX
13
+ y = event.clientY
14
+ for (const fn of subscribers) fn(x, y)
15
+ }
16
+
17
+ export function getPointer(): { x: number; y: number } | null {
18
+ return x < 0 ? null : { x, y }
19
+ }
20
+
21
+ export function subscribePointer(fn: PointerFn): () => void {
22
+ subscribers.add(fn)
23
+ if (!attached && typeof window !== 'undefined') {
24
+ window.addEventListener('pointermove', onMove, { passive: true, capture: true })
25
+ attached = true
26
+ }
27
+ return () => {
28
+ subscribers.delete(fn)
29
+ if (subscribers.size === 0 && attached) {
30
+ window.removeEventListener('pointermove', onMove, { capture: true })
31
+ attached = false
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,83 @@
1
+ import { Vector3 } from 'three'
2
+ import { getEditorThreeContext } from '../../components/editor/three-context-bridge'
3
+ import { BUBBLE_BOTTOM_INSET, BUBBLE_TOP_INSET, VIEWPORT_INSET } from './overlay-metrics'
4
+ import type { Rect, ScreenPoint } from './types'
5
+
6
+ // World to screen for the DOM overlay, read from the live editor camera each frame: the overlay
7
+ // lives outside the R3F tree, so it asks the same camera the canvas draws with. Screen units are
8
+ // CSS pixels in viewport coordinates (the overlay is `position: fixed`).
9
+
10
+ const scratch = new Vector3()
11
+
12
+ export type Projected = ScreenPoint & { ok: boolean }
13
+
14
+ /** The canvas the editor draws in, or null before it mounts. */
15
+ export function canvasRect(): DOMRect | null {
16
+ return getEditorThreeContext()?.domElement.getBoundingClientRect() ?? null
17
+ }
18
+
19
+ /** A world point on the page; `ok` is false behind the camera or outside the clip volume. */
20
+ export function projectWorld(point: readonly [number, number, number]): Projected {
21
+ const three = getEditorThreeContext()
22
+ if (!three) return { x: 0, y: 0, ok: false }
23
+ const rect = three.domElement.getBoundingClientRect()
24
+ scratch.set(point[0], point[1], point[2])
25
+ three.camera.updateMatrixWorld()
26
+ scratch.project(three.camera)
27
+ const ok = scratch.z > -1 && scratch.z < 1
28
+ return {
29
+ x: rect.left + (scratch.x * 0.5 + 0.5) * rect.width,
30
+ y: rect.top + (-scratch.y * 0.5 + 0.5) * rect.height,
31
+ ok,
32
+ }
33
+ }
34
+
35
+ /** The screen box of a world box (its eight corners projected); null when nothing of it is in front of the camera. */
36
+ export function projectBox(box: {
37
+ min: readonly [number, number, number]
38
+ max: readonly [number, number, number]
39
+ }): Rect | null {
40
+ let x0 = Number.POSITIVE_INFINITY
41
+ let y0 = Number.POSITIVE_INFINITY
42
+ let x1 = Number.NEGATIVE_INFINITY
43
+ let y1 = Number.NEGATIVE_INFINITY
44
+ let any = false
45
+ for (const x of [box.min[0], box.max[0]])
46
+ for (const y of [box.min[1], box.max[1]])
47
+ for (const z of [box.min[2], box.max[2]]) {
48
+ const p = projectWorld([x, y, z])
49
+ if (!p.ok) continue
50
+ any = true
51
+ x0 = Math.min(x0, p.x)
52
+ y0 = Math.min(y0, p.y)
53
+ x1 = Math.max(x1, p.x)
54
+ y1 = Math.max(y1, p.y)
55
+ }
56
+ return any ? { x0, y0, x1, y1 } : null
57
+ }
58
+
59
+ /** The union of several screen boxes. */
60
+ export function unionRect(rects: readonly Rect[]): Rect | null {
61
+ if (rects.length === 0) return null
62
+ return rects.reduce((all, r) => ({
63
+ x0: Math.min(all.x0, r.x0),
64
+ y0: Math.min(all.y0, r.y0),
65
+ x1: Math.max(all.x1, r.x1),
66
+ y1: Math.max(all.y1, r.y1),
67
+ }))
68
+ }
69
+
70
+ /**
71
+ * The free viewport a bubble may sit in: the canvas inset on every side, and clear of the top bar
72
+ * and the action bar the editor floats over it.
73
+ */
74
+ export function freeViewport(): Rect | null {
75
+ const rect = canvasRect()
76
+ if (!rect) return null
77
+ return {
78
+ x0: rect.left + VIEWPORT_INSET,
79
+ y0: rect.top + BUBBLE_TOP_INSET,
80
+ x1: rect.right - VIEWPORT_INSET,
81
+ y1: rect.bottom - BUBBLE_BOTTOM_INSET,
82
+ }
83
+ }