@pascal-app/editor 1.0.3 → 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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,1391 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ beginScenePreview,
5
+ emitter,
6
+ hasPointAskHandler,
7
+ type NodeEvent,
8
+ type PointAskStatus,
9
+ type PointCamera,
10
+ resolveLevelId,
11
+ type SceneCommit,
12
+ type SceneSnapshot,
13
+ sceneRegistry,
14
+ submitPointAsk,
15
+ subscribeSceneCommits,
16
+ subscribeScenePreview,
17
+ useScene,
18
+ } from '@pascal-app/core'
19
+ import { sceneViewBounds, sceneViewPose } from '@pascal-app/core/agent-operations'
20
+ import { retractNodes, useViewer } from '@pascal-app/viewer'
21
+ import { type Object3D, Vector2, Vector3 } from 'three'
22
+ import {
23
+ resolveRoofSegmentSelectionTarget,
24
+ roomForEvent,
25
+ } from '../../components/editor/selection-manager'
26
+ import { getEditorThreeContext } from '../../components/editor/three-context-bridge'
27
+ import { resolveEditorRoomHit } from '../../hooks/use-selected-room'
28
+ import { cameraPoseStore } from '../../store/camera-pose-store'
29
+ import useEditor, { getActiveContinuationContext } from '../../store/use-editor'
30
+ import useFollowPascal from '../../store/use-follow-pascal'
31
+ import useInteractionScope from '../../store/use-interaction-scope'
32
+ import {
33
+ type BubbleTarget,
34
+ type PinRecord,
35
+ type PointBubble,
36
+ type PointHit,
37
+ type PointHover,
38
+ usePointAsk,
39
+ type WorldBox,
40
+ } from '../../store/use-point-ask'
41
+ import { isViewVisible, VIEW_3D } from '../../store/view-layout'
42
+ import { keyCyclableContinuationContext } from '../continuation'
43
+ import { resolveCanvasSelectionNode } from '../selection-routing'
44
+ import { triggerSFX } from '../sfx-bus'
45
+ import { buildPointContext } from './build-context'
46
+ import { type CapturedCrop, captureViewCrop } from './capture-crop'
47
+ import { CLICK_SLOP_PX, MAX_TARGETS, TAP_LATCH_MS } from './choreography'
48
+ import { createCompare } from './compare'
49
+ import { describeTarget } from './describe-target'
50
+ import {
51
+ createdSince,
52
+ type HistoryMark,
53
+ markHistory,
54
+ stateAfterMark,
55
+ stepsSince,
56
+ } from './history-marks'
57
+ import { outlineBox } from './outline-box'
58
+ import { resolvePointPick } from './pick'
59
+ import { createPin } from './pin-machine'
60
+ import { canvasRect, projectBox, unionRect } from './projector'
61
+ import {
62
+ formatRegionSize,
63
+ isRegionBigEnough,
64
+ nearestEdgePoint,
65
+ rankInRegion,
66
+ rectFromDrag,
67
+ regionFractions,
68
+ regionLabel,
69
+ regionSizeMetres,
70
+ } from './region'
71
+ import { planRetry } from './retry'
72
+ import { pointCommitEvent } from './scene-commit'
73
+ import type { Rect } from './types'
74
+
75
+ // Point and ask's session (the owner, 8 October): the mode's entry and exit, what the pointer
76
+ // picks, the bubble's lifecycle, the send, and the pins' link to the chat and the scene. It owns
77
+ // no pixels: the overlay draws from `usePointAsk`, this module writes to it.
78
+
79
+ const CROP_WAIT_MS = 1_500
80
+
81
+ type Down = {
82
+ x: number
83
+ y: number
84
+ moved: boolean
85
+ hover: PointHover | null
86
+ capturedAt: string
87
+ camera: PointCamera | null
88
+ crop: Promise<CapturedCrop | null>
89
+ shift: boolean
90
+ }
91
+
92
+ let stopSession: (() => void) | null = null
93
+
94
+ // ─── what is pointed at ─────────────────────────────────────────────────────
95
+
96
+ /** The editor's own lookups for a hit, handed to the pure pick. */
97
+ function pickFor(event: NodeEvent, alt: boolean, selectedTargetIds: readonly string[]) {
98
+ const editor = useEditor.getState()
99
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
100
+ const levelId = useViewer.getState().selection.levelId ?? null
101
+ return resolvePointPick({
102
+ event,
103
+ alt,
104
+ selectedTargetIds,
105
+ nodes,
106
+ currentLevelId: levelId,
107
+ phase: editor.phase,
108
+ zonesPickable: false,
109
+ resolveRoom: (e) => roomForEvent(e),
110
+ roomAtPoint: (level, x, z) => resolveEditorRoomHit(null, level, [x, z], '3d'),
111
+ resolveRoofSegment: (e) => resolveRoofSegmentSelectionTarget(e),
112
+ resolveNode: (node) =>
113
+ resolveCanvasSelectionNode({
114
+ node,
115
+ nodes,
116
+ selectedIds: useViewer.getState().selection.selectedIds,
117
+ }),
118
+ })
119
+ }
120
+
121
+ /** The element's world box as view_scene frames it, or null when it has none to draw. */
122
+ function worldBoxOf(nodes: Record<string, AnyNode>, id: string): WorldBox | null {
123
+ try {
124
+ const node = nodes[id]
125
+ return node ? outlineBox(node.type, sceneViewBounds(nodes, id)) : null
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ function targetFor(id: string): BubbleTarget | null {
132
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
133
+ const node = nodes[id]
134
+ if (!node) return null
135
+ const viewer = useViewer.getState()
136
+ const described = describeTarget(node, nodes, {
137
+ unit: viewer.unit,
138
+ metricNotation: viewer.metricNotation,
139
+ })
140
+ const {
141
+ sizes: _sizes,
142
+ levelId: _levelId,
143
+ zoneId: _zoneId,
144
+ parentId: _parentId,
145
+ ...target
146
+ } = described
147
+ return { ...target, box: worldBoxOf(nodes, id) }
148
+ }
149
+
150
+ const targetCache = new Map<string, BubbleTarget>()
151
+
152
+ function cachedTarget(id: string): BubbleTarget | null {
153
+ const cached = targetCache.get(id)
154
+ if (cached) return cached
155
+ const target = targetFor(id)
156
+ if (target) targetCache.set(id, target)
157
+ return target
158
+ }
159
+
160
+ /** A pick under the cursor, as the store's hover. */
161
+ function hoverFromEvent(event: NodeEvent): PointHover | null {
162
+ const state = usePointAsk.getState()
163
+ const pick = pickFor(event, state.alt, state.bubble?.targets.map((t) => t.id) ?? [])
164
+ if (!pick) return null
165
+ const target = cachedTarget(pick.nodeId)
166
+ if (!target) return null
167
+ const point = event.position as [number, number, number]
168
+ const normal = (event.normal ?? [0, 1, 0]) as [number, number, number]
169
+ const hit: PointHit = { point, normal, ...(pick.face ? { face: pick.face } : {}) }
170
+ return { target, ambiguous: pick.ambiguous, hit }
171
+ }
172
+
173
+ // ─── the camera the person pointed from ─────────────────────────────────────
174
+
175
+ function currentCamera(): PointCamera | null {
176
+ const pose = cameraPoseStore.getState().pose
177
+ const rect = canvasRect()
178
+ if (!(pose && rect)) return null
179
+ return {
180
+ position: pose.position,
181
+ target: pose.target,
182
+ fov: pose.fov ?? 50,
183
+ aspect: rect.width / Math.max(1, rect.height),
184
+ projection: pose.projection,
185
+ ...(pose.projection === 'orthographic' && pose.viewWidth !== undefined
186
+ ? { viewWidth: pose.viewWidth }
187
+ : {}),
188
+ }
189
+ }
190
+
191
+ // ─── entering and leaving ───────────────────────────────────────────────────
192
+
193
+ let suspendedTool: ReturnType<typeof useEditor.getState>['toolMode'] | null = null
194
+ let cDown: number | null = null
195
+ let leaveOnClose = false
196
+
197
+ /** Whether the mode can start now: a host answers asks, the view is 3D, nothing else holds the pointer. */
198
+ export function canPoint(): boolean {
199
+ if (!hasPointAskHandler()) return false
200
+ const editor = useEditor.getState()
201
+ if (!isViewVisible(editor, VIEW_3D) || editor.isFirstPersonMode || editor.isPreviewMode)
202
+ return false
203
+ if (editor.workspaceMode !== 'edit') return false
204
+ return !!getEditorThreeContext()
205
+ }
206
+
207
+ /**
208
+ * Whether C is the mode's key right now. It is the tool's while a gesture is under way or the armed
209
+ * tool cycles something with it (the placement tools' "place once"); an armed tool that is only
210
+ * waiting (a wall about to be drawn) is put aside and comes back when the mode leaves.
211
+ */
212
+ function cIsOurs(): boolean {
213
+ const editor = useEditor.getState()
214
+ if (editor.mode !== 'select' && editor.mode !== 'build') return false
215
+ if (useInteractionScope.getState().scope.kind !== 'idle') return false
216
+ return keyCyclableContinuationContext(getActiveContinuationContext()) === null
217
+ }
218
+
219
+ export function isPointing(): boolean {
220
+ return usePointAsk.getState().active
221
+ }
222
+
223
+ export function enterPointMode(latched: boolean): boolean {
224
+ if (usePointAsk.getState().active) return true
225
+ if (!canPoint()) return false
226
+ const editor = useEditor.getState()
227
+ // An armed tool is put aside and comes back when the mode leaves.
228
+ if (editor.mode !== 'select') {
229
+ suspendedTool = editor.toolMode
230
+ editor.armToolMode({ mode: 'select' })
231
+ } else suspendedTool = null
232
+ const scope = useInteractionScope.getState()
233
+ if (scope.scope.kind !== 'idle') scope.end()
234
+ scope.begin({ kind: 'pointing' })
235
+ useViewer.getState().setHoverHighlightMode('point')
236
+ useViewer.getState().setHoveredId(null)
237
+ usePointAsk.getState().setActive(true, latched)
238
+ setCursor(true)
239
+ return true
240
+ }
241
+
242
+ export function leavePointMode() {
243
+ const state = usePointAsk.getState()
244
+ if (!state.active) return
245
+ endCompare()
246
+ // Off first: ending the scope below must not find the mode still on and leave it again.
247
+ state.setActive(false)
248
+ cDown = null
249
+ leaveOnClose = false
250
+ regionCandidates = null
251
+ const viewer = useViewer.getState()
252
+ viewer.setHoveredId(null)
253
+ viewer.setPointedIds(null)
254
+ useInteractionScope.getState().endIf((s) => s.kind === 'pointing')
255
+ viewer.setHoverHighlightMode(useEditor.getState().mode === 'delete' ? 'delete' : 'default')
256
+ state.setRegion(null)
257
+ targetCache.clear()
258
+ setCursor(false)
259
+ if (suspendedTool) {
260
+ const tool = suspendedTool
261
+ suspendedTool = null
262
+ useEditor
263
+ .getState()
264
+ .armToolMode(tool as Parameters<ReturnType<typeof useEditor.getState>['armToolMode']>[0])
265
+ }
266
+ }
267
+
268
+ const CURSOR =
269
+ "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'><path d='M5 3l15 7.2-6.4 1.9-2.3 6.4z' fill='%23a99bff' stroke='%2316171b' stroke-width='1.4' stroke-linejoin='round'/><rect x='15' y='15' width='11' height='9' rx='3' fill='%23edece9' stroke='%2316171b' stroke-width='1'/><text x='20.5' y='22' font-family='system-ui,sans-serif' font-size='7.5' font-weight='700' text-anchor='middle' fill='%2316171b'>C</text></svg>\") 5 3, crosshair"
270
+
271
+ function setCursor(on: boolean) {
272
+ const canvas = getEditorThreeContext()?.domElement
273
+ if (canvas) canvas.style.cursor = on ? CURSOR : ''
274
+ }
275
+
276
+ // ─── the bubble ─────────────────────────────────────────────────────────────
277
+
278
+ function openBubble(
279
+ targets: BubbleTarget[],
280
+ hit: PointHit,
281
+ gesture: PointBubble['gesture'],
282
+ down: Pick<Down, 'crop' | 'capturedAt' | 'camera'> | null,
283
+ area?: PointBubble['area'],
284
+ changesAsk?: PointBubble['changesAsk'],
285
+ ) {
286
+ const state = usePointAsk.getState()
287
+ // Asking for more starts from an empty field; the words just sent are not a draft to come back to.
288
+ const draft = changesAsk
289
+ ? ''
290
+ : (state.bubble?.draft ?? state.drafts[targets[0]?.id ?? area?.ids.join() ?? ''] ?? '')
291
+ const frame = canvasRect()
292
+ const crop = down?.crop
293
+ const bubble: PointBubble = {
294
+ targets,
295
+ anchor: hit.point,
296
+ hit,
297
+ gesture,
298
+ capturedAt: down?.capturedAt ?? new Date().toISOString(),
299
+ camera: down?.camera ?? currentCamera(),
300
+ crop: { state: crop ? 'pending' : 'none' },
301
+ draft,
302
+ error: null,
303
+ ...(area && frame ? { area, region: regionFractions(area.rect, frame) } : {}),
304
+ ...(changesAsk ? { changesAsk } : {}),
305
+ }
306
+ state.openBubble(bubble)
307
+ // The bubble's targets keep the point outline while the pointer goes on naming the next pick.
308
+ useViewer
309
+ .getState()
310
+ .setPointedIds(
311
+ targets.length ? (targets.map((t) => t.id) as never) : ((area?.ids as never) ?? null),
312
+ )
313
+ if (crop) {
314
+ const opened = bubble
315
+ crop.then(
316
+ (value) => {
317
+ const current = usePointAsk.getState().bubble
318
+ if (!(current && current.capturedAt === opened.capturedAt)) return
319
+ usePointAsk.getState().patchBubble({
320
+ crop: value
321
+ ? {
322
+ state: 'ready',
323
+ dataUrl: value.dataUrl,
324
+ width: value.width,
325
+ height: value.height,
326
+ marked: false,
327
+ }
328
+ : { state: 'failed' },
329
+ })
330
+ },
331
+ () => undefined,
332
+ )
333
+ }
334
+ downCrops.set(bubble.capturedAt, crop ?? Promise.resolve(null))
335
+ }
336
+
337
+ /** The pending crop of each bubble, kept so a send can wait for it. */
338
+ const downCrops = new Map<string, Promise<CapturedCrop | null>>()
339
+
340
+ export function closeBubble(kind: 'send' | 'dismiss' = 'dismiss') {
341
+ const state = usePointAsk.getState()
342
+ const bubble = state.bubble
343
+ if (!bubble) return
344
+ // Words that were not sent are kept for the next time the same element is pointed at.
345
+ if (kind === 'dismiss') state.rememberDraft(bubble.targets[0]?.id ?? '', bubble.draft)
346
+ downCrops.delete(bubble.capturedAt)
347
+ if (bubble.area) state.setRegion(null)
348
+ state.closeBubble(kind)
349
+ useViewer.getState().setPointedIds(null)
350
+ if (leaveOnClose) {
351
+ leaveOnClose = false
352
+ leavePointMode()
353
+ }
354
+ }
355
+
356
+ function startCrop(hover: PointHover | null): Promise<CapturedCrop | null> {
357
+ if (!hover?.target.box) return Promise.resolve(null)
358
+ const rect = projectBox(hover.target.box)
359
+ if (!rect) return Promise.resolve(null)
360
+ return captureViewCrop(rect).then(
361
+ (crop) => crop,
362
+ (error) => {
363
+ console.debug('[point-ask] no picture:', error instanceof Error ? error.message : error)
364
+ return null
365
+ },
366
+ )
367
+ }
368
+
369
+ // ─── pointer ────────────────────────────────────────────────────────────────
370
+
371
+ let down: Down | null = null
372
+
373
+ /**
374
+ * What is under a page point, found by raycasting the scene's registered nodes: the pick for a press
375
+ * that lands before any hover was read (a fast click, a touch, which has no hover at all). The same
376
+ * `resolvePointPick` decides it, so a click without a hover sends what the hover would have shown.
377
+ */
378
+ function pickAtPoint(x: number, y: number): PointHover | null {
379
+ const three = getEditorThreeContext()
380
+ const rect = canvasRect()
381
+ if (!(three && rect)) return null
382
+ three.raycaster.setFromCamera(
383
+ new Vector2(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1),
384
+ three.camera,
385
+ )
386
+ const owners = new Map<Object3D, string>()
387
+ for (const [id, object] of sceneRegistry.nodes) owners.set(object, id)
388
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
389
+ for (const hit of three.raycaster.intersectObjects([...owners.keys()], true)) {
390
+ let object: Object3D | null = hit.object
391
+ let hidden = false
392
+ let id: string | undefined
393
+ for (; object; object = object.parent) {
394
+ if (!object.visible) hidden = true
395
+ id ??= owners.get(object)
396
+ }
397
+ const node = id ? nodes[id] : undefined
398
+ if (hidden || !node) continue
399
+ const local = hit.object.worldToLocal(hit.point.clone())
400
+ const event = {
401
+ node,
402
+ position: [hit.point.x, hit.point.y, hit.point.z],
403
+ localPosition: [local.x, local.y, local.z],
404
+ normal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : undefined,
405
+ faceIndex: hit.faceIndex,
406
+ object: hit.object,
407
+ stopPropagation: () => undefined,
408
+ } as unknown as NodeEvent
409
+ const hover = hoverFromEvent(event)
410
+ if (hover) return hover
411
+ }
412
+ return null
413
+ }
414
+
415
+ function onPointerDown(event: PointerEvent) {
416
+ const state = usePointAsk.getState()
417
+ if (!state.active || event.button !== 0) return
418
+ const canvas = getEditorThreeContext()?.domElement
419
+ if (!canvas || event.target !== canvas) return
420
+ event.preventDefault()
421
+ event.stopImmediatePropagation()
422
+ // A touch has no hover: its press names what it lands on, and shows the outline at once.
423
+ const hover =
424
+ state.hover && event.pointerType !== 'touch'
425
+ ? state.hover
426
+ : pickAtPoint(event.clientX, event.clientY)
427
+ if (hover && hover !== state.hover) {
428
+ state.setHover(hover)
429
+ useViewer.getState().setHoveredId(hover.target.id as AnyNodeId)
430
+ }
431
+ down = {
432
+ x: event.clientX,
433
+ y: event.clientY,
434
+ moved: false,
435
+ hover,
436
+ capturedAt: new Date().toISOString(),
437
+ camera: currentCamera(),
438
+ crop: startCrop(hover),
439
+ shift: event.shiftKey,
440
+ }
441
+ }
442
+
443
+ // ─── a region: a view dragged out on the canvas, not a selection ────────────
444
+
445
+ /** The camera's ray through a page point, for measuring a rectangle on the ground. */
446
+ function cameraRay(x: number, y: number) {
447
+ const three = getEditorThreeContext()
448
+ const rect = canvasRect()
449
+ if (!(three && rect)) return null
450
+ three.raycaster.setFromCamera(
451
+ new Vector2(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1),
452
+ three.camera,
453
+ )
454
+ const { origin, direction } = three.raycaster.ray
455
+ return {
456
+ origin: [origin.x, origin.y, origin.z] as [number, number, number],
457
+ direction: [direction.x, direction.y, direction.z] as [number, number, number],
458
+ }
459
+ }
460
+
461
+ /** The floor of the level in view, in world metres. */
462
+ function levelFloorY(): number {
463
+ const levelId = useViewer.getState().selection.levelId
464
+ const object = levelId ? sceneRegistry.nodes.get(levelId) : null
465
+ if (!object) return 0
466
+ object.updateWorldMatrix(true, false)
467
+ return object.getWorldPosition(new Vector3()).y
468
+ }
469
+
470
+ /** What a region drag could take, boxed once when the drag begins: rebuilding it per move would walk the whole scene. */
471
+ let regionCandidates: { id: string; box: WorldBox }[] | null = null
472
+ let regionPreviewAt = 0
473
+
474
+ function candidatesForRegion() {
475
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
476
+ const levelId = useViewer.getState().selection.levelId
477
+ const found: { id: string; box: WorldBox }[] = []
478
+ for (const node of Object.values(nodes)) {
479
+ if (!REGION_KINDS.has(node.type) || node.visible === false) continue
480
+ if (levelId && resolveLevelId(node, nodes) !== levelId) continue
481
+ const box = worldBoxOf(nodes, node.id)
482
+ if (box) found.push({ id: node.id, box })
483
+ }
484
+ return found
485
+ }
486
+
487
+ /** The live preview of a region: what its rectangle would take is outlined while it is dragged. */
488
+ function previewRegion(rect: Rect, now: number) {
489
+ if (now - regionPreviewAt < 80) return
490
+ regionPreviewAt = now
491
+ regionCandidates ??= candidatesForRegion()
492
+ const { ids } = rankInRegion(
493
+ regionCandidates.map(({ id, box }) => ({ id, rect: projectBox(box) })),
494
+ rect,
495
+ )
496
+ useViewer.getState().setPointedIds(ids.length ? (ids as never) : null)
497
+ }
498
+
499
+ function updateRegionDrag(press: Down, event: PointerEvent) {
500
+ const bounds = canvasRect()
501
+ if (!bounds) return
502
+ const rect = rectFromDrag(
503
+ { x: press.x, y: press.y },
504
+ { x: event.clientX, y: event.clientY },
505
+ { x0: bounds.left, y0: bounds.top, x1: bounds.right, y1: bounds.bottom },
506
+ )
507
+ const size = regionSizeMetres(rect, cameraRay, levelFloorY())
508
+ const state = usePointAsk.getState()
509
+ state.setRegion({
510
+ rect,
511
+ sizeLabel: size ? formatRegionSize(size, useViewer.getState().unit) : null,
512
+ settled: false,
513
+ flashKey: state.region?.flashKey ?? 0,
514
+ })
515
+ // A region has no pick: the chip and the hover outline step aside while it is drawn.
516
+ if (state.hover) state.setHover(null)
517
+ useViewer.getState().setHoveredId(null)
518
+ previewRegion(rect, event.timeStamp)
519
+ }
520
+
521
+ /** The elements of the level in view that show inside the rectangle, the biggest first. */
522
+ function elementsInRegion(rect: Rect) {
523
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
524
+ const levelId = useViewer.getState().selection.levelId
525
+ const candidates: { id: string; rect: Rect | null }[] = []
526
+ for (const node of Object.values(nodes)) {
527
+ if (!REGION_KINDS.has(node.type) || node.visible === false) continue
528
+ if (levelId && resolveLevelId(node, nodes) !== levelId) continue
529
+ const box = worldBoxOf(nodes, node.id)
530
+ candidates.push({ id: node.id, rect: box ? projectBox(box) : null })
531
+ }
532
+ return rankInRegion(candidates, rect)
533
+ }
534
+
535
+ const REGION_KINDS = new Set([
536
+ 'zone',
537
+ 'wall',
538
+ 'window',
539
+ 'door',
540
+ 'item',
541
+ 'stair',
542
+ 'roof',
543
+ 'column',
544
+ 'fence',
545
+ 'shelf',
546
+ ])
547
+
548
+ /** The press became a drag and was let go: the shutter fires and a bubble opens on the rectangle. */
549
+ function finishRegion(press: Down, event: PointerEvent) {
550
+ const state = usePointAsk.getState()
551
+ const region = state.region
552
+ regionCandidates = null
553
+ if (!(region && isRegionBigEnough(region.rect))) {
554
+ state.setRegion(null)
555
+ useViewer.getState().setPointedIds(null)
556
+ return
557
+ }
558
+ const rect = region.rect
559
+ state.setRegion({ ...region, settled: true, flashKey: region.flashKey + 1 })
560
+ if (state.playful) triggerSFX('sfx:snapshot-capture')
561
+ const crop = captureViewCrop(rect, { pad: 0 }).then(
562
+ (value) => value,
563
+ (error) => {
564
+ console.debug(
565
+ '[point-ask] no picture of the region:',
566
+ error instanceof Error ? error.message : error,
567
+ )
568
+ return null
569
+ },
570
+ )
571
+ const { ids, more } = elementsInRegion(rect)
572
+ const release = { x: event.clientX, y: event.clientY }
573
+ const click = nearestEdgePoint(rect, release)
574
+ // The pin plants on the ground under where the finger let go (the level's floor when the ray misses).
575
+ const planeY = levelFloorY()
576
+ const ray = cameraRay(release.x, release.y)
577
+ const t = ray && ray.direction[1] < -1e-6 ? (planeY - ray.origin[1]) / ray.direction[1] : null
578
+ const point: [number, number, number] =
579
+ ray && t !== null && t > 0
580
+ ? [ray.origin[0] + ray.direction[0] * t, planeY, ray.origin[2] + ray.direction[2] * t]
581
+ : (cameraPoseStore.getState().pose?.target ?? [0, planeY, 0])
582
+ openBubble(
583
+ [],
584
+ { point, normal: [0, 1, 0] },
585
+ 'region',
586
+ { ...press, crop },
587
+ { rect, click, ids, more },
588
+ )
589
+ }
590
+
591
+ function onPointerMove(event: PointerEvent) {
592
+ const point = usePointAsk.getState()
593
+ // Over a pin, its card or the bubble the pointer is no longer on the scene: nothing is named.
594
+ if (
595
+ point.active &&
596
+ !down &&
597
+ point.hover &&
598
+ event.target !== getEditorThreeContext()?.domElement
599
+ ) {
600
+ point.setHover(null)
601
+ useViewer.getState().setHoveredId(null)
602
+ }
603
+ if (!down) return
604
+ if (!down.moved && Math.hypot(event.clientX - down.x, event.clientY - down.y) > CLICK_SLOP_PX) {
605
+ down.moved = true
606
+ // A drag begun while composing re-points: the open bubble goes, its words are kept.
607
+ if (usePointAsk.getState().bubble) closeBubble('dismiss')
608
+ }
609
+ if (down.moved) updateRegionDrag(down, event)
610
+ }
611
+
612
+ function onPointerUp(event: PointerEvent) {
613
+ const press = down
614
+ down = null
615
+ if (!press || event.button !== 0) return
616
+ const state = usePointAsk.getState()
617
+ if (!state.active) return
618
+ event.preventDefault()
619
+ event.stopImmediatePropagation()
620
+ if (press.moved) {
621
+ finishRegion(press, event)
622
+ return
623
+ }
624
+ handleClick(press, event.shiftKey || press.shift)
625
+ }
626
+
627
+ /** A native click that follows a press this mode took must not reach the scene. */
628
+ function swallowCanvasClick(event: MouseEvent) {
629
+ if (!usePointAsk.getState().active) return
630
+ const canvas = getEditorThreeContext()?.domElement
631
+ if (canvas && event.target === canvas) {
632
+ event.preventDefault()
633
+ event.stopImmediatePropagation()
634
+ }
635
+ }
636
+
637
+ function handleClick(press: Down, shift: boolean) {
638
+ const state = usePointAsk.getState()
639
+ const hover = press.hover
640
+ if (!hover) {
641
+ if (state.bubble) closeBubble('dismiss')
642
+ return
643
+ }
644
+ const bubble = state.bubble
645
+ if (bubble && shift) {
646
+ const has = bubble.targets.some((t) => t.id === hover.target.id)
647
+ const targets = has
648
+ ? bubble.targets.filter((t) => t.id !== hover.target.id)
649
+ : bubble.targets.length < MAX_TARGETS
650
+ ? [...bubble.targets, hover.target]
651
+ : bubble.targets
652
+ if (targets.length === 0) return closeBubble('dismiss')
653
+ state.patchBubble({ targets, gesture: targets.length > 1 ? 'multi' : 'click' })
654
+ return
655
+ }
656
+ // A plain click on another element while composing moves the bubble there and keeps the words.
657
+ openBubble([hover.target], hover.hit, 'click', press)
658
+ }
659
+
660
+ // ─── hover (the node events the viewer already raises) ──────────────────────
661
+
662
+ function onAnyEvent(type: string | symbol, event: unknown) {
663
+ if (typeof type !== 'string') return
664
+ const state = usePointAsk.getState()
665
+ if (!state.active) return
666
+ // Each pointer event is emitted per kind (`wall:move`) and again generically (`node:move`), and
667
+ // then climbs to the level, the building and the site: only the innermost kind's own event is read.
668
+ if (type.startsWith('node:')) return
669
+ const suffix = type.slice(type.lastIndexOf(':') + 1)
670
+ if (suffix !== 'enter' && suffix !== 'move' && suffix !== 'leave') return
671
+ const nodeEvent = event as NodeEvent | undefined
672
+ if (!nodeEvent?.node) return
673
+ if (
674
+ nodeEvent.node.type === 'level' ||
675
+ nodeEvent.node.type === 'building' ||
676
+ nodeEvent.node.type === 'site'
677
+ )
678
+ return
679
+ const viewer = useViewer.getState()
680
+ if (viewer.cameraDragging || viewer.inputDragging || down?.moved) return
681
+ if (suffix === 'leave') {
682
+ const current = state.hover
683
+ if (current) {
684
+ // The next enter in the same pointer move replaces it; a leave with no enter clears.
685
+ queueMicrotask(() => {
686
+ const now = usePointAsk.getState()
687
+ if (now.hover === current) {
688
+ now.setHover(null)
689
+ useViewer.getState().setHoveredId(null)
690
+ }
691
+ })
692
+ }
693
+ return
694
+ }
695
+ lastEvent = nodeEvent
696
+ // What is under the pointer is read here and goes no higher.
697
+ nodeEvent.stopPropagation()
698
+ const hover = hoverFromEvent(nodeEvent)
699
+ state.setHover(hover)
700
+ useViewer.getState().setHoveredId((hover?.target.id as AnyNodeId | undefined) ?? null)
701
+ }
702
+
703
+ let lastEvent: NodeEvent | null = null
704
+
705
+ function replayHover() {
706
+ if (!lastEvent) return
707
+ const hover = hoverFromEvent(lastEvent)
708
+ const state = usePointAsk.getState()
709
+ state.setHover(hover)
710
+ useViewer.getState().setHoveredId((hover?.target.id as AnyNodeId | undefined) ?? null)
711
+ }
712
+
713
+ // ─── keys ───────────────────────────────────────────────────────────────────
714
+
715
+ const typing = (target: EventTarget | null) =>
716
+ target instanceof HTMLElement &&
717
+ (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))
718
+
719
+ function onKeyDown(event: KeyboardEvent) {
720
+ const state = usePointAsk.getState()
721
+ if (event.key === 'Alt' || event.key === 'Shift') {
722
+ state.setModifiers(event.altKey, event.shiftKey)
723
+ if (state.active) replayHover()
724
+ return
725
+ }
726
+ if (event.key === 'Escape') {
727
+ if (state.cardFor) {
728
+ state.openCard(null)
729
+ event.stopImmediatePropagation()
730
+ return
731
+ }
732
+ if (state.bubble) {
733
+ closeBubble('dismiss')
734
+ event.stopImmediatePropagation()
735
+ return
736
+ }
737
+ if (state.active) {
738
+ leavePointMode()
739
+ event.stopImmediatePropagation()
740
+ }
741
+ return
742
+ }
743
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && state.active && !state.bubble) {
744
+ if (state.hover) {
745
+ event.preventDefault()
746
+ event.stopImmediatePropagation()
747
+ openBubble([state.hover.target], state.hover.hit, 'click', null)
748
+ }
749
+ return
750
+ }
751
+ if (event.key !== 'c' && event.key !== 'C') return
752
+ // A held C never types: its repeats are swallowed until the key goes up.
753
+ if (cDown !== null) {
754
+ event.preventDefault()
755
+ event.stopImmediatePropagation()
756
+ return
757
+ }
758
+ if (event.metaKey || event.ctrlKey || event.altKey || typing(event.target)) return
759
+ if (event.repeat) return
760
+ if (state.active && state.latched) {
761
+ event.preventDefault()
762
+ event.stopImmediatePropagation()
763
+ if (state.bubble) closeBubble('dismiss')
764
+ leavePointMode()
765
+ return
766
+ }
767
+ if (state.active) return
768
+ if (!cIsOurs()) return
769
+ if (enterPointMode(false)) {
770
+ cDown = event.timeStamp
771
+ event.preventDefault()
772
+ event.stopImmediatePropagation()
773
+ }
774
+ }
775
+
776
+ function onKeyUp(event: KeyboardEvent) {
777
+ const state = usePointAsk.getState()
778
+ if (event.key === 'Alt' || event.key === 'Shift') {
779
+ state.setModifiers(event.altKey, event.shiftKey)
780
+ if (state.active) replayHover()
781
+ return
782
+ }
783
+ if ((event.key === 'c' || event.key === 'C') && cDown !== null) {
784
+ // Both times are the input's own: a slow render between the two must not turn a tap into a hold.
785
+ const held = event.timeStamp - cDown
786
+ cDown = null
787
+ if (held < TAP_LATCH_MS) state.setLatched(true)
788
+ else if (state.bubble) leaveOnClose = true
789
+ else leavePointMode()
790
+ }
791
+ }
792
+
793
+ function onBlur() {
794
+ usePointAsk.getState().setModifiers(false, false)
795
+ if (cDown !== null) {
796
+ cDown = null
797
+ if (usePointAsk.getState().bubble) leaveOnClose = true
798
+ else leavePointMode()
799
+ }
800
+ }
801
+
802
+ // ─── send ───────────────────────────────────────────────────────────────────
803
+
804
+ let askCounter = 0
805
+ const newAskId = () =>
806
+ `ask_${Date.now().toString(36)}${(askCounter++).toString(36)}${Math.random().toString(36).slice(2, 6)}`
807
+
808
+ /** The scene history now, marked so what an ask's turn wrote can be counted when its Undo comes. */
809
+ const markNow = () => markHistory(useScene.temporal.getState().pastStates)
810
+ const stepsFrom = (mark: HistoryMark) => stepsSince(useScene.temporal.getState().pastStates, mark)
811
+
812
+ /**
813
+ * The chat has taken the ask: the scene as it stands now is the "before" of its turn. Taken here and
814
+ * not at the chat's first "working", which comes from the stream and can follow the turn's first
815
+ * write. An ask the chat queued behind another turn is marked when its own turn starts.
816
+ */
817
+ function markAccepted(askId: string) {
818
+ const marks = askMarks.get(askId)
819
+ const pin = usePointAsk.getState().pins[askId]
820
+ if (marks && marks.start === null && pin && pin.model.state !== 'queued') marks.start = markNow()
821
+ }
822
+
823
+ const askMarks = new Map<
824
+ string,
825
+ { start: HistoryMark | null; end: HistoryMark | null; undoneSteps: number; undone: boolean }
826
+ >()
827
+
828
+ /**
829
+ * Sends the bubble's ask: the pin plants on the frame of Enter and the bubble folds into it; the
830
+ * handoff to the chat follows (after the crop, up to 1.5 s), and a refusal brings the bubble back
831
+ * with its words and the reason.
832
+ */
833
+ export async function sendBubble(
834
+ text: string,
835
+ from: { x: number; y: number; width: number; height: number },
836
+ ) {
837
+ const state = usePointAsk.getState()
838
+ const bubble = state.bubble
839
+ const words = text.trim()
840
+ if (!(bubble && words)) return
841
+ const askId = newAskId()
842
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
843
+ const first = bubble.targets[0]
844
+ const area = bubble.area
845
+ const object = first ? sceneRegistry.nodes.get(first.id) : undefined
846
+ let local: PinRecord['local'] = null
847
+ if (object && first) {
848
+ object.updateWorldMatrix(true, false)
849
+ const p = object.worldToLocal(new Vector3(...bubble.anchor))
850
+ local = { nodeId: first.id, offset: [p.x, p.y, p.z] }
851
+ }
852
+ // A region has no targets: what shows inside it is what the pin watches for changes.
853
+ const model = createPin({
854
+ askId,
855
+ text: words,
856
+ targetIds: area ? area.ids : bubble.targets.map((t) => t.id),
857
+ })
858
+ const label = area
859
+ ? regionLabel(area.ids.length + area.more)
860
+ : bubble.targets.length > 1
861
+ ? `${first?.name} and ${bubble.targets.length - 1} more`
862
+ : (first?.name ?? 'Selection')
863
+ state.addPin({
864
+ model,
865
+ anchor: bubble.anchor,
866
+ local,
867
+ label,
868
+ levelId: useViewer.getState().selection.levelId ?? null,
869
+ retireAt: null,
870
+ createdAt: Date.now(),
871
+ ...(bubble.crop.state === 'ready' ? { before: bubble.crop } : {}),
872
+ })
873
+ askMarks.set(askId, { start: null, end: null, undoneSteps: 0, undone: false })
874
+ const restore = bubble
875
+ // "Change more": the earlier pin gives way to this ask's, which now stands where it stood.
876
+ const changes = bubble.changesAsk?.askId
877
+ if (changes) state.patchPin(changes, { supersededBy: askId })
878
+ closeBubble('send')
879
+ state.dispatchPin(askId, { type: 'sent' })
880
+
881
+ // The crop is usually ready by now (it started at pointer-down); wait for it only briefly.
882
+ let crop = bubble.crop
883
+ if (crop.state === 'pending') {
884
+ const pending = downCrops.get(bubble.capturedAt)
885
+ const waited = pending
886
+ ? await Promise.race([
887
+ pending,
888
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), CROP_WAIT_MS)),
889
+ ])
890
+ : null
891
+ crop = waited
892
+ ? {
893
+ state: 'ready',
894
+ dataUrl: waited.dataUrl,
895
+ width: waited.width,
896
+ height: waited.height,
897
+ marked: false,
898
+ }
899
+ : { state: 'failed' }
900
+ }
901
+ const firstNode = first ? nodes[first.id] : undefined
902
+ const levelId =
903
+ useViewer.getState().selection.levelId ??
904
+ (firstNode ? (resolveLevelId(firstNode, nodes) ?? '') : '')
905
+ const context = buildPointContext({
906
+ askId,
907
+ capturedAt: bubble.capturedAt,
908
+ gesture: bubble.gesture,
909
+ nodes: nodes as Record<string, AnyNode>,
910
+ levelId,
911
+ targets: bubble.targets.map((t, i) => ({ id: t.id, ...(i === 0 ? { hit: bubble.hit } : {}) })),
912
+ ...(area ? { inRegion: { ids: area.ids, more: area.more } } : {}),
913
+ camera: bubble.camera ??
914
+ currentCamera() ?? {
915
+ position: [0, 5, 5],
916
+ target: [0, 0, 0],
917
+ fov: 50,
918
+ aspect: 1.6,
919
+ projection: 'perspective',
920
+ },
921
+ ...(bubble.region ? { region: bubble.region } : {}),
922
+ ...(crop.state === 'ready' && crop.dataUrl
923
+ ? {
924
+ image: {
925
+ dataUrl: crop.dataUrl,
926
+ width: crop.width ?? 0,
927
+ height: crop.height ?? 0,
928
+ marked: false,
929
+ },
930
+ }
931
+ : {}),
932
+ })
933
+ const submit = { askId, text: words, context, from }
934
+ usePointAsk.getState().patchPin(askId, { submit })
935
+ const result = await submitPointAsk(submit)
936
+ if (result.ok) {
937
+ markAccepted(askId)
938
+ if (changes) {
939
+ usePointAsk.getState().dispatchPin(changes, { type: 'keep' })
940
+ usePointAsk.getState().removePin(changes)
941
+ askMarks.delete(changes)
942
+ }
943
+ return
944
+ }
945
+ // The chat did not take it: the pin goes, and the bubble returns with the reason and the words.
946
+ usePointAsk.getState().removePin(askId)
947
+ if (changes) usePointAsk.getState().patchPin(changes, { supersededBy: undefined })
948
+ askMarks.delete(askId)
949
+ usePointAsk.getState().openBubble({ ...restore, draft: words, error: result.message })
950
+ if (restore.area)
951
+ usePointAsk
952
+ .getState()
953
+ .setRegion({ rect: restore.area.rect, sizeLabel: null, settled: true, flashKey: 0 })
954
+ }
955
+
956
+ /** The camera flies to what an ask is about (the edge marker's click): the targets' union box, as view_scene frames it. */
957
+ export function lookAtAsk(askId: string) {
958
+ const pin = usePointAsk.getState().pins[askId]
959
+ if (!pin) return
960
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
961
+ const boxes = pin.model.targetIds.flatMap((id) => {
962
+ const box = worldBoxOf(nodes, id)
963
+ return box ? [box] : []
964
+ })
965
+ if (boxes.length === 0) return
966
+ const box = {
967
+ min: [0, 1, 2].map((i) => Math.min(...boxes.map((b) => b.min[i] as number))) as [
968
+ number,
969
+ number,
970
+ number,
971
+ ],
972
+ max: [0, 1, 2].map((i) => Math.max(...boxes.map((b) => b.max[i] as number))) as [
973
+ number,
974
+ number,
975
+ number,
976
+ ],
977
+ }
978
+ // The person asked to look: Follow Pascal must not take the camera back while it flies there.
979
+ useFollowPascal.getState().pause()
980
+ emitter.emit('camera-controls:apply-pose', sceneViewPose(box, {}))
981
+ }
982
+
983
+ /** The picture of what the turn made, from the same view when the camera has not moved: the card's "after". */
984
+ async function captureAfter(askId: string) {
985
+ const pin = usePointAsk.getState().pins[askId]
986
+ if (!pin) return
987
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
988
+ const boxes = pin.model.targetIds.flatMap((id) => {
989
+ const box = worldBoxOf(nodes, id)
990
+ const rect = box ? projectBox(box) : null
991
+ return rect ? [rect] : []
992
+ })
993
+ const rect = unionRect(boxes)
994
+ if (!rect) return
995
+ try {
996
+ const crop = await captureViewCrop(rect)
997
+ usePointAsk.getState().patchPin(askId, {
998
+ after: {
999
+ state: 'ready',
1000
+ dataUrl: crop.dataUrl,
1001
+ width: crop.width,
1002
+ height: crop.height,
1003
+ marked: false,
1004
+ },
1005
+ })
1006
+ } catch (error) {
1007
+ console.debug(
1008
+ '[point-ask] no picture of the result:',
1009
+ error instanceof Error ? error.message : error,
1010
+ )
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Try again: the same words and context go as a new ask, planted where the old pin was. After an
1016
+ * error the new pin replaces the old; from the chat a finished ask keeps its pin. False when the pin
1017
+ * is gone (a reload, an ask of a collaborator's): the caller sends the words again without one.
1018
+ */
1019
+ export async function retryAsk(askId: string): Promise<boolean> {
1020
+ const state = usePointAsk.getState()
1021
+ const old = state.pins[askId]
1022
+ const next = newAskId()
1023
+ const plan = old ? planRetry(old, next, Date.now()) : null
1024
+ if (!plan) return false
1025
+ state.addPin(plan.pin)
1026
+ if (!plan.keepOld) state.removePin(askId)
1027
+ state.openCard(null)
1028
+ askMarks.set(next, { start: null, end: null, undoneSteps: 0, undone: false })
1029
+ state.dispatchPin(next, { type: 'sent' })
1030
+ const result = await submitPointAsk(plan.submit)
1031
+ if (result.ok) {
1032
+ markAccepted(next)
1033
+ return true
1034
+ }
1035
+ usePointAsk.getState().dispatchPin(next, {
1036
+ type: 'status',
1037
+ status: {
1038
+ askId: next,
1039
+ status: 'ended',
1040
+ outcome: 'errored',
1041
+ asked: false,
1042
+ wroteScene: false,
1043
+ error: result.message,
1044
+ },
1045
+ })
1046
+ return true
1047
+ }
1048
+
1049
+ // ─── the card: change more, and the scene as it was ─────────────────────────
1050
+
1051
+ /**
1052
+ * "Change more": the done pin's bubble opens again on the same elements (a fresh view and picture
1053
+ * of them, the field focused, chips that move on) and sends as its own ask. The pin gives way while
1054
+ * the bubble is open, and goes when the new ask is taken.
1055
+ */
1056
+ export function changeMore(askId: string): boolean {
1057
+ const state = usePointAsk.getState()
1058
+ const pin = state.pins[askId]
1059
+ if (!pin) return false
1060
+ const targets = pin.model.targetIds
1061
+ .map((id) => targetFor(id))
1062
+ .filter((target): target is BubbleTarget => target !== null)
1063
+ .slice(0, MAX_TARGETS)
1064
+ if (targets.length === 0) return false
1065
+ if (!state.active && !enterPointMode(true)) return false
1066
+ endCompare()
1067
+ if (usePointAsk.getState().bubble) closeBubble('dismiss')
1068
+ state.openCard(null)
1069
+ const rect = unionRect(
1070
+ targets.flatMap((target) => {
1071
+ const projected = target.box ? projectBox(target.box) : null
1072
+ return projected ? [projected] : []
1073
+ }),
1074
+ )
1075
+ const crop = rect
1076
+ ? captureViewCrop(rect).then(
1077
+ (value) => value,
1078
+ () => null,
1079
+ )
1080
+ : Promise.resolve(null)
1081
+ openBubble(
1082
+ targets,
1083
+ { point: pin.anchor, normal: [0, 1, 0] },
1084
+ targets.length > 1 ? 'multi' : 'click',
1085
+ { crop, capturedAt: new Date().toISOString(), camera: currentCamera() },
1086
+ undefined,
1087
+ { askId, after: pin.model.text },
1088
+ )
1089
+ return true
1090
+ }
1091
+
1092
+ /** The scene as it was before the ask's turn: the state Undo would restore, null once it cannot. */
1093
+ function beforeAsk(askId: string): SceneSnapshot | null {
1094
+ const marks = askMarks.get(askId)
1095
+ if (!(marks?.start && usePointAsk.getState().pins[askId]) || marks.undone) return null
1096
+ const past = useScene.temporal.getState().pastStates as unknown as SceneSnapshot[]
1097
+ return stateAfterMark(past, marks.start)
1098
+ }
1099
+
1100
+ // Only a turn that is writing swallows a preview. One that has been sent and not started cannot
1101
+ // (its first write lets go of the preview itself), and one that is waiting on the person is not
1102
+ // writing: a stuck "sent" pin must not shut every hold for good.
1103
+ const TURN_WRITING = new Set(['working'])
1104
+ const COMPARE_REFUSAL_MS = 3500
1105
+
1106
+ const compare = createCompare({
1107
+ before: beforeAsk,
1108
+ // A pin that says "working" while the chat is idle never heard its turn end: it is not writing.
1109
+ turnsWorking: () => {
1110
+ const state = usePointAsk.getState()
1111
+ return state.busy && Object.values(state.pins).some((pin) => TURN_WRITING.has(pin.model.state))
1112
+ },
1113
+ begin: beginScenePreview,
1114
+ onChange: (askId) => usePointAsk.getState().setComparing(askId),
1115
+ })
1116
+
1117
+ /** Shows the scene as it was before that ask's turn, until `endCompare`. False when it may not. */
1118
+ export function startCompare(askId: string) {
1119
+ const started = compare.start(askId)
1120
+ const reason = compare.refusal()
1121
+ const state = usePointAsk.getState()
1122
+ if (started || !reason) {
1123
+ state.setCompareRefusal(null)
1124
+ return started
1125
+ }
1126
+ state.setCompareRefusal({ askId, reason })
1127
+ setTimeout(() => {
1128
+ const refusal = usePointAsk.getState().compareRefusal
1129
+ if (refusal?.askId === askId && refusal.reason === reason) {
1130
+ usePointAsk.getState().setCompareRefusal(null)
1131
+ }
1132
+ }, COMPARE_REFUSAL_MS)
1133
+ return false
1134
+ }
1135
+
1136
+ /** Lets go: the live scene is back. Idempotent, so every way out can call it. */
1137
+ export const endCompare = () => compare.end()
1138
+
1139
+ /**
1140
+ * A hold that keeps its release by pointer capture (the pin) cannot see the pointer leave: it ends
1141
+ * when the pointer has drifted `radius` px from where it pressed.
1142
+ */
1143
+ export function endCompareWhenPointerDrifts(origin: { x: number; y: number }, radius: number) {
1144
+ const watch = (event: PointerEvent) => {
1145
+ if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > radius) endCompare()
1146
+ }
1147
+ const stop = () => {
1148
+ window.removeEventListener('pointermove', watch, true)
1149
+ off()
1150
+ }
1151
+ window.addEventListener('pointermove', watch, true)
1152
+ const off = usePointAsk.subscribe((state) => {
1153
+ if (state.comparing === null) stop()
1154
+ })
1155
+ }
1156
+
1157
+ // ─── the chat's word on a turn, and the scene's commits ─────────────────────
1158
+
1159
+ /**
1160
+ * The ask whose turn is running: the last one the chat reported "working", until it ends. Only its
1161
+ * pin takes the scene's commits. A pin that says "working" and never heard its end is an older turn
1162
+ * that is not running, and must not absorb this one's changes.
1163
+ */
1164
+ let runningAskId: string | null = null
1165
+
1166
+ /**
1167
+ * Pins that said "working" or "asking" for a turn the chat has moved past end, unfinished. `running`
1168
+ * is the ask that is working now; none when the chat is idle.
1169
+ */
1170
+ function settleStalePins(running: string | null) {
1171
+ const state = usePointAsk.getState()
1172
+ for (const askId of state.pinOrder) {
1173
+ if (askId === running) continue
1174
+ const model = state.pins[askId]?.model
1175
+ if (model && (model.state === 'working' || model.state === 'needs-input')) {
1176
+ state.dispatchPin(askId, {
1177
+ type: 'status',
1178
+ status: {
1179
+ askId,
1180
+ status: 'ended',
1181
+ outcome: 'cancelled',
1182
+ asked: false,
1183
+ wroteScene: false,
1184
+ },
1185
+ })
1186
+ }
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * The chat has no turn running: an ask that still says "working" never heard its end, and the next
1192
+ * turn (a plain message, say) is not its. Called by the host's busy flag going false.
1193
+ */
1194
+ export function chatWentIdle() {
1195
+ runningAskId = null
1196
+ settleStalePins(null)
1197
+ }
1198
+
1199
+ function onStatus(status: PointAskStatus) {
1200
+ // A turn starting to write while the before is held would be swallowed by it.
1201
+ if (status.status === 'working' || status.status === 'queued') endCompare()
1202
+ const state = usePointAsk.getState()
1203
+ const pin = state.pins[status.askId]
1204
+ if (!pin) return
1205
+ if (status.status === 'working') {
1206
+ runningAskId = status.askId
1207
+ settleStalePins(status.askId)
1208
+ }
1209
+ const marks = askMarks.get(status.askId)
1210
+ if (status.status === 'queued' && marks) marks.start = null
1211
+ if (status.status === 'working' && marks && marks.start === null) marks.start = markNow()
1212
+ state.dispatchPin(status.askId, { type: 'status', status })
1213
+ if (status.status === 'ended') {
1214
+ if (runningAskId === status.askId) runningAskId = null
1215
+ if (marks) marks.end = markNow()
1216
+ state.patchPin(status.askId, { retireAt: null })
1217
+ const settled = usePointAsk.getState().pins[status.askId]
1218
+ if (settled?.model.state === 'done') void captureAfter(status.askId)
1219
+ }
1220
+ }
1221
+
1222
+ function onCommit(commit: SceneCommit) {
1223
+ if (!runningAskId) return
1224
+ const state = usePointAsk.getState()
1225
+ const pin = state.pins[runningAskId]
1226
+ if (pin?.model.state !== 'working') return
1227
+ const event = pointCommitEvent(commit, pin.model.targetIds)
1228
+ if (event) state.dispatchPin(runningAskId, event)
1229
+ }
1230
+
1231
+ /** What an undo of the turn will take out of the scene: the nodes the turn created, not those it only changed. */
1232
+ function createdByTurn(start: HistoryMark): string[] {
1233
+ const before = stateAfterMark(useScene.temporal.getState().pastStates, start)
1234
+ if (!before) return []
1235
+ return createdSince(
1236
+ before.nodes as Record<string, unknown>,
1237
+ useScene.getState().nodes as Record<string, unknown>,
1238
+ )
1239
+ }
1240
+
1241
+ /**
1242
+ * Undo for an ask from its pin: the history steps its turn wrote (the chat has no turn undo
1243
+ * tonight). What the turn built plays backwards first (the construction in reverse, so taking
1244
+ * something back is as satisfying as making it) and the steps are undone the moment it has gone.
1245
+ * `needs-confirm` when the scene changed after the turn ended, which Undo would remove too;
1246
+ * `unavailable` when the history no longer holds the whole turn.
1247
+ */
1248
+ export async function undoAsk(
1249
+ askId: string,
1250
+ options: { force?: boolean } = {},
1251
+ ): Promise<'undone' | 'needs-confirm' | 'unavailable'> {
1252
+ const marks = askMarks.get(askId)
1253
+ if (!marks?.start) return 'unavailable'
1254
+ const steps = stepsFrom(marks.start)
1255
+ if (steps === null || steps <= 0) return 'unavailable'
1256
+ if (!options.force && handEditsSince(askId) > 0) return 'needs-confirm'
1257
+ usePointAsk.getState().dispatchPin(askId, { type: 'undo' })
1258
+ // Only what the undo removes is retracted: a node it merely changes would go and come back whole.
1259
+ const created = createdByTurn(marks.start)
1260
+ if (created.length > 0) {
1261
+ // Walls the level has batched are drawn by a merged mesh a pose cannot move: a dirty mark hands
1262
+ // each back to its own mesh, as an edit does, or the walls would stand whole until they vanish.
1263
+ for (const id of created) useScene.getState().markDirty(id as AnyNodeId)
1264
+ await retractNodes(created)
1265
+ }
1266
+ // The steps are counted again: the history can have moved while the build played backwards.
1267
+ const now = stepsFrom(marks.start) ?? steps
1268
+ useScene.temporal.getState().undo(now)
1269
+ marks.undone = true
1270
+ marks.undoneSteps = now
1271
+ usePointAsk.getState().dispatchPin(askId, { type: 'undone', ok: true })
1272
+ return 'undone'
1273
+ }
1274
+
1275
+ /** Redo for an ask the person just undid, while nothing else has been done since. */
1276
+ export function redoAsk(askId: string): boolean {
1277
+ const marks = askMarks.get(askId)
1278
+ if (!marks?.undone) return false
1279
+ const temporal = useScene.temporal.getState()
1280
+ // Anything done after the undo clears the future, so there is nothing of the ask to bring back.
1281
+ if (temporal.futureStates.length < marks.undoneSteps) return false
1282
+ temporal.redo(marks.undoneSteps)
1283
+ marks.undone = false
1284
+ usePointAsk.getState().dispatchPin(askId, { type: 'redo' })
1285
+ return true
1286
+ }
1287
+
1288
+ /**
1289
+ * How many steps the scene has taken since the ask's turn ended: the person's own edits, which an
1290
+ * Undo would remove too. A history that no longer holds the turn's end counts as one.
1291
+ */
1292
+ export function handEditsSince(askId: string): number {
1293
+ const marks = askMarks.get(askId)
1294
+ if (!marks?.end) return 0
1295
+ return stepsFrom(marks.end) ?? 1
1296
+ }
1297
+
1298
+ // ─── lifecycle ──────────────────────────────────────────────────────────────
1299
+
1300
+ /** Starts listening for the keys, the pointer, the chat's statuses and the scene's commits. */
1301
+ export function startPointAskSession(): () => void {
1302
+ if (stopSession) return stopSession
1303
+ const offs: (() => void)[] = []
1304
+ const listen = <K extends keyof WindowEventMap>(
1305
+ type: K,
1306
+ handler: (event: WindowEventMap[K]) => void,
1307
+ options: AddEventListenerOptions | boolean = true,
1308
+ ) => {
1309
+ window.addEventListener(type, handler as EventListener, options)
1310
+ offs.push(() => window.removeEventListener(type, handler as EventListener, options))
1311
+ }
1312
+ listen('keydown', onKeyDown)
1313
+ listen('keyup', onKeyUp)
1314
+ listen('blur', onBlur, false)
1315
+ // A hold on the "before" ends on any way out: a key (Cmd+S, Cmd+Z, Delete are not for a preview),
1316
+ // the window or tab losing focus, the page going away.
1317
+ listen('keydown', (event) => {
1318
+ if (!event.repeat) endCompare()
1319
+ })
1320
+ listen('pointerup', endCompare)
1321
+ listen('blur', endCompare, false)
1322
+ listen('pagehide', endCompare, false)
1323
+ listen('pointercancel', endCompare)
1324
+ const onHidden = () => {
1325
+ if (document.hidden) endCompare()
1326
+ }
1327
+ document.addEventListener('visibilitychange', onHidden)
1328
+ offs.push(() => document.removeEventListener('visibilitychange', onHidden))
1329
+ // The scene lets go of a preview itself when something is about to write: the tag follows.
1330
+ offs.push(
1331
+ subscribeScenePreview((previewing) => {
1332
+ if (!previewing) compare.released()
1333
+ }),
1334
+ )
1335
+ listen('pointerdown', onPointerDown)
1336
+ listen('pointermove', onPointerMove)
1337
+ listen('pointerup', onPointerUp)
1338
+ listen('click', swallowCanvasClick)
1339
+ listen('dblclick', swallowCanvasClick)
1340
+ emitter.on('*', onAnyEvent as never)
1341
+ offs.push(() => emitter.off('*', onAnyEvent as never))
1342
+ emitter.on('point-ask:status', onStatus as never)
1343
+ offs.push(() => emitter.off('point-ask:status', onStatus as never))
1344
+ offs.push(subscribeSceneCommits(onCommit))
1345
+ // The editor's selection manager resets the hover style whenever the tool mode changes, which
1346
+ // putting an armed tool aside does: while the mode is on the point style is kept.
1347
+ offs.push(
1348
+ useViewer.subscribe((state) => {
1349
+ if (usePointAsk.getState().active && state.hoverHighlightMode !== 'point')
1350
+ state.setHoverHighlightMode('point')
1351
+ }),
1352
+ )
1353
+ // Another interaction taking the pointer ends the mode; a level or view change drops stale hover.
1354
+ offs.push(
1355
+ useInteractionScope.subscribe((state) => {
1356
+ if (usePointAsk.getState().active && state.scope.kind !== 'pointing') leavePointMode()
1357
+ }),
1358
+ )
1359
+ offs.push(
1360
+ useEditor.subscribe((state, prev) => {
1361
+ if (!usePointAsk.getState().active) return
1362
+ if (!isViewVisible(state, VIEW_3D)) leavePointMode()
1363
+ if (state.isFirstPersonMode && !prev.isFirstPersonMode) leavePointMode()
1364
+ }),
1365
+ )
1366
+ offs.push(
1367
+ useScene.subscribe((state, prev) => {
1368
+ if (state.nodes === prev.nodes) return
1369
+ const point = usePointAsk.getState()
1370
+ if (point.hover && !state.nodes[point.hover.target.id as AnyNodeId]) point.setHover(null)
1371
+ targetCache.clear()
1372
+ }),
1373
+ )
1374
+ stopSession = () => {
1375
+ endCompare()
1376
+ for (const off of offs) off()
1377
+ leavePointMode()
1378
+ runningAskId = null
1379
+ stopSession = null
1380
+ }
1381
+ return stopSession
1382
+ }
1383
+
1384
+ // Used by the overlay to know a node's level for hiding pins on hidden levels.
1385
+ export function levelOfNode(id: string): string | null {
1386
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
1387
+ const node = nodes[id]
1388
+ return node ? (resolveLevelId(node, nodes) ?? null) : null
1389
+ }
1390
+
1391
+ export { unionRect }