@pascal-app/editor 1.0.1 → 1.0.4

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 (498) hide show
  1. package/package.json +16 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +221 -128
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +37 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,225 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useFrame } from '@react-three/fiber'
6
+ import { useEffect, useMemo, useRef } from 'react'
7
+ import {
8
+ BufferGeometry,
9
+ DoubleSide,
10
+ Float32BufferAttribute,
11
+ type Group,
12
+ type Object3D,
13
+ } from 'three'
14
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
15
+ import { useShallow } from 'zustand/react/shallow'
16
+ import { useHighlightedRoom } from '../../hooks/use-selected-room'
17
+ import { EDITOR_LAYER } from '../../lib/constants'
18
+ import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
19
+ import {
20
+ getRoomAssembly,
21
+ type RoomAssemblyHeights,
22
+ resolveRoomAssemblyHeights,
23
+ } from '../../lib/room-assembly-overlay'
24
+ import { describeZoneOutline, type RoomSelectionRecord } from '../../lib/room-selection'
25
+ import useEditor from '../../store/use-editor'
26
+ import useInteractionScope from '../../store/use-interaction-scope'
27
+ import { RoomControls2D, RoomControls3D } from './room-controls'
28
+
29
+ /**
30
+ * A zone reached as a zone (hovered or selected through its area or pill, not
31
+ * as a room) shows the room's outline, never a tinted volume.
32
+ */
33
+ function useZoneOutline() {
34
+ const hoveredId = useViewer((state) => state.hoveredId)
35
+ const selectedId = useViewer(
36
+ (state) =>
37
+ state.selection.zoneId ??
38
+ (state.selection.selectedIds.length === 1 ? state.selection.selectedIds[0] : null),
39
+ )
40
+ const unitFocused = useViewer((state) => !!state.focusedUnitId)
41
+ const zone = useScene((state) => {
42
+ for (const id of [hoveredId, selectedId]) {
43
+ const node = id ? state.nodes[id as AnyNodeId] : undefined
44
+ if (node?.type === 'zone') return node
45
+ }
46
+ return null
47
+ })
48
+ return useMemo(
49
+ () => (zone?.parentId && !unitFocused ? describeZoneOutline(zone.parentId, zone) : null),
50
+ [zone, unitFocused],
51
+ )
52
+ }
53
+
54
+ function useRoomHighlight() {
55
+ const room = useHighlightedRoom()
56
+ const zoneOutline = useZoneOutline()
57
+ const visible = useInteractionScope(
58
+ (state) => resolveOverlayPolicy(state.scope).conflictingControls === 'shown',
59
+ )
60
+ // No phase gate: site never hovers or keeps a room (`roomPickingEnabled`).
61
+ const enabled = useEditor((state) => state.mode === 'select')
62
+ return enabled && visible ? (room ?? zoneOutline) : null
63
+ }
64
+
65
+ const noRaycast = () => {}
66
+ const outlineMaterial = new LineBasicNodeMaterial({
67
+ color: '#818cf8',
68
+ depthTest: false,
69
+ depthWrite: false,
70
+ })
71
+ const fillMaterial = (opacity: number) =>
72
+ new MeshBasicNodeMaterial({
73
+ color: '#818cf8',
74
+ depthTest: false,
75
+ depthWrite: false,
76
+ opacity,
77
+ side: DoubleSide,
78
+ transparent: true,
79
+ })
80
+ const floorMaterial = fillMaterial(0.18)
81
+ // Walls and the ceiling stack over the floor when seen from above.
82
+ const surfaceMaterial = fillMaterial(0.1)
83
+
84
+ function positions(array: Float32Array) {
85
+ const geometry = new BufferGeometry()
86
+ geometry.setAttribute('position', new Float32BufferAttribute(array, 3))
87
+ return geometry
88
+ }
89
+
90
+ function RoomMesh({ room }: { room: RoomSelectionRecord }) {
91
+ const { levelId } = room.key
92
+ // Zone, plate and ceiling edits already produce a new record; wall and
93
+ // storey heights live on these nodes.
94
+ const heightSources = useScene(
95
+ useShallow((state) => [
96
+ state.nodes[levelId as keyof typeof state.nodes],
97
+ ...room.boundaryWallIds.map((id) => state.nodes[id as keyof typeof state.nodes]),
98
+ ]),
99
+ )
100
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `heightSources` re-resolves heights from the current node map.
101
+ const heights = useMemo(
102
+ () => resolveRoomAssemblyHeights(room, useScene.getState().nodes),
103
+ [room, heightSources],
104
+ )
105
+ return (
106
+ <RoomAssemblyMesh
107
+ heights={heights}
108
+ levelObject={() => sceneRegistry.nodes.get(levelId)}
109
+ room={room}
110
+ />
111
+ )
112
+ }
113
+
114
+ /** A room's floor, wall faces and outline in the highlight colour, riding its level's object. */
115
+ export function RoomAssemblyMesh({
116
+ room,
117
+ heights,
118
+ levelObject,
119
+ }: {
120
+ room: RoomSelectionRecord
121
+ heights: RoomAssemblyHeights
122
+ levelObject: () => Object3D | undefined
123
+ }) {
124
+ const ref = useRef<Group>(null)
125
+ const assembly = useMemo(() => getRoomAssembly(room.geometry, heights), [room, heights])
126
+ const geometry = useMemo(
127
+ () => ({
128
+ floor: positions(assembly.floor),
129
+ surfaces: positions(assembly.surfaces),
130
+ outline: positions(assembly.outline),
131
+ }),
132
+ [assembly],
133
+ )
134
+ useEffect(
135
+ () => () => {
136
+ geometry.floor.dispose()
137
+ geometry.surfaces.dispose()
138
+ geometry.outline.dispose()
139
+ },
140
+ [geometry],
141
+ )
142
+ useFrame(() => {
143
+ const level = levelObject()
144
+ if (!ref.current) return
145
+ ref.current.visible = !!level
146
+ if (level) {
147
+ level.updateWorldMatrix(true, false)
148
+ ref.current.matrix.copy(level.matrixWorld)
149
+ }
150
+ })
151
+ return (
152
+ <group matrixAutoUpdate={false} ref={ref}>
153
+ <mesh
154
+ geometry={geometry.floor}
155
+ layers={EDITOR_LAYER}
156
+ material={floorMaterial}
157
+ raycast={noRaycast}
158
+ renderOrder={100}
159
+ />
160
+ <mesh
161
+ geometry={geometry.surfaces}
162
+ layers={EDITOR_LAYER}
163
+ material={surfaceMaterial}
164
+ raycast={noRaycast}
165
+ renderOrder={100}
166
+ />
167
+ <lineSegments
168
+ geometry={geometry.outline}
169
+ layers={EDITOR_LAYER}
170
+ material={outlineMaterial}
171
+ raycast={noRaycast}
172
+ renderOrder={101}
173
+ />
174
+ </group>
175
+ )
176
+ }
177
+
178
+ export function RoomHighlight3D() {
179
+ const room = useRoomHighlight()
180
+ return (
181
+ <>
182
+ <RoomControls3D />
183
+ {room ? <RoomMesh room={room} /> : null}
184
+ </>
185
+ )
186
+ }
187
+
188
+ export function RoomHighlight2D({ levelId }: { levelId: string | null }) {
189
+ const room = useRoomHighlight()
190
+ const shape = room?.geometry
191
+ const paths = useMemo(() => {
192
+ if (!shape) return null
193
+ const ringPath = (ring: [number, number][]) =>
194
+ `M ${ring.map((point) => point.join(' ')).join(' L ')} Z`
195
+ return {
196
+ floor: shape.clearPolygon
197
+ .flatMap(({ outer, holes }) => [outer, ...holes])
198
+ .map(ringPath)
199
+ .join(' '),
200
+ walls: shape.boundaryWallIds
201
+ .flatMap((id) => {
202
+ const ring = shape.context.wallFootprints.get(id)
203
+ return ring ? [ringPath(ring)] : []
204
+ })
205
+ .join(' '),
206
+ }
207
+ }, [shape])
208
+ return (
209
+ <>
210
+ <RoomControls2D levelId={levelId} />
211
+ {room && paths && room.key.levelId === levelId && (
212
+ <g data-room-highlight={room.key.zoneId} pointerEvents="none">
213
+ <path d={paths.floor} fill="#818cf8" fillOpacity={0.18} fillRule="evenodd" />
214
+ <path
215
+ d={paths.walls}
216
+ fill="none"
217
+ stroke="#818cf8"
218
+ strokeWidth={2}
219
+ vectorEffect="non-scaling-stroke"
220
+ />
221
+ </g>
222
+ )}
223
+ </>
224
+ )
225
+ }
@@ -0,0 +1,357 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ZoneNode } from '@pascal-app/core/schema'
3
+ import type { RoomSelectionRecord } from '../../lib/room-selection'
4
+ import {
5
+ DeclutterClock,
6
+ formatRoomArea,
7
+ measureFullPill,
8
+ PILL_DOT_SIZE,
9
+ type PillCandidate,
10
+ type PillPlacement,
11
+ PillPlacer,
12
+ pillRect,
13
+ placePills,
14
+ type RoomLabelVisibilityState,
15
+ roomLabelEntries,
16
+ roomLabelsVisible,
17
+ } from './room-labels'
18
+
19
+ const resting: RoomLabelVisibilityState = {
20
+ phase: 'building',
21
+ mode: 'select',
22
+ scopeIdle: true,
23
+ room: null,
24
+ selectedTypes: [],
25
+ zoneId: null,
26
+ focusedUnitId: null,
27
+ isCaptureMode: false,
28
+ isThumbnailCapture: false,
29
+ }
30
+
31
+ describe('room label pills', () => {
32
+ test('show on the resting level, with only the site, building or level picked', () => {
33
+ expect(roomLabelsVisible(resting)).toBe(true)
34
+ for (const type of ['site', 'building', 'level']) {
35
+ expect(roomLabelsVisible({ ...resting, selectedTypes: [type] })).toBe(true)
36
+ }
37
+ })
38
+
39
+ test('step back for any other selection, a room, a zone, a unit, a tool, a gesture or site', () => {
40
+ for (const change of [
41
+ { selectedTypes: ['wall'] },
42
+ { selectedTypes: ['level', 'item'] },
43
+ { selectedTypes: [undefined] },
44
+ { room: { levelId: 'level_1', zoneId: 'zone_1' } },
45
+ { zoneId: 'zone_1' },
46
+ { focusedUnitId: 'unit_1' },
47
+ { mode: 'build' },
48
+ { scopeIdle: false },
49
+ { phase: 'site' },
50
+ { isCaptureMode: true },
51
+ { isThumbnailCapture: true },
52
+ ] satisfies Partial<RoomLabelVisibilityState>[]) {
53
+ expect(roomLabelsVisible({ ...resting, ...change })).toBe(false)
54
+ }
55
+ })
56
+
57
+ test('read the area in the active unit system', () => {
58
+ expect(formatRoomArea(33, 'metric')).toBe('33.0 m²')
59
+ expect(formatRoomArea(10, 'imperial')).toBe('107.6 ft²')
60
+ })
61
+
62
+ test('a pill is placed centred on where its anchor lands', () => {
63
+ expect(pillRect(100, 50, [80, 20])).toEqual({ left: 60, top: 40, right: 140, bottom: 60 })
64
+ })
65
+
66
+ test('a collapsed pill measures its updated full label and stays collapsed', () => {
67
+ const element = { dataset: { roomLabelCollapsed: '' } } as unknown as HTMLElement
68
+ let width = 161.5
69
+ const read = (target: HTMLElement) => ({
70
+ width: 'roomLabelCollapsed' in target.dataset ? '16px' : `${width}px`,
71
+ height: 'roomLabelCollapsed' in target.dataset ? '16px' : '26px',
72
+ })
73
+ expect(measureFullPill(element, read)).toEqual([161.5, 26])
74
+ width = 176.25
75
+ expect(measureFullPill(element, read)).toEqual([176.25, 26])
76
+ expect(element.dataset.roomLabelCollapsed).toBe('')
77
+ })
78
+
79
+ test('invisible rooms and drawn zones do not reserve space for a pill', () => {
80
+ const polygon: [number, number][] = [
81
+ [0, 0],
82
+ [3, 0],
83
+ [3, 3],
84
+ [0, 3],
85
+ ]
86
+ const room = {
87
+ zoneId: 'zone_room',
88
+ polygon,
89
+ holes: [],
90
+ area: 9,
91
+ } as unknown as RoomSelectionRecord
92
+ const zones = [
93
+ ZoneNode.parse({ id: 'zone_room', name: 'Room', polygon, visible: false }),
94
+ ZoneNode.parse({ id: 'zone_hidden', name: 'Hidden', polygon, visible: false }),
95
+ ZoneNode.parse({ id: 'zone_visible', name: 'Visible', polygon }),
96
+ ]
97
+ expect(roomLabelEntries([room], zones).map((entry) => entry.zoneId)).toEqual(['zone_visible'])
98
+ expect(roomLabelEntries([room], [{ ...zones[0]!, visible: true }])[0]).toEqual({
99
+ zoneId: room.zoneId,
100
+ polygon,
101
+ holes: [],
102
+ area: 9,
103
+ })
104
+ })
105
+ })
106
+
107
+ describe('pill placement', () => {
108
+ const pill = (id: string, x: number, y: number, area: number, width = 120): PillCandidate => ({
109
+ id,
110
+ x,
111
+ y,
112
+ size: [width, 24],
113
+ area,
114
+ })
115
+ const rectOf = (candidate: PillCandidate, dy: number, dot: boolean) =>
116
+ pillRect(candidate.x, candidate.y + dy, dot ? [PILL_DOT_SIZE, PILL_DOT_SIZE] : candidate.size)
117
+ const apart = (a: ReturnType<typeof pillRect>, b: ReturnType<typeof pillRect>) =>
118
+ a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top
119
+
120
+ test('apart, every pill stays on its room', () => {
121
+ const placements = placePills([pill('a', 100, 100, 20), pill('b', 400, 100, 10)])
122
+ expect([...placements.values()]).toEqual([
123
+ { mode: 'full', dy: 0 },
124
+ { mode: 'full', dy: 0 },
125
+ ])
126
+ })
127
+
128
+ test('two overlapping pills: the larger room keeps its place, the other moves the least it can', () => {
129
+ const big = pill('big', 100, 100, 30)
130
+ const small = pill('small', 110, 106, 10)
131
+ const placements = placePills([small, big])
132
+ expect(placements.get('big')).toEqual({ mode: 'full', dy: 0 })
133
+ const moved = placements.get('small')!
134
+ expect(moved.mode).toBe('full')
135
+ expect(Math.abs(moved.dy)).toBeLessThanOrEqual(24)
136
+ expect(apart(rectOf(big, 0, false), rectOf(small, moved.dy, false))).toBe(true)
137
+ // 24 px tall, 6 px apart, 2 px gap: 20 px down is the nearest clear slot (up needs 32).
138
+ expect(moved.dy).toBe(20)
139
+ })
140
+
141
+ test('crowded beyond a pill height, a pill collapses to its dot; hidden only if the dot collides too', () => {
142
+ const wide = pill('wide', 200, 100, 40, 300)
143
+ const above = pill('above', 200, 74, 30, 300)
144
+ const below = pill('below', 200, 126, 20, 300)
145
+ const squeezed = pill('squeezed', 120, 100, 10)
146
+ const placements = placePills([squeezed, below, above, wide])
147
+ expect(placements.get('wide')?.mode).toBe('full')
148
+ expect(placements.get('above')?.mode).toBe('full')
149
+ expect(placements.get('below')?.mode).toBe('full')
150
+ // Its dot sits inside the wide pill: nothing left but to hide it.
151
+ expect(placements.get('squeezed')).toEqual({ mode: 'hidden', dy: 0 })
152
+
153
+ const nearEdge = pill('near-edge', 30, 100, 10)
154
+ const crowded = placePills([nearEdge, below, above, wide])
155
+ // Its full pill still collides, but its dot at the anchor is clear.
156
+ expect(crowded.get('near-edge')).toEqual({ mode: 'dot', dy: 0 })
157
+ })
158
+
159
+ test('a dot with no room at its anchor takes the nearest free slot instead of hiding', () => {
160
+ const big = pill('big', 100, 100, 30)
161
+ const closet = pill('closet', 100, 100, 5)
162
+ const placements = placePills([closet, big])
163
+ // The full pill would need 26 px (past its 24 px budget); its dot clears at 24 px up.
164
+ expect(placements.get('closet')).toEqual({ mode: 'dot', dy: -24 })
165
+ expect(apart(rectOf(big, 0, false), rectOf(closet, -24, true))).toBe(true)
166
+ })
167
+
168
+ test('the order is fixed by room size and id, not by input order', () => {
169
+ const candidates = [pill('b', 100, 100, 10), pill('a', 105, 100, 10), pill('c', 110, 100, 10)]
170
+ const first = placePills(candidates)
171
+ const shuffled = placePills([candidates[2]!, candidates[0]!, candidates[1]!])
172
+ expect(shuffled).toEqual(first)
173
+ expect(first.get('a')).toEqual({ mode: 'full', dy: 0 })
174
+ })
175
+
176
+ test('no two placed boxes ever overlap', () => {
177
+ const candidates = Array.from({ length: 40 }, (_, i) =>
178
+ pill(
179
+ `p${i}`,
180
+ 100 + ((i * 37) % 220),
181
+ 100 + ((i * 53) % 140),
182
+ (i * 7) % 13,
183
+ 90 + (i % 5) * 20,
184
+ ),
185
+ )
186
+ const placements = placePills(candidates)
187
+ const boxes = candidates.flatMap((candidate) => {
188
+ const placement = placements.get(candidate.id)!
189
+ return placement.mode === 'hidden'
190
+ ? []
191
+ : [rectOf(candidate, placement.dy, placement.mode === 'dot')]
192
+ })
193
+ for (let i = 0; i < boxes.length; i++) {
194
+ for (let j = 0; j < i; j++) expect(apart(boxes[i]!, boxes[j]!)).toBe(true)
195
+ }
196
+ })
197
+ })
198
+
199
+ describe('when pills re-read their overlaps', () => {
200
+ // Camera damping after a zoom: each frame moves the camera by a shrinking
201
+ // fraction of a millimetre for a second or more after it visibly stops.
202
+ const settling = (frame: number) => {
203
+ const view = new Float64Array(50)
204
+ view[12] = 30 + 1e-5 * 0.8 ** frame
205
+ view[48] = 1500
206
+ view[49] = 900
207
+ return view
208
+ }
209
+
210
+ test('once the camera has visibly stopped, not once damping stops nudging it', () => {
211
+ const clock = new DeclutterClock()
212
+ const frame = 1 / 60
213
+ let firstRead = -1
214
+ for (let i = 0; i < 120; i++) {
215
+ if (clock.shouldRead(settling(i), i * frame) && i > 0) {
216
+ firstRead = i
217
+ break
218
+ }
219
+ }
220
+ // Within the rest delay (0.12 s ≈ 7 frames at 60 fps), not two seconds later.
221
+ expect(firstRead).toBeGreaterThan(0)
222
+ expect(firstRead * frame).toBeLessThan(0.2)
223
+ })
224
+
225
+ test('once per rest, not while moving, and again when the pills change at rest', () => {
226
+ const clock = new DeclutterClock()
227
+ const at = (x: number) => {
228
+ const view = new Float64Array(50)
229
+ view[12] = x
230
+ return view
231
+ }
232
+ expect(clock.shouldRead(at(0), 0)).toBe(false)
233
+ expect(clock.shouldRead(at(0), 0.2)).toBe(true)
234
+ expect(clock.shouldRead(at(0), 0.4)).toBe(false)
235
+ // Moving: pills ride their rooms, nothing is re-placed until it rests.
236
+ const moving = [0.5, 0.55, 0.6, 0.65, 0.7].map((t) => clock.shouldRead(at(t), t))
237
+ expect(moving.some(Boolean)).toBe(false)
238
+ expect(clock.shouldRead(at(0.7), 0.85)).toBe(true)
239
+ clock.invalidate()
240
+ expect(clock.shouldRead(at(0.7), 0.86)).toBe(true)
241
+ })
242
+ })
243
+
244
+ describe('applying placements', () => {
245
+ function setup() {
246
+ const element = () =>
247
+ ({
248
+ dataset: {} as Record<string, string>,
249
+ style: { translate: '' },
250
+ }) as unknown as HTMLElement
251
+ const elements = new Map([
252
+ ['near', element()],
253
+ ['far', element()],
254
+ ])
255
+ const timers: Array<{ run: () => void; ms: number; cancelled: boolean }> = []
256
+ const changes: ReadonlySet<string>[] = []
257
+ const placer = new PillPlacer(
258
+ elements,
259
+ (gone) => changes.push(gone),
260
+ (run, ms) => {
261
+ const timer = { run, ms, cancelled: false }
262
+ timers.push(timer)
263
+ return timer as unknown as ReturnType<typeof setTimeout>
264
+ },
265
+ (timer) => {
266
+ ;(timer as unknown as { cancelled: boolean }).cancelled = true
267
+ },
268
+ )
269
+ return { elements, timers, changes, placer }
270
+ }
271
+ const has = (element: HTMLElement | undefined, flag: string) => flag in element!.dataset
272
+ const full = (dy = 0) => ({ mode: 'full' as const, dy })
273
+
274
+ test('a nudge is a translate, a dot is a collapsed pill, both undone when placed back', () => {
275
+ const { elements, placer } = setup()
276
+ placer.apply(
277
+ new Map<string, PillPlacement>([
278
+ ['near', full(-8)],
279
+ ['far', { mode: 'dot' as const, dy: 0 }],
280
+ ]),
281
+ 180,
282
+ )
283
+ expect(elements.get('near')!.style.translate).toBe('0 -8px')
284
+ expect(has(elements.get('far'), 'roomLabelCollapsed')).toBe(true)
285
+ placer.apply(
286
+ new Map<string, PillPlacement>([
287
+ ['near', full()],
288
+ ['far', full()],
289
+ ]),
290
+ 180,
291
+ )
292
+ expect(elements.get('near')!.style.translate).toBe('')
293
+ expect(has(elements.get('far'), 'roomLabelCollapsed')).toBe(false)
294
+ })
295
+
296
+ test('a hidden pill fades out without taking the pointer, then leaves the DOM', () => {
297
+ const { elements, timers, changes, placer } = setup()
298
+ placer.apply(
299
+ new Map<string, PillPlacement>([
300
+ ['near', full()],
301
+ ['far', { mode: 'hidden' as const, dy: 0 }],
302
+ ]),
303
+ 180,
304
+ )
305
+ expect(has(elements.get('far'), 'roomLabelHidden')).toBe(true)
306
+ expect(timers.map(({ ms }) => ms)).toEqual([180])
307
+ expect(changes).toEqual([])
308
+ timers[0]!.run()
309
+ expect(placer.gone).toEqual(new Set(['far']))
310
+ })
311
+
312
+ test('a pill given room again mounts in its new place, or turns back mid-fade', () => {
313
+ const { elements, timers, placer } = setup()
314
+ placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]), 180)
315
+ timers[0]!.run()
316
+ const element = elements.get('far')!
317
+ elements.delete('far')
318
+ placer.apply(new Map<string, PillPlacement>([['far', full(12)]]), 180)
319
+ expect(placer.gone).toEqual(new Set())
320
+ placer.mount('far', element)
321
+ expect(element.style.translate).toBe('0 12px')
322
+
323
+ const again = setup()
324
+ again.placer.apply(
325
+ new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]),
326
+ 180,
327
+ )
328
+ again.placer.apply(new Map<string, PillPlacement>([['far', full()]]), 180)
329
+ expect(again.timers[0]!.cancelled).toBe(true)
330
+ expect(has(again.elements.get('far'), 'roomLabelHidden')).toBe(false)
331
+ })
332
+
333
+ test('hidden pills can refresh their text measurements without becoming visible', () => {
334
+ const { elements, timers, placer } = setup()
335
+ placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden', dy: 0 }]]), 180)
336
+ timers[0]!.run()
337
+ elements.delete('far')
338
+ placer.remeasure()
339
+ expect(placer.gone.size).toBe(0)
340
+ const fresh = { dataset: {}, style: { translate: '' } } as unknown as HTMLElement
341
+ elements.set('far', fresh)
342
+ placer.mount('far', fresh)
343
+ expect(has(fresh, 'roomLabelHidden')).toBe(true)
344
+ placer.apply(new Map<string, PillPlacement>([['far', full()]]), 180)
345
+ expect(has(fresh, 'roomLabelHidden')).toBe(false)
346
+ })
347
+
348
+ test('the whole layer fades out and back in, except pills still hidden', () => {
349
+ const { elements, placer } = setup()
350
+ placer.apply(new Map<string, PillPlacement>([['far', { mode: 'hidden' as const, dy: 0 }]]), 180)
351
+ placer.showAll(false)
352
+ expect(has(elements.get('near'), 'roomLabelHidden')).toBe(true)
353
+ placer.showAll(true)
354
+ expect(has(elements.get('near'), 'roomLabelHidden')).toBe(false)
355
+ expect(has(elements.get('far'), 'roomLabelHidden')).toBe(true)
356
+ })
357
+ })