@pascal-app/editor 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode } from '@pascal-app/core'
3
+ import { house } from './__fixtures__/house'
4
+ import { describeTarget } from './describe-target'
5
+
6
+ // Point and ask names what the finger is on, in the viewer's unit, for the label chip and the
7
+ // bubble's context row, and in metres for the agent (the context's `size`). Written first: a size
8
+ // in the wrong unit; a wall that does not say which room it bounds; a window's sill missing from the
9
+ // context; an element of a kind nobody described showing no name at all.
10
+
11
+ const nodes = house()
12
+ const metric = { unit: 'metric' } as const
13
+ const at = (id: string, unit: Parameters<typeof describeTarget>[2] = metric) =>
14
+ describeTarget(nodes[id] as AnyNode, nodes, unit)
15
+
16
+ describe('a room', () => {
17
+ test('is named by its zone, sized by its area, and is the kind "room"', () => {
18
+ const room = at('zone_kitchen')
19
+
20
+ expect(room).toMatchObject({
21
+ id: 'zone_kitchen',
22
+ type: 'zone',
23
+ kind: 'room',
24
+ name: 'Kitchen',
25
+ size: '12.0 m²',
26
+ levelId: 'level_0',
27
+ })
28
+ expect(room.sizes).toEqual({ area: 12, width: 4, depth: 3 })
29
+ expect(room.zoneId).toBeUndefined()
30
+ })
31
+
32
+ test('a room with no name is "Room"', () => {
33
+ const unnamed = { ...nodes.zone_kitchen, name: '' } as AnyNode
34
+
35
+ expect(describeTarget(unnamed, { ...nodes, zone_kitchen: unnamed }, metric).name).toBe('Room')
36
+ })
37
+ })
38
+
39
+ describe('a wall', () => {
40
+ test('says which room it bounds, its length by its height, and the room it is in', () => {
41
+ const wall = at('wall_north')
42
+
43
+ expect(wall).toMatchObject({ kind: 'wall', name: 'Wall · Kitchen', zoneId: 'zone_kitchen' })
44
+ expect(wall.sizes.length).toBeCloseTo(4, 6)
45
+ expect(wall.sizes.thickness).toBeGreaterThan(0)
46
+ expect(wall.size).toBe(`4.00 × ${wall.sizes.height!.toFixed(2)} m`)
47
+ })
48
+
49
+ test('a wall bounding no room is just "Wall"', () => {
50
+ const { zone_kitchen: _gone, ...without } = nodes
51
+ const wall = describeTarget(nodes.wall_north as AnyNode, without as typeof nodes, metric)
52
+
53
+ expect(wall.name).toBe('Wall')
54
+ expect(wall.zoneId).toBeUndefined()
55
+ })
56
+ })
57
+
58
+ describe('a window and a door', () => {
59
+ test('a window: width by height, its sill in the context, its wall as parent', () => {
60
+ const window = at('window_north')
61
+
62
+ expect(window).toMatchObject({
63
+ kind: 'window',
64
+ name: 'Window',
65
+ size: '1.20 × 1.40 m',
66
+ parentId: 'wall_north',
67
+ zoneId: 'zone_kitchen',
68
+ })
69
+ expect(window.sizes.width).toBeCloseTo(1.2, 6)
70
+ expect(window.sizes.height).toBeCloseTo(1.4, 6)
71
+ expect(window.sizes.sill).toBeCloseTo(0.7, 6)
72
+ })
73
+
74
+ test('a door stands on the floor: sill 0', () => {
75
+ const door = at('door_front')
76
+
77
+ expect(door).toMatchObject({ kind: 'door', name: 'Door', size: '0.90 × 2.10 m' })
78
+ expect(door.sizes.sill).toBeCloseTo(0, 6)
79
+ })
80
+ })
81
+
82
+ describe('an item', () => {
83
+ test('is named by the person’s name, sized by its width, and sits in the room it stands in', () => {
84
+ const sofa = at('item_sofa')
85
+
86
+ expect(sofa).toMatchObject({
87
+ kind: 'item',
88
+ name: 'Sofa',
89
+ size: '2.10 m wide',
90
+ zoneId: 'zone_kitchen',
91
+ })
92
+ expect(sofa.parentId).toBeUndefined()
93
+ expect(sofa.sizes).toEqual({ width: 2.1, depth: 0.95, height: 0.85 })
94
+ })
95
+
96
+ test('with no name it is the asset’s, and then just "Item"', () => {
97
+ const unnamed = { ...nodes.item_sofa, name: undefined } as AnyNode
98
+ expect(describeTarget(unnamed, nodes, metric).name).toBe('Oslo 3-seat')
99
+
100
+ const bare = {
101
+ ...nodes.item_sofa,
102
+ name: undefined,
103
+ asset: { ...(nodes.item_sofa as never as { asset: object }).asset, name: '' },
104
+ } as AnyNode
105
+ expect(describeTarget(bare, nodes, metric).name).toBe('Item')
106
+ })
107
+ })
108
+
109
+ describe('units', () => {
110
+ test('imperial reads in feet, inches and square feet', () => {
111
+ const imperial = { unit: 'imperial' } as const
112
+
113
+ expect(at('zone_kitchen', imperial).size).toBe('129.2 ft²')
114
+ expect(at('window_north', imperial).size).toBe(`3'11" × 4'7"`)
115
+ expect(at('item_sofa', imperial).size).toBe(`6'11" wide`)
116
+ // The context stays in metres whatever the viewer shows.
117
+ expect(at('window_north', imperial).sizes.width).toBeCloseTo(1.2, 6)
118
+ })
119
+
120
+ test('millimetres when the viewer shows them', () => {
121
+ const mm = { unit: 'metric', metricNotation: 'millimeters' } as const
122
+
123
+ expect(at('window_north', mm).size).toBe('1200 × 1400 mm')
124
+ expect(at('item_sofa', mm).size).toBe('2100 mm wide')
125
+ })
126
+ })
127
+
128
+ describe('everything else', () => {
129
+ test('a kind nobody described still has a name and a type', () => {
130
+ const odd = {
131
+ id: 'column_1',
132
+ type: 'column',
133
+ parentId: 'level_0',
134
+ name: 'Pillar',
135
+ } as unknown as AnyNode
136
+ const anonymous = { id: 'fence_1', type: 'fence', parentId: 'level_0' } as unknown as AnyNode
137
+
138
+ expect(describeTarget(odd, { ...nodes, column_1: odd }, metric)).toMatchObject({
139
+ kind: 'other',
140
+ name: 'Pillar',
141
+ type: 'column',
142
+ })
143
+ expect(describeTarget(anonymous, { ...nodes, fence_1: anonymous }, metric).name).toBe('Fence')
144
+ })
145
+
146
+ test('a roof segment is the kind "roof" and a stair the kind "stair"', () => {
147
+ const segment = {
148
+ id: 'roof_segment_1',
149
+ type: 'roof-segment',
150
+ parentId: 'roof_1',
151
+ width: 8,
152
+ depth: 6,
153
+ } as unknown as AnyNode
154
+ const stair = {
155
+ id: 'stair_1',
156
+ type: 'stair',
157
+ parentId: 'level_0',
158
+ width: 1,
159
+ totalRise: 2.7,
160
+ } as unknown as AnyNode
161
+
162
+ expect(describeTarget(segment, nodes, metric).kind).toBe('roof')
163
+ expect(describeTarget(stair, nodes, metric).kind).toBe('stair')
164
+ })
165
+ })
@@ -0,0 +1,234 @@
1
+ import {
2
+ type AnyNode,
3
+ area,
4
+ getScaledDimensions,
5
+ getWallCurveLength,
6
+ getWallEffectiveHeightForNodes,
7
+ getWallThickness,
8
+ type ItemNode,
9
+ resolveLevelId,
10
+ type SlabNode,
11
+ type StairNode,
12
+ type WallNode,
13
+ type WindowNode,
14
+ type ZoneNode,
15
+ } from '@pascal-app/core'
16
+ import {
17
+ formatLinearMeasurement,
18
+ getAreaUnitLabel,
19
+ type LinearUnit,
20
+ type MetricNotation,
21
+ squareMetersToAreaUnit,
22
+ } from '../measurements'
23
+ import type { PointKind, PointTarget } from './types'
24
+
25
+ // Point and ask names what the finger is on (the owner, 8 October): the label chip and the
26
+ // bubble's context row read it in the viewer's unit; the agent reads the same element's `sizes` in
27
+ // metres. Pure: the scene is handed in.
28
+
29
+ export type PointUnit = { unit: LinearUnit; metricNotation?: MetricNotation }
30
+
31
+ /** A described element: the chip's fields, plus what the agent's context carries. */
32
+ export type DescribedTarget = PointTarget & {
33
+ levelId: string
34
+ /** The room it is in or bounds. */
35
+ zoneId?: string
36
+ /** The wall an opening is in, the item an item stands on. */
37
+ parentId?: string
38
+ /** Metres: wall {length, height, thickness}; zone {area, width, depth}; window/door {width,
39
+ * height, sill}; item {width, depth, height}. */
40
+ sizes: Record<string, number>
41
+ }
42
+
43
+ type Nodes = Readonly<Record<string, AnyNode | undefined>>
44
+
45
+ const KINDS: Partial<Record<string, PointKind>> = {
46
+ zone: 'room',
47
+ wall: 'wall',
48
+ window: 'window',
49
+ door: 'door',
50
+ item: 'item',
51
+ roof: 'roof',
52
+ 'roof-segment': 'roof',
53
+ stair: 'stair',
54
+ 'stair-segment': 'stair',
55
+ }
56
+
57
+ const DEFAULT_NAMES: Partial<Record<string, string>> = {
58
+ slab: 'Floor',
59
+ ceiling: 'Ceiling',
60
+ }
61
+
62
+ const sentence = (type: string) => {
63
+ const words = type.replace(/-/g, ' ')
64
+ return words.charAt(0).toUpperCase() + words.slice(1)
65
+ }
66
+
67
+ const metres = (value: number, unit: PointUnit) => {
68
+ if (unit.unit === 'imperial') return formatLinearMeasurement(value, 'imperial')
69
+ return unit.metricNotation === 'millimeters' ? `${Math.round(value * 1000)}` : value.toFixed(2)
70
+ }
71
+
72
+ /** "4.80 × 2.70 m", "3'11" × 4'7"", "1200 × 1400 mm". */
73
+ function dimensions(values: number[], unit: PointUnit): string {
74
+ const parts = values.map((value) => metres(value, unit)).join(' × ')
75
+ if (unit.unit === 'imperial') return parts
76
+ return `${parts} ${unit.metricNotation === 'millimeters' ? 'mm' : 'm'}`
77
+ }
78
+
79
+ /** "2.10 m wide". */
80
+ function wide(value: number, unit: PointUnit): string {
81
+ return `${dimensions([value], unit)} wide`
82
+ }
83
+
84
+ function areaLabel(squareMetres: number, unit: PointUnit): string {
85
+ return `${squareMetersToAreaUnit(squareMetres, unit.unit).toFixed(1)} ${getAreaUnitLabel(unit.unit)}`
86
+ }
87
+
88
+ function extent(points: readonly (readonly number[])[], axis: 0 | 1): number {
89
+ const values = points.map((point) => point[axis]!)
90
+ return values.length ? Math.max(...values) - Math.min(...values) : 0
91
+ }
92
+
93
+ function insidePolygon(point: readonly [number, number], polygon: readonly (readonly number[])[]) {
94
+ let inside = false
95
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
96
+ const [xi, zi] = [polygon[i]![0]!, polygon[i]![1]!]
97
+ const [xj, zj] = [polygon[j]![0]!, polygon[j]![1]!]
98
+ if (
99
+ zi > point[1] !== zj > point[1] &&
100
+ point[0] < ((xj - xi) * (point[1] - zi)) / (zj - zi) + xi
101
+ )
102
+ inside = !inside
103
+ }
104
+ return inside
105
+ }
106
+
107
+ const zonesOf = (nodes: Nodes, levelId: string) =>
108
+ Object.values(nodes).filter(
109
+ (node): node is ZoneNode => node?.type === 'zone' && node.parentId === levelId,
110
+ )
111
+
112
+ /** The room a wall bounds: the zone that lists it among its boundary walls. */
113
+ const roomOfWall = (nodes: Nodes, levelId: string, wallId: string) =>
114
+ zonesOf(nodes, levelId).find((zone) => zone.boundaryWallIds?.includes(wallId as never))
115
+
116
+ function wallHeight(wall: WallNode, nodes: Nodes): number {
117
+ try {
118
+ const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>)
119
+ if (Number.isFinite(height) && height > 0) return height
120
+ } catch {
121
+ // No slab election to consult in a bare scene: the wall's own height stands.
122
+ }
123
+ return wall.height ?? 2.5
124
+ }
125
+
126
+ export function describeTarget(node: AnyNode, nodes: Nodes, unit: PointUnit): DescribedTarget {
127
+ const levelId = resolveLevelId(node, nodes as Record<string, AnyNode>)
128
+ const named = (fallback: string) => (node as { name?: string }).name?.trim() || fallback
129
+ const base = {
130
+ id: node.id,
131
+ type: node.type,
132
+ kind: KINDS[node.type] ?? ('other' as PointKind),
133
+ levelId,
134
+ }
135
+
136
+ if (node.type === 'zone') {
137
+ const zone = node as ZoneNode
138
+ const squareMetres = area([{ outer: zone.polygon, holes: zone.holes }])
139
+ return {
140
+ ...base,
141
+ name: named('Room'),
142
+ size: areaLabel(squareMetres, unit),
143
+ sizes: {
144
+ area: squareMetres,
145
+ width: extent(zone.polygon, 0),
146
+ depth: extent(zone.polygon, 1),
147
+ },
148
+ }
149
+ }
150
+
151
+ if (node.type === 'wall') {
152
+ const wall = node as WallNode
153
+ const room = roomOfWall(nodes, levelId, wall.id)
154
+ const length = getWallCurveLength(wall)
155
+ const height = wallHeight(wall, nodes)
156
+ return {
157
+ ...base,
158
+ name: named(room ? `Wall · ${room.name?.trim() || 'Room'}` : 'Wall'),
159
+ size: dimensions([length, height], unit),
160
+ sizes: { length, height, thickness: getWallThickness(wall) },
161
+ ...(room ? { zoneId: room.id } : {}),
162
+ }
163
+ }
164
+
165
+ if (node.type === 'window' || node.type === 'door') {
166
+ const opening = node as WindowNode & { wallId?: string }
167
+ const wallId = opening.wallId ?? opening.parentId ?? undefined
168
+ const room = wallId ? roomOfWall(nodes, levelId, wallId) : undefined
169
+ return {
170
+ ...base,
171
+ name: named(node.type === 'window' ? 'Window' : 'Door'),
172
+ size: dimensions([opening.width, opening.height], unit),
173
+ sizes: {
174
+ width: opening.width,
175
+ height: opening.height,
176
+ sill: Math.max(0, opening.position[1] - opening.height / 2),
177
+ },
178
+ ...(opening.parentId ? { parentId: opening.parentId } : {}),
179
+ ...(room ? { zoneId: room.id } : {}),
180
+ }
181
+ }
182
+
183
+ if (node.type === 'item') {
184
+ const item = node as ItemNode
185
+ const [width, height, depth] = getScaledDimensions(item)
186
+ const room = zonesOf(nodes, levelId).find((zone) =>
187
+ insidePolygon([item.position[0], item.position[2]], zone.polygon),
188
+ )
189
+ const onItem = item.parentId && nodes[item.parentId]?.type === 'item'
190
+ return {
191
+ ...base,
192
+ name: named(item.asset.name?.trim() || 'Item'),
193
+ size: wide(width, unit),
194
+ sizes: { width, depth, height },
195
+ ...(onItem ? { parentId: item.parentId as string } : {}),
196
+ ...(room ? { zoneId: room.id } : {}),
197
+ }
198
+ }
199
+
200
+ if (node.type === 'roof-segment') {
201
+ const { width, depth } = node as { width: number; depth: number }
202
+ return {
203
+ ...base,
204
+ name: named('Roof segment'),
205
+ size: dimensions([width, depth], unit),
206
+ sizes: { width, depth },
207
+ }
208
+ }
209
+
210
+ if (node.type === 'stair' || node.type === 'stair-segment') {
211
+ const stair = node as StairNode
212
+ const sizes: Record<string, number> = { width: stair.width ?? 1 }
213
+ if (stair.totalRise !== undefined) sizes.rise = stair.totalRise
214
+ return { ...base, name: named('Stair'), size: wide(sizes.width!, unit), sizes }
215
+ }
216
+
217
+ if (node.type === 'slab' || node.type === 'ceiling') {
218
+ const plate = node as SlabNode
219
+ const squareMetres = area([{ outer: plate.polygon, holes: [] }])
220
+ return {
221
+ ...base,
222
+ name: named(DEFAULT_NAMES[node.type] ?? sentence(node.type)),
223
+ size: areaLabel(squareMetres, unit),
224
+ sizes: { area: squareMetres },
225
+ }
226
+ }
227
+
228
+ return {
229
+ ...base,
230
+ name: named(DEFAULT_NAMES[node.type] ?? sentence(node.type)),
231
+ size: '',
232
+ sizes: {},
233
+ }
234
+ }
@@ -0,0 +1,79 @@
1
+ // Dictate in the bubble (the owner said yes to voice, 8 October): the browser's own speech
2
+ // recognition where it exists, hidden where it does not. The words fill the field and never send:
3
+ // the person reads them and presses Enter.
4
+
5
+ type RecognitionResult = { 0: { transcript: string }; isFinal: boolean; length: number }
6
+ type RecognitionEvent = { resultIndex: number; results: ArrayLike<RecognitionResult> }
7
+ type Recognition = {
8
+ lang: string
9
+ interimResults: boolean
10
+ continuous: boolean
11
+ onresult: ((event: RecognitionEvent) => void) | null
12
+ onend: (() => void) | null
13
+ onerror: ((event: { error: string }) => void) | null
14
+ start: () => void
15
+ stop: () => void
16
+ abort: () => void
17
+ }
18
+ type RecognitionConstructor = new () => Recognition
19
+
20
+ function constructorOf(): RecognitionConstructor | null {
21
+ if (typeof window === 'undefined') return null
22
+ const w = window as unknown as {
23
+ SpeechRecognition?: RecognitionConstructor
24
+ webkitSpeechRecognition?: RecognitionConstructor
25
+ }
26
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
27
+ }
28
+
29
+ export function isDictationSupported(): boolean {
30
+ return constructorOf() !== null
31
+ }
32
+
33
+ export type Dictation = { stop: () => void }
34
+
35
+ /**
36
+ * Starts listening. `onText` gets the whole transcript so far (interim words included) each time
37
+ * it changes; `onEnd` fires once when listening stops, by `stop`, by silence or by an error.
38
+ */
39
+ export function startDictation(options: {
40
+ lang?: string
41
+ onText: (text: string) => void
42
+ onEnd: (reason: 'stopped' | 'error', error?: string) => void
43
+ }): Dictation | null {
44
+ const Ctor = constructorOf()
45
+ if (!Ctor) return null
46
+ const recognition = new Ctor()
47
+ recognition.lang =
48
+ options.lang ?? (typeof navigator === 'undefined' ? 'en-US' : navigator.language)
49
+ recognition.interimResults = true
50
+ recognition.continuous = true
51
+ let finished = false
52
+ const finish = (reason: 'stopped' | 'error', error?: string) => {
53
+ if (finished) return
54
+ finished = true
55
+ options.onEnd(reason, error)
56
+ }
57
+ recognition.onresult = (event) => {
58
+ let text = ''
59
+ for (let i = 0; i < event.results.length; i++) text += event.results[i]?.[0]?.transcript ?? ''
60
+ options.onText(text.trim())
61
+ }
62
+ recognition.onerror = (event) => finish('error', event.error)
63
+ recognition.onend = () => finish('stopped')
64
+ try {
65
+ recognition.start()
66
+ } catch (error) {
67
+ finish('error', error instanceof Error ? error.message : String(error))
68
+ return null
69
+ }
70
+ return {
71
+ stop: () => {
72
+ try {
73
+ recognition.stop()
74
+ } catch {
75
+ finish('stopped')
76
+ }
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { edgeMarker } from './edge-marker'
3
+
4
+ // A pin whose element is out of view (spec 2.6, "work outside the view shows an edge marker"):
5
+ // the marker sits on the viewport's edge, on the line from its centre towards the pin, and points
6
+ // at it. Written first: a marker for a pin that is in view; one off the edge on the wrong side;
7
+ // a pin behind the camera sending the marker nowhere.
8
+ const VIEW = { x0: 0, y0: 0, x1: 1000, y1: 600 }
9
+
10
+ describe('edgeMarker', () => {
11
+ test('a pin in view needs none', () => {
12
+ expect(edgeMarker({ x: 400, y: 300 }, VIEW)).toBeNull()
13
+ })
14
+
15
+ test('a pin to the right sits on the right edge, level with the line to it', () => {
16
+ const marker = edgeMarker({ x: 2000, y: 300 }, VIEW)!
17
+
18
+ expect(marker.x).toBe(1000 - 18)
19
+ expect(marker.y).toBeCloseTo(300, 5)
20
+ expect(marker.angle).toBeCloseTo(0, 5)
21
+ })
22
+
23
+ test('a pin above points up from the top edge', () => {
24
+ const marker = edgeMarker({ x: 500, y: -900 }, VIEW)!
25
+
26
+ expect(marker.y).toBe(18)
27
+ expect(marker.x).toBeCloseTo(500, 5)
28
+ expect(marker.angle).toBeCloseTo(-90, 5)
29
+ })
30
+
31
+ test('a pin beyond a corner sits in that corner region, on the line from the centre', () => {
32
+ const marker = edgeMarker({ x: 3000, y: 1800 }, VIEW)!
33
+
34
+ expect(marker.x).toBeLessThanOrEqual(1000 - 18)
35
+ expect(marker.y).toBeLessThanOrEqual(600 - 18)
36
+ // On the line through the centre (500, 300) and the pin: equal slope.
37
+ const slope = (marker.y - 300) / (marker.x - 500)
38
+ expect(slope).toBeCloseTo((1800 - 300) / (3000 - 500), 5)
39
+ })
40
+
41
+ test('is always inside the inset viewport', () => {
42
+ for (const p of [
43
+ { x: -500, y: 1200 },
44
+ { x: 1500, y: -300 },
45
+ { x: 2000, y: 640 },
46
+ { x: -50, y: 300 },
47
+ ]) {
48
+ const marker = edgeMarker(p, VIEW)!
49
+ expect(marker.x).toBeGreaterThanOrEqual(18)
50
+ expect(marker.x).toBeLessThanOrEqual(1000 - 18)
51
+ expect(marker.y).toBeGreaterThanOrEqual(18)
52
+ expect(marker.y).toBeLessThanOrEqual(600 - 18)
53
+ }
54
+ })
55
+
56
+ test('a pin behind the camera has no direction: the marker sits at the bottom, pointing down', () => {
57
+ const marker = edgeMarker(null, VIEW)!
58
+
59
+ expect(marker).toEqual({ x: 500, y: 600 - 18, angle: 90 })
60
+ })
61
+ })
@@ -0,0 +1,27 @@
1
+ import type { Rect, ScreenPoint } from './types'
2
+
3
+ /**
4
+ * Where the edge marker of an off-screen pin goes: on the viewport's inset edge, on the line from
5
+ * the viewport's centre towards the pin, turned to point at it. Null when the pin is in view; a
6
+ * pin behind the camera (`point` null) has no direction, so its marker sits at the bottom.
7
+ */
8
+ export function edgeMarker(
9
+ point: ScreenPoint | null,
10
+ viewport: Rect,
11
+ inset = 18,
12
+ ): { x: number; y: number; angle: number } | null {
13
+ const cx = (viewport.x0 + viewport.x1) / 2
14
+ const cy = (viewport.y0 + viewport.y1) / 2
15
+ const hw = (viewport.x1 - viewport.x0) / 2 - inset
16
+ const hh = (viewport.y1 - viewport.y0) / 2 - inset
17
+ if (!point) return { x: cx, y: cy + hh, angle: 90 }
18
+ const dx = point.x - cx
19
+ const dy = point.y - cy
20
+ if (Math.abs(dx) <= hw + inset && Math.abs(dy) <= hh + inset) return null
21
+ // Scale the direction until it touches the inset rectangle.
22
+ const scale = Math.min(
23
+ dx === 0 ? Number.POSITIVE_INFINITY : hw / Math.abs(dx),
24
+ dy === 0 ? Number.POSITIVE_INFINITY : hh / Math.abs(dy),
25
+ )
26
+ return { x: cx + dx * scale, y: cy + dy * scale, angle: (Math.atan2(dy, dx) * 180) / Math.PI }
27
+ }
@@ -0,0 +1,25 @@
1
+ // One requestAnimationFrame loop for everything that follows the camera (the outline corners, the
2
+ // bubble, the pins): it runs only while something subscribes, and each subscriber gets the frame's
3
+ // delta in seconds (capped, so a hidden tab does not fling springs on return).
4
+
5
+ type FrameFn = (dt: number, now: number) => void
6
+
7
+ const subscribers = new Set<FrameFn>()
8
+ let frame = 0
9
+ let last = 0
10
+
11
+ function tick(now: number) {
12
+ const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016)
13
+ last = now
14
+ for (const fn of [...subscribers]) fn(dt, now)
15
+ frame = subscribers.size > 0 ? requestAnimationFrame(tick) : 0
16
+ if (!frame) last = 0
17
+ }
18
+
19
+ export function subscribeFrame(fn: FrameFn): () => void {
20
+ subscribers.add(fn)
21
+ if (!frame && typeof requestAnimationFrame === 'function') frame = requestAnimationFrame(tick)
22
+ return () => {
23
+ subscribers.delete(fn)
24
+ }
25
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createdSince, markHistory, stateAfterMark, stepsSince } from './history-marks'
3
+
4
+ // Undo from a pin steps back through what the ask's turn wrote. The scene history keeps only the
5
+ // last 50 steps and a Cmd+Z shrinks it, so a length alone lies: the mark holds the last step that
6
+ // stood when the turn began, and the steps since are counted from where that step is now.
7
+ const steps = (n: number, from = 0) => Array.from({ length: n }, (_, i) => ({ id: from + i }))
8
+
9
+ describe('stepsSince', () => {
10
+ test('counts the steps pushed after the mark', () => {
11
+ const past = steps(3)
12
+ const mark = markHistory(past)
13
+ const later = [...past, ...steps(2, 3)]
14
+
15
+ expect(stepsSince(later, mark)).toBe(2)
16
+ expect(stepsSince(past, mark)).toBe(0)
17
+ })
18
+
19
+ test('an empty history at the mark counts everything since', () => {
20
+ const mark = markHistory([])
21
+
22
+ expect(stepsSince(steps(4), mark)).toBe(4)
23
+ })
24
+
25
+ test('still counts once old steps fall off the front (the history limit)', () => {
26
+ const past = steps(50)
27
+ const mark = markHistory(past)
28
+ const later = [...past, ...steps(3, 50)].slice(3)
29
+
30
+ expect(stepsSince(later, mark)).toBe(3)
31
+ })
32
+
33
+ test('is null when the marked step itself fell off, so the whole turn cannot be undone', () => {
34
+ const past = steps(50)
35
+ const mark = markHistory(past)
36
+ const later = [...past, ...steps(60, 50)].slice(-50)
37
+
38
+ expect(stepsSince(later, mark)).toBeNull()
39
+ })
40
+
41
+ test('is null after the person undid back past the mark', () => {
42
+ const past = steps(5)
43
+ const mark = markHistory(past)
44
+
45
+ expect(stepsSince(past.slice(0, 2), mark)).toBeNull()
46
+ })
47
+
48
+ test('a Cmd+Z that only took part of the turn leaves the rest countable', () => {
49
+ const past = steps(3)
50
+ const mark = markHistory(past)
51
+ const written = [...past, ...steps(4, 3)]
52
+ const afterOneUndo = written.slice(0, -1)
53
+
54
+ expect(stepsSince(afterOneUndo, mark)).toBe(3)
55
+ })
56
+ })
57
+
58
+ describe('stateAfterMark', () => {
59
+ test('is the state the first step since the mark was taken from', () => {
60
+ const past = steps(3)
61
+ const mark = markHistory(past)
62
+ const later = [...past, ...steps(2, 3)]
63
+
64
+ expect(stateAfterMark(later, mark)).toBe(later[3] ?? null)
65
+ })
66
+
67
+ test('with an empty history at the mark it is the oldest state', () => {
68
+ const mark = markHistory([])
69
+ const later = steps(2)
70
+
71
+ expect(stateAfterMark(later, mark)).toBe(later[0] ?? null)
72
+ })
73
+
74
+ test('is null when nothing was pushed since the mark, or the marked step is gone', () => {
75
+ const past = steps(3)
76
+ const mark = markHistory(past)
77
+
78
+ expect(stateAfterMark(past, mark)).toBeNull()
79
+ expect(stateAfterMark(steps(2, 10), mark)).toBeNull()
80
+ })
81
+ })
82
+
83
+ describe('createdSince', () => {
84
+ test('is the nodes the scene has now that the earlier state did not', () => {
85
+ const before = { wall_a: {}, window_a: {} }
86
+ const now = { wall_a: {}, window_a: {}, window_b: {}, wall_b: {} }
87
+
88
+ expect(createdSince(before, now).sort()).toEqual(['wall_b', 'window_b'])
89
+ })
90
+
91
+ test('a node only changed is not created, and one deleted is not either', () => {
92
+ expect(createdSince({ a: {}, b: {} }, { a: { width: 2 } })).toEqual([])
93
+ })
94
+ })