@pascal-app/editor 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,1890 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ area,
7
+ containsPoint,
8
+ DEFAULT_LEVEL_HEIGHT,
9
+ emitter,
10
+ type Polygon,
11
+ polygonInteriorPoint,
12
+ type Ring,
13
+ sceneRegistry,
14
+ useScene,
15
+ type ZoneNode,
16
+ } from '@pascal-app/core'
17
+ import { timeSpan, useViewer } from '@pascal-app/viewer'
18
+ import { Html } from '@react-three/drei'
19
+ import { useFrame, useThree } from '@react-three/fiber'
20
+ import {
21
+ createContext,
22
+ Fragment,
23
+ type MouseEvent as ReactMouseEvent,
24
+ type ReactNode,
25
+ type PointerEvent as ReactPointerEvent,
26
+ useCallback,
27
+ useContext,
28
+ useEffect,
29
+ useLayoutEffect,
30
+ useMemo,
31
+ useRef,
32
+ useState,
33
+ useSyncExternalStore,
34
+ } from 'react'
35
+ import { createPortal } from 'react-dom'
36
+ import {
37
+ type Camera,
38
+ type Group,
39
+ Matrix4,
40
+ type Object3D,
41
+ Ray,
42
+ Raycaster,
43
+ Vector2,
44
+ Vector3,
45
+ } from 'three'
46
+ import { useShallow } from 'zustand/react/shallow'
47
+ import { useRoomRecords } from '../../hooks/use-selected-room'
48
+ import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements'
49
+ import { roomFloorElevation } from '../../lib/room-handle-drag'
50
+ import type { RoomSelectionRecord } from '../../lib/room-selection'
51
+ import {
52
+ clickZoneArea,
53
+ hoverZoneArea,
54
+ leaveZoneArea,
55
+ zoneKindLabel,
56
+ } from '../../lib/room-zone-routing'
57
+ import { selectionModifiersFromEvent } from '../../lib/selection-routing'
58
+ import useEditor from '../../store/use-editor'
59
+ import useInteractionScope from '../../store/use-interaction-scope'
60
+ import { isViewVisible, VIEW_2D, VIEW_3D } from '../../store/view-layout'
61
+
62
+ const CONTEXT_TYPES = new Set(['site', 'building', 'level'])
63
+
64
+ export type RoomLabelVisibilityState = {
65
+ phase: string
66
+ mode: string
67
+ scopeIdle: boolean
68
+ room: unknown
69
+ selectedTypes: readonly (string | undefined)[]
70
+ zoneId: string | null
71
+ focusedUnitId: string | null
72
+ isCaptureMode: boolean
73
+ isThumbnailCapture: boolean
74
+ }
75
+
76
+ /**
77
+ * Room pills are the resting view of a level: inside the building, select tool
78
+ * idle, and nothing picked but the site, building or level itself. Anything
79
+ * selected, a unit being arranged, or a gesture in flight takes them away.
80
+ */
81
+ export function roomLabelsVisible(state: RoomLabelVisibilityState): boolean {
82
+ return (
83
+ state.phase === 'building' &&
84
+ state.mode === 'select' &&
85
+ state.scopeIdle &&
86
+ !state.room &&
87
+ !state.zoneId &&
88
+ !state.focusedUnitId &&
89
+ !state.isCaptureMode &&
90
+ !state.isThumbnailCapture &&
91
+ state.selectedTypes.every((type) => type !== undefined && CONTEXT_TYPES.has(type))
92
+ )
93
+ }
94
+
95
+ export function formatRoomArea(squareMeters: number, unit: LinearUnit): string {
96
+ return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(1)} ${getAreaUnitLabel(unit)}`
97
+ }
98
+
99
+ function useRoomLabelsVisible() {
100
+ const [isThumbnailCapture, setThumbnailCapture] = useState(false)
101
+ useEffect(() => {
102
+ const hide = () => setThumbnailCapture(true)
103
+ const restore = () => setThumbnailCapture(false)
104
+ emitter.on('thumbnail:before-capture', hide)
105
+ emitter.on('thumbnail:after-capture', restore)
106
+ return () => {
107
+ emitter.off('thumbnail:before-capture', hide)
108
+ emitter.off('thumbnail:after-capture', restore)
109
+ }
110
+ }, [])
111
+ const editor = useEditor(
112
+ useShallow((s) => ({
113
+ phase: s.phase,
114
+ mode: s.mode,
115
+ room: s.room,
116
+ isCaptureMode: s.isCaptureMode,
117
+ })),
118
+ )
119
+ const scopeIdle = useInteractionScope((s) => s.scope.kind === 'idle')
120
+ const zoneId = useViewer((s) => s.selection.zoneId)
121
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
122
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
123
+ const selectedTypes = useScene(
124
+ useShallow((s) =>
125
+ selectedIds.map((id) => (s.nodes[id as AnyNodeId] as AnyNode | undefined)?.type),
126
+ ),
127
+ )
128
+ return roomLabelsVisible({
129
+ ...editor,
130
+ scopeIdle,
131
+ zoneId,
132
+ focusedUnitId,
133
+ selectedTypes,
134
+ isThumbnailCapture,
135
+ })
136
+ }
137
+
138
+ export type LabelRect = { left: number; top: number; right: number; bottom: number }
139
+ type PaneSize = { width: number; height: number }
140
+
141
+ /** A pill's screen rect: centred on where its anchor lands, at its size. */
142
+ export function pillRect(x: number, y: number, [width, height]: readonly [number, number]) {
143
+ return { left: x - width / 2, top: y - height / 2, right: x + width / 2, bottom: y + height / 2 }
144
+ }
145
+
146
+ /** A pill's measured size: its full width, its height, and its width with the name alone. */
147
+ export type PillSize = readonly [fullWidth: number, height: number, nameWidth: number]
148
+
149
+ /** How much of a pill shows: dot, name and area; dot and name; its dot; nothing. */
150
+ export type PillTier = 'full' | 'name' | 'dot' | 'hidden'
151
+
152
+ /** Where a pill goes: how much of it shows, moved `dy` px off its anchor. */
153
+ export type PillPlacement = { tier: PillTier; dy: number }
154
+
155
+ /**
156
+ * A pill to place: where its room's anchor lands on screen, its size, its
157
+ * room's area and the part of the room's outline in front of the camera, on
158
+ * screen.
159
+ */
160
+ export type PillCandidate = {
161
+ id: string
162
+ x: number
163
+ y: number
164
+ size: PillSize
165
+ area: number
166
+ outline: Polygon
167
+ }
168
+
169
+ /** The collapsed pill: its colour dot in a small round box. */
170
+ const PILL_DOT_SIZE = 16
171
+ /** The clear space around a placed pill, and around the detail chip. */
172
+ export const PILL_GAP = 4
173
+ const NUDGE_STEP = 4
174
+ const TIERS = ['full', 'name', 'dot'] as const
175
+
176
+ function near(a: LabelRect, b: LabelRect) {
177
+ return (
178
+ a.left < b.right + PILL_GAP &&
179
+ b.left < a.right + PILL_GAP &&
180
+ a.top < b.bottom + PILL_GAP &&
181
+ b.top < a.bottom + PILL_GAP
182
+ )
183
+ }
184
+
185
+ function tierSize(size: PillSize, tier: Exclude<PillTier, 'hidden'>): readonly [number, number] {
186
+ if (tier === 'full') return [size[0], size[1]]
187
+ if (tier === 'name') return [size[2], size[1]]
188
+ return [PILL_DOT_SIZE, PILL_DOT_SIZE]
189
+ }
190
+
191
+ /** The rect a placed pill takes on screen, or null when it is hidden. */
192
+ export function placedRect(pill: PillCandidate, { tier, dy }: PillPlacement): LabelRect | null {
193
+ return tier === 'hidden' ? null : pillRect(pill.x, pill.y + dy, tierSize(pill.size, tier))
194
+ }
195
+
196
+ function segmentTouchesRect(
197
+ [ax, ay]: readonly [number, number],
198
+ [bx, by]: readonly [number, number],
199
+ rect: LabelRect,
200
+ ) {
201
+ if (
202
+ Math.max(ax, bx) < rect.left ||
203
+ Math.min(ax, bx) > rect.right ||
204
+ Math.max(ay, by) < rect.top ||
205
+ Math.min(ay, by) > rect.bottom
206
+ )
207
+ return false
208
+ // Liang–Barsky: narrow the segment's span to each side of the rect in turn.
209
+ let t0 = 0
210
+ let t1 = 1
211
+ const clip = (p: number, q: number) => {
212
+ if (p === 0) return q >= 0
213
+ const t = q / p
214
+ if (p < 0) {
215
+ if (t > t1) return false
216
+ if (t > t0) t0 = t
217
+ } else {
218
+ if (t < t0) return false
219
+ if (t < t1) t1 = t
220
+ }
221
+ return true
222
+ }
223
+ return (
224
+ clip(ax - bx, ax - rect.left) &&
225
+ clip(bx - ax, rect.right - ax) &&
226
+ clip(ay - by, ay - rect.top) &&
227
+ clip(by - ay, rect.bottom - ay)
228
+ )
229
+ }
230
+
231
+ function ringTouchesRect(ring: Ring, rect: LabelRect) {
232
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
233
+ if (segmentTouchesRect(ring[j]!, ring[i]!, rect)) return true
234
+ }
235
+ return false
236
+ }
237
+
238
+ /**
239
+ * Whether `rect` lies inside `outline`: no edge of the outline or its holes
240
+ * touches the rect, and the rect's centre is inside.
241
+ */
242
+ function rectInPolygon(rect: LabelRect, outline: Polygon): boolean {
243
+ if (ringTouchesRect(outline.outer, rect)) return false
244
+ if (outline.holes.some((hole) => ringTouchesRect(hole, rect))) return false
245
+ return containsPoint([outline], [(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2])
246
+ }
247
+
248
+ /**
249
+ * 0, then the nudge the pill took last time (so it keeps to that side), then
250
+ * up and down by growing steps, up to `budget` px either way.
251
+ */
252
+ function* nudges(budget: number, previous: number | undefined) {
253
+ yield 0
254
+ if (previous) yield previous
255
+ for (let d = NUDGE_STEP; d <= budget; d += NUDGE_STEP) {
256
+ if (-d !== previous) yield -d
257
+ if (d !== previous) yield d
258
+ }
259
+ }
260
+
261
+ function ringBox(ring: Ring): LabelRect {
262
+ const box = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }
263
+ for (const [x, y] of ring) {
264
+ box.left = Math.min(box.left, x)
265
+ box.top = Math.min(box.top, y)
266
+ box.right = Math.max(box.right, x)
267
+ box.bottom = Math.max(box.bottom, y)
268
+ }
269
+ return box
270
+ }
271
+
272
+ function slotFor(
273
+ pill: PillCandidate,
274
+ bounds: PaneSize,
275
+ placed: LabelRect[],
276
+ previous: PillPlacement | undefined,
277
+ ): PillPlacement {
278
+ const { outline } = pill
279
+ // The pane, narrowed to the room's bounding box: cheap to reject against.
280
+ const box = ringBox(outline.outer)
281
+ box.left = Math.max(box.left, 0)
282
+ box.top = Math.max(box.top, 0)
283
+ box.right = Math.min(box.right, bounds.width)
284
+ box.bottom = Math.min(box.bottom, bounds.height)
285
+ for (const tier of TIERS) {
286
+ const size = tierSize(pill.size, tier)
287
+ // Wider or taller than the room's box: no nudge fits it.
288
+ if (size[0] > box.right - box.left || size[1] > box.bottom - box.top) continue
289
+ const budget = tier === 'dot' ? PILL_DOT_SIZE : pill.size[1]
290
+ for (const dy of nudges(budget, previous?.tier === tier ? previous.dy : undefined)) {
291
+ const rect = pillRect(pill.x, pill.y + dy, size)
292
+ if (
293
+ rect.left < box.left ||
294
+ rect.top < box.top ||
295
+ rect.right > box.right ||
296
+ rect.bottom > box.bottom
297
+ )
298
+ continue
299
+ if (!rectInPolygon(rect, outline)) continue
300
+ if (placed.some((other) => near(rect, other))) continue
301
+ placed.push(rect)
302
+ return { tier, dy }
303
+ }
304
+ }
305
+ return { tier: 'hidden', dy: 0 }
306
+ }
307
+
308
+ /**
309
+ * Where every pill goes, so that none overlap and each stays inside its room
310
+ * and the pane. Larger rooms are placed first (ties by id, so the order holds
311
+ * while the camera moves). Each pill takes the first slot that fits: its full
312
+ * pill, then its name alone, then its dot, each at its anchor or nudged up or
313
+ * down; with no slot it is hidden. A room's size on screen decides how much of
314
+ * its label shows, so the detail follows the zoom.
315
+ */
316
+ export function placePills(
317
+ candidates: readonly PillCandidate[],
318
+ bounds: PaneSize,
319
+ previous: ReadonlyMap<string, PillPlacement> = new Map(),
320
+ ): Map<string, PillPlacement> {
321
+ const placed: LabelRect[] = []
322
+ const placements = new Map<string, PillPlacement>()
323
+ const order = [...candidates].sort(
324
+ (a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
325
+ )
326
+ for (const pill of order)
327
+ placements.set(pill.id, slotFor(pill, bounds, placed, previous.get(pill.id)))
328
+ return placements
329
+ }
330
+
331
+ /** The placed pills whose rects come within the gap of `chip`. */
332
+ export function suppressedBy(
333
+ chip: LabelRect,
334
+ rects: ReadonlyMap<string, LabelRect>,
335
+ ): ReadonlySet<string> {
336
+ const ids = new Set<string>()
337
+ for (const [id, rect] of rects) if (near(chip, rect)) ids.add(id)
338
+ return ids
339
+ }
340
+
341
+ export type RoomLabelEntry = {
342
+ zoneId: string
343
+ polygon: [number, number][]
344
+ holes: [number, number][][]
345
+ area: number
346
+ }
347
+ type LabelEntry = RoomLabelEntry
348
+
349
+ /** Where the pointer meets a room's plane: the point in it, and how far along the pointer's ray. */
350
+ export type RoomHit = readonly [x: number, y: number, distance: number]
351
+
352
+ // Planes closer together than this along the ray (in metres) count as one floor.
353
+ const SAME_FLOOR = 1e-3
354
+
355
+ /**
356
+ * The room under the pointer: of the rooms whose outline, holes excluded,
357
+ * holds the point where the pointer meets their plane, the nearest along the
358
+ * pointer's ray (the floor it reaches first), then the smallest (a zone drawn
359
+ * inside a room, on the same floor). `pointOn` gives that point for each room
360
+ * (in 3D, where the ray meets the room's floor), or null if it never meets it.
361
+ */
362
+ export function roomAt(
363
+ entries: readonly RoomLabelEntry[],
364
+ pointOn: (entry: RoomLabelEntry) => RoomHit | null,
365
+ ): string | null {
366
+ let found: RoomLabelEntry | null = null
367
+ let foundAt = Number.POSITIVE_INFINITY
368
+ for (const entry of entries) {
369
+ const hit = pointOn(entry)
370
+ if (!hit) continue
371
+ const [x, y, distance] = hit
372
+ if (found) {
373
+ const nearer = distance < foundAt - SAME_FLOOR
374
+ const sameFloor = !nearer && distance <= foundAt + SAME_FLOOR
375
+ if (!(nearer || (sameFloor && entry.area < found.area))) continue
376
+ }
377
+ if (containsPoint([{ outer: entry.polygon, holes: entry.holes }], [x, y])) {
378
+ found = entry
379
+ foundAt = distance
380
+ }
381
+ }
382
+ return found?.zoneId ?? null
383
+ }
384
+
385
+ // Mid-height of a default storey: above furniture, below the ceiling.
386
+ const LABEL_HEIGHT = DEFAULT_LEVEL_HEIGHT / 2
387
+ // The view has to hold still this long before pills are re-placed; while it
388
+ // moves they keep their places and ride their rooms, so nothing jitters.
389
+ const DECLUTTER_REST_SECONDS = 0.12
390
+ // Below this the view counts as still: well under a pixel on screen.
391
+ const VIEW_EPSILON = 1e-4
392
+ /** How long a pill takes to fade, move or collapse. */
393
+ const PILL_FADE_MS = 180
394
+ /** How long the pointer rests on a room before its detail opens. */
395
+ export const REVEAL_DWELL_MS = 80
396
+ /** How long the detail stays once the pointer has left its room. */
397
+ export const REVEAL_GRACE_MS = 120
398
+ // The detail chip stays this far inside its layer.
399
+ const DETAIL_INSET = 8
400
+
401
+ function pillFadeMs(): number {
402
+ return typeof window !== 'undefined' &&
403
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
404
+ ? 0
405
+ : PILL_FADE_MS
406
+ }
407
+
408
+ /**
409
+ * When a layer re-places its pills: once the view has held still, and again
410
+ * whenever its pills change while it rests. Still means within an epsilon of
411
+ * the view last seen moving — camera damping keeps nudging the camera by
412
+ * fractions of a millimetre for a second or more after it visibly stops, and
413
+ * waiting for it to stop exactly left pills misplaced for that long.
414
+ */
415
+ export class DeclutterClock {
416
+ private reference: number[] | null = null
417
+ private movedAt = Number.NEGATIVE_INFINITY
418
+ private settled = false
419
+ private dirty = true
420
+
421
+ /** Whether the view rests and its pills were placed for it. */
422
+ get atRest() {
423
+ return this.settled
424
+ }
425
+
426
+ /** The pills changed (mounted, unmounted, resized): place them again. */
427
+ invalidate() {
428
+ this.dirty = true
429
+ }
430
+
431
+ /** Whether to place pills for this view (any flat list of numbers) at `now` seconds. */
432
+ shouldRead(view: ArrayLike<number>, now: number): boolean {
433
+ const reference = this.reference
434
+ let moved = !reference || reference.length !== view.length
435
+ for (let i = 0; !moved && i < view.length; i++) {
436
+ moved = Math.abs(view[i]! - reference![i]!) > VIEW_EPSILON
437
+ }
438
+ if (moved) {
439
+ this.reference = Array.from(view)
440
+ this.movedAt = now
441
+ this.settled = false
442
+ }
443
+ if (now - this.movedAt < DECLUTTER_REST_SECONDS || (this.settled && !this.dirty)) return false
444
+ this.settled = true
445
+ this.dirty = false
446
+ return true
447
+ }
448
+ }
449
+
450
+ /** The pills a layer has mounted, by zone id; the layer hears each one come and go. */
451
+ export class PillElements extends Map<string, HTMLElement> {
452
+ onMount: ((id: string, element: HTMLElement) => void) | null = null
453
+ onUnmount: ((element: HTMLElement) => void) | null = null
454
+ onMeasure: ((id: string, element: HTMLElement) => void) | null = null
455
+
456
+ measure(id: string) {
457
+ const element = this.get(id)
458
+ if (element) this.onMeasure?.(id, element)
459
+ }
460
+
461
+ override set(id: string, element: HTMLElement) {
462
+ super.set(id, element)
463
+ this.onMount?.(id, element)
464
+ return this
465
+ }
466
+
467
+ override delete(id: string) {
468
+ const element = this.get(id)
469
+ const removed = super.delete(id)
470
+ if (element) this.onUnmount?.(element)
471
+ return removed
472
+ }
473
+ }
474
+
475
+ /** Where each room's pill floats, level-local; moving one has the pills placed again. */
476
+ export class RoomAnchors extends Map<string, Vector3> {
477
+ onMove: (() => void) | null = null
478
+
479
+ override set(id: string, position: Vector3) {
480
+ const moved = !this.get(id)?.equals(position)
481
+ super.set(id, position)
482
+ if (moved) this.onMove?.()
483
+ return this
484
+ }
485
+ }
486
+
487
+ /** Read a pill's full border-box and its width without the area, whatever it shows now. */
488
+ export function measureFullPill(
489
+ element: HTMLElement,
490
+ read: (
491
+ element: Element,
492
+ ) => Pick<CSSStyleDeclaration, 'width' | 'height' | 'columnGap'> = getComputedStyle,
493
+ ): PillSize {
494
+ const tier = element.dataset.roomLabelTier
495
+ delete element.dataset.roomLabelTier
496
+ try {
497
+ const style = read(element)
498
+ const width = Number.parseFloat(style.width)
499
+ const areaLabel = element.querySelector('[data-room-label-area]')
500
+ const areaWidth = areaLabel
501
+ ? Number.parseFloat(read(areaLabel).width) + Number.parseFloat(style.columnGap)
502
+ : 0
503
+ return [width, Number.parseFloat(style.height), width - areaWidth]
504
+ } finally {
505
+ if (tier !== undefined) element.dataset.roomLabelTier = tier
506
+ }
507
+ }
508
+
509
+ type Timer = ReturnType<typeof setTimeout>
510
+ // Wrapped, not passed as is: browsers throw "Illegal invocation" when the
511
+ // global timers are called as methods of another object.
512
+ const startTimer = (run: () => void, ms: number): Timer => setTimeout(run, ms)
513
+ const stopTimer = (timer: Timer) => clearTimeout(timer)
514
+
515
+ /**
516
+ * Applies placements to the mounted pills as direct style writes: the nudge
517
+ * as a `translate`, the tier as `data-room-label-tier`, both eased by the
518
+ * pill's transition. A pill placed hidden fades out without taking the
519
+ * pointer, then leaves the DOM (`gone`); one given room again mounts and fades
520
+ * back in. A mounted pill that a pass leaves out (not measured yet, or its
521
+ * anchor off screen) waits hidden: no pill shows that placement has not
522
+ * cleared.
523
+ */
524
+ export class PillPlacer {
525
+ gone: ReadonlySet<string> = new Set()
526
+ /** The last pass's placements: a remounting pill takes its own at once. */
527
+ placements: ReadonlyMap<string, PillPlacement> = new Map()
528
+ private readonly fading = new Map<string, Timer>()
529
+ private suppressed: ReadonlySet<string> = new Set()
530
+ private open: string | null = null
531
+ private shown = true
532
+
533
+ constructor(
534
+ private readonly elements: ReadonlyMap<string, HTMLElement>,
535
+ private readonly onGone: (gone: ReadonlySet<string>) => void,
536
+ private readonly schedule: (run: () => void, ms: number) => Timer = startTimer,
537
+ private readonly cancel: (timer: Timer) => void = stopTimer,
538
+ ) {}
539
+
540
+ apply(placements: ReadonlyMap<string, PillPlacement>, fadeMs = pillFadeMs()) {
541
+ this.placements = placements
542
+ for (const [id, element] of this.elements) {
543
+ const placement = placements.get(id)
544
+ if (placement && placement.tier !== 'hidden') {
545
+ const timer = this.fading.get(id)
546
+ if (timer !== undefined) this.cancel(timer)
547
+ this.fading.delete(id)
548
+ this.show(element, placement)
549
+ continue
550
+ }
551
+ element.dataset.roomLabelHidden = ''
552
+ if (!placement || this.fading.has(id)) continue
553
+ this.fading.set(
554
+ id,
555
+ this.schedule(() => {
556
+ this.fading.delete(id)
557
+ this.setGone(new Set(this.gone).add(id))
558
+ }, fadeMs),
559
+ )
560
+ }
561
+ const kept = [...this.gone].filter((id) => (placements.get(id)?.tier ?? 'hidden') === 'hidden')
562
+ if (kept.length !== this.gone.size) this.setGone(new Set(kept))
563
+ }
564
+
565
+ /** A pill mounting takes its last placement at once, or waits hidden for one. */
566
+ mount(id: string, element: HTMLElement) {
567
+ const placement = this.placements.get(id)
568
+ if (placement && placement.tier !== 'hidden') this.show(element, placement)
569
+ else element.dataset.roomLabelHidden = ''
570
+ this.mark(id, element)
571
+ }
572
+
573
+ /**
574
+ * Fade the pills under the detail chip out of its way, and every other back.
575
+ * The open room's own pill fades too but still takes the pointer: the chip
576
+ * over it is read-only, so the pill stays the control.
577
+ */
578
+ suppress(ids: ReadonlySet<string>, open: string | null) {
579
+ this.suppressed = ids
580
+ this.open = open
581
+ for (const [id, element] of this.elements) this.mark(id, element)
582
+ }
583
+
584
+ /** Remount hidden pills invisibly so changed text or fonts can be measured. */
585
+ remeasure() {
586
+ if (this.gone.size) this.setGone(new Set())
587
+ }
588
+
589
+ /** Fade every mounted pill out (the layer is leaving), or the placed ones back in. */
590
+ showAll(shown: boolean) {
591
+ this.shown = shown
592
+ for (const [id, element] of this.elements) {
593
+ const placement = this.placements.get(id)
594
+ if (shown && placement && placement.tier !== 'hidden') delete element.dataset.roomLabelHidden
595
+ else element.dataset.roomLabelHidden = ''
596
+ }
597
+ }
598
+
599
+ dispose() {
600
+ for (const timer of this.fading.values()) this.cancel(timer)
601
+ this.fading.clear()
602
+ }
603
+
604
+ private show(element: HTMLElement, { tier, dy }: PillPlacement) {
605
+ const translate = dy ? `0 ${dy}px` : ''
606
+ if (element.style.translate !== translate) element.style.translate = translate
607
+ if (tier === 'full') delete element.dataset.roomLabelTier
608
+ else if (element.dataset.roomLabelTier !== tier) element.dataset.roomLabelTier = tier
609
+ if (this.shown) delete element.dataset.roomLabelHidden
610
+ else element.dataset.roomLabelHidden = ''
611
+ }
612
+
613
+ private mark(id: string, element: HTMLElement) {
614
+ const { dataset } = element
615
+ if (id === this.open) dataset.roomLabelOpen = ''
616
+ else delete dataset.roomLabelOpen
617
+ if (id !== this.open && this.suppressed.has(id)) dataset.roomLabelSuppressed = ''
618
+ else delete dataset.roomLabelSuppressed
619
+ }
620
+
621
+ private setGone(gone: ReadonlySet<string>) {
622
+ this.gone = gone
623
+ this.onGone(gone)
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Which room's detail chip is open. A pill under the pointer, or a pill with
629
+ * keyboard focus, opens at once; when both are set, whichever moved last
630
+ * wins. Otherwise the room under the pointer opens once the pointer has
631
+ * rested on it for a moment, and stays a moment after the pointer leaves, so
632
+ * crossing a wall hands over from one room to the next without a gap and
633
+ * passing over a room opens nothing. Escape closes it until the pointer or
634
+ * focus moves on to another room.
635
+ */
636
+ export class LabelReveal {
637
+ active: string | null = null
638
+ private activeFocused = false
639
+ private hovered: string | null = null
640
+ private focused: string | null = null
641
+ private picked: string | null = null
642
+ private keyboardLast = false
643
+ private dismissed: string | null = null
644
+ private pending: { id: string | null; timer: Timer } | null = null
645
+
646
+ constructor(
647
+ private readonly onChange: (id: string | null, focused: boolean) => void,
648
+ private readonly schedule: (run: () => void, ms: number) => Timer = startTimer,
649
+ private readonly cancel: (timer: Timer) => void = stopTimer,
650
+ ) {}
651
+
652
+ /** The pill under the pointer. */
653
+ hover(id: string | null) {
654
+ if (id !== null && id !== this.hovered) this.keyboardLast = false
655
+ this.hovered = id
656
+ this.update()
657
+ }
658
+
659
+ /** The pill with keyboard focus. */
660
+ focus(id: string | null) {
661
+ if (id !== null) this.keyboardLast = true
662
+ this.focused = id
663
+ this.update()
664
+ }
665
+
666
+ /** The room under the pointer. */
667
+ pick(id: string | null) {
668
+ this.picked = id
669
+ this.update()
670
+ }
671
+
672
+ /** Close what is open, and keep it closed while the pointer and focus stay on it. */
673
+ dismiss() {
674
+ if (this.active === null) return
675
+ this.dismissed = this.active
676
+ this.stop()
677
+ this.set(null)
678
+ }
679
+
680
+ /**
681
+ * Close at once and forget the pointer: the view moved, or the layer hid.
682
+ * Focus is kept, so a focused pill opens again once the view rests.
683
+ */
684
+ clear() {
685
+ this.hovered = null
686
+ this.picked = null
687
+ this.stop()
688
+ this.set(null)
689
+ }
690
+
691
+ private update() {
692
+ const direct = this.keyboardLast
693
+ ? (this.focused ?? this.hovered)
694
+ : (this.hovered ?? this.focused)
695
+ if (this.dismissed !== null) {
696
+ if ((direct ?? this.picked) === this.dismissed) {
697
+ this.stop()
698
+ return
699
+ }
700
+ this.dismissed = null
701
+ }
702
+ if (direct) {
703
+ this.stop()
704
+ this.set(direct)
705
+ return
706
+ }
707
+ const next = this.picked
708
+ if (next === this.active) {
709
+ this.stop()
710
+ this.set(next)
711
+ return
712
+ }
713
+ if (this.pending && this.pending.id === next) return
714
+ this.stop()
715
+ this.pending = {
716
+ id: next,
717
+ timer: this.schedule(
718
+ () => {
719
+ this.pending = null
720
+ this.set(next)
721
+ },
722
+ next ? REVEAL_DWELL_MS : REVEAL_GRACE_MS,
723
+ ),
724
+ }
725
+ }
726
+
727
+ private stop() {
728
+ if (this.pending) this.cancel(this.pending.timer)
729
+ this.pending = null
730
+ }
731
+
732
+ private set(id: string | null) {
733
+ const focused = id !== null && this.focused === id
734
+ if (id === this.active && focused === this.activeFocused) return
735
+ this.active = id
736
+ this.activeFocused = focused
737
+ this.onChange(id, focused)
738
+ }
739
+ }
740
+
741
+ /** True while `shown`, and through the fade once it turns false; then false, so the pills unmount. */
742
+ export function useFadePresence(shown: boolean, onGone?: () => void): boolean {
743
+ const [present, setPresent] = useState(shown)
744
+ useEffect(() => {
745
+ if (shown) {
746
+ setPresent(true)
747
+ return
748
+ }
749
+ const timer = setTimeout(() => setPresent(false), pillFadeMs())
750
+ return () => clearTimeout(timer)
751
+ }, [shown])
752
+ const goneRef = useRef(onGone)
753
+ goneRef.current = onGone
754
+ const isPresent = shown || present
755
+ useEffect(() => {
756
+ if (!isPresent) goneRef.current?.()
757
+ }, [isPresent])
758
+ return isPresent
759
+ }
760
+
761
+ /**
762
+ * One pill layer per level: the current level's fades in, a level just left
763
+ * fades out over the next one, then unmounts.
764
+ */
765
+ function useFadingLevels(levelId: string | null, visible: boolean) {
766
+ const [levels, setLevels] = useState<string[]>([])
767
+ useEffect(() => {
768
+ if (levelId && visible) {
769
+ setLevels((previous) => (previous.includes(levelId) ? previous : [...previous, levelId]))
770
+ }
771
+ }, [levelId, visible])
772
+ const drop = useCallback(
773
+ (id: string) => setLevels((previous) => previous.filter((level) => level !== id)),
774
+ [],
775
+ )
776
+ return { levels, drop }
777
+ }
778
+
779
+ /** Where the detail chip opens, in its layer's px: on the pill's colour dot, or centred. */
780
+ type DetailOrigin = { x: number; y: number; onDot: boolean; bounds: PaneSize }
781
+ type RoomLabelDetailState = { id: string; open: boolean; focused: boolean; at: DetailOrigin }
782
+ type PointerAt = { x: number; y: number }
783
+
784
+ /**
785
+ * One layer's placement and reveal, shared by its frame loop, its pointer
786
+ * listeners and its pills: the pills it has mounted, the size each last
787
+ * rendered at (kept for pills that left the DOM), the last placement pass, and
788
+ * which room's detail is open.
789
+ */
790
+ class PillLayer {
791
+ readonly elements = new PillElements()
792
+ readonly clock = new DeclutterClock()
793
+ readonly sizes = new Map<string, PillSize>()
794
+ readonly placer: PillPlacer
795
+ readonly reveal: LabelReveal
796
+ container: () => HTMLElement | null = () => null
797
+ surface: () => Element | null | undefined = () => null
798
+ /** The pointer over the host in client px while no button is down, null off it. */
799
+ pointer: PointerAt | null = null
800
+ pointerMoved = false
801
+ private candidates = new Map<string, PillCandidate>()
802
+ private rects = new Map<string, LabelRect>()
803
+ private bounds: PaneSize = { width: 0, height: 0 }
804
+ private chip: LabelRect | null = null
805
+ // The open detail, a store of its own: see `OpenRoomLabelDetail`.
806
+ private detail: RoomLabelDetailState | null = null
807
+ private readonly detailListeners = new Set<() => void>()
808
+
809
+ constructor(onGone: (gone: ReadonlySet<string>) => void) {
810
+ this.placer = new PillPlacer(this.elements, onGone)
811
+ this.reveal = new LabelReveal((id, focused) => this.openDetail(id, focused))
812
+ }
813
+
814
+ subscribeDetail = (listener: () => void) => {
815
+ this.detailListeners.add(listener)
816
+ return () => {
817
+ this.detailListeners.delete(listener)
818
+ }
819
+ }
820
+
821
+ getDetail = () => this.detail
822
+
823
+ place(candidates: PillCandidate[], bounds: PaneSize) {
824
+ const placements = placePills(candidates, bounds, this.placer.placements)
825
+ this.placer.apply(placements)
826
+ this.bounds = { width: bounds.width, height: bounds.height }
827
+ this.candidates = new Map(candidates.map((candidate) => [candidate.id, candidate]))
828
+ this.rects = new Map()
829
+ for (const candidate of candidates) {
830
+ const rect = placedRect(candidate, placements.get(candidate.id)!)
831
+ if (rect) this.rects.set(candidate.id, rect)
832
+ }
833
+ // An open chip follows its pill to its new place.
834
+ if (this.detail?.open) this.openDetail(this.detail.id, this.detail.focused)
835
+ this.suppress()
836
+ }
837
+
838
+ /**
839
+ * After the frame's placement, if any: close the detail while the view
840
+ * moves; once it rests, follow the room under the pointer, again after each
841
+ * placement so a wheel zoom opens the room left under the cursor.
842
+ */
843
+ settle(placed: boolean, pick: (pointer: PointerAt) => string | null) {
844
+ if (!this.clock.atRest) {
845
+ this.reveal.clear()
846
+ return
847
+ }
848
+ if (!(placed || this.pointerMoved)) return
849
+ this.pointerMoved = false
850
+ this.reveal.pick(this.pointer && pick(this.pointer))
851
+ }
852
+
853
+ /** Where the detail chip now sits, or null once it closes or unmounts. */
854
+ placeDetail = (rect: LabelRect | null) => {
855
+ this.chip = rect
856
+ this.suppress()
857
+ }
858
+
859
+ private suppress() {
860
+ if (!this.chip) {
861
+ this.placer.suppress(new Set(), null)
862
+ return
863
+ }
864
+ const open = this.detail?.open ? this.detail.id : null
865
+ this.placer.suppress(suppressedBy(this.chip, this.rects), open)
866
+ }
867
+
868
+ private openDetail(id: string | null, focused: boolean) {
869
+ const at = id ? this.locate(id) : null
870
+ this.detail =
871
+ id && at ? { id, open: true, focused, at } : this.detail && { ...this.detail, open: false }
872
+ for (const listener of this.detailListeners) listener()
873
+ }
874
+
875
+ private locate(id: string): DetailOrigin | null {
876
+ const bounds = this.bounds
877
+ const rect = this.rects.get(id)
878
+ const dot = rect && this.elements.get(id)?.querySelector<HTMLElement>('[data-room-label-dot]')
879
+ if (rect && dot) {
880
+ // From the placed rect, not the pill's live box: it may still be easing in.
881
+ const x = rect.left + dot.offsetLeft + dot.offsetWidth / 2
882
+ return { x, y: (rect.top + rect.bottom) / 2, onDot: true, bounds }
883
+ }
884
+ const anchor = this.candidates.get(id)
885
+ if (anchor) return { x: anchor.x, y: anchor.y, onDot: false, bounds }
886
+ const box = this.container()?.getBoundingClientRect()
887
+ if (!(box && this.pointer)) return null
888
+ return { x: this.pointer.x - box.left, y: this.pointer.y - box.top, onDot: false, bounds }
889
+ }
890
+ }
891
+
892
+ const samePx = (a: number, b: number) => Math.abs(a - b) < 0.5
893
+
894
+ /**
895
+ * The state of one layer of pills, kept in step with them: measured as they
896
+ * mount (and observed, so a web font arriving, a rename or a unit change
897
+ * places them again), shown and hidden with the layer, and its detail opened
898
+ * by keyboard focus or the pointer over `host`: over the `surface` (the canvas
899
+ * or the plan) or the layer's own labels, not the UI over them.
900
+ */
901
+ function usePillLayer(
902
+ shown: boolean,
903
+ entries: readonly LabelEntry[],
904
+ host: HTMLElement | null,
905
+ container: () => HTMLElement | null,
906
+ surface: () => Element | null | undefined,
907
+ ) {
908
+ const [gone, setGone] = useState<ReadonlySet<string>>(() => new Set())
909
+ const [layer] = useState(() => new PillLayer(setGone))
910
+ layer.container = container
911
+ layer.surface = surface
912
+ const unit = useViewer((s) => s.unit)
913
+ const shownRef = useRef(shown)
914
+ shownRef.current = shown
915
+ useEffect(() => {
916
+ const { clock, elements, placer, sizes } = layer
917
+ elements.onMeasure = (id, element) => {
918
+ const size = measureFullPill(element)
919
+ if (!(size[0] > 0 && size[1] > 0 && size[2] > 0)) return
920
+ const previous = sizes.get(id)
921
+ if (previous?.every((value, i) => samePx(value, size[i]!))) return
922
+ sizes.set(id, size)
923
+ clock.invalidate()
924
+ }
925
+ const observer =
926
+ typeof ResizeObserver === 'undefined'
927
+ ? null
928
+ : new ResizeObserver((records) => {
929
+ for (const record of records) {
930
+ const element = record.target as HTMLElement
931
+ const id = element.dataset.roomLabel
932
+ const box = record.borderBoxSize?.[0]
933
+ if (!(id && box && box.inlineSize > 0)) continue
934
+ // A new tier resizes the pill to a size already known; anything
935
+ // else (a dot back on screen with text changed while its wrapper
936
+ // was display:none) is measured again.
937
+ const tier = element.dataset.roomLabelTier
938
+ const size = sizes.get(id)
939
+ const expected =
940
+ size && tierSize(size, tier === 'name' || tier === 'dot' ? tier : 'full')
941
+ if (
942
+ expected &&
943
+ samePx(expected[0], box.inlineSize) &&
944
+ samePx(expected[1], box.blockSize)
945
+ )
946
+ continue
947
+ elements.measure(id)
948
+ }
949
+ })
950
+ elements.onMount = (id, element) => {
951
+ placer.mount(id, element)
952
+ elements.measure(id)
953
+ observer?.observe(element)
954
+ clock.invalidate()
955
+ }
956
+ elements.onUnmount = (element) => {
957
+ observer?.unobserve(element)
958
+ clock.invalidate()
959
+ }
960
+ for (const [id, element] of elements) elements.onMount(id, element)
961
+ const fontsChanged = () => {
962
+ placer.remeasure()
963
+ for (const id of elements.keys()) elements.measure(id)
964
+ }
965
+ document.fonts?.addEventListener('loadingdone', fontsChanged)
966
+ return () => {
967
+ elements.onMount = null
968
+ elements.onUnmount = null
969
+ elements.onMeasure = null
970
+ document.fonts?.removeEventListener('loadingdone', fontsChanged)
971
+ observer?.disconnect()
972
+ placer.dispose()
973
+ layer.reveal.clear()
974
+ }
975
+ }, [layer])
976
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Hidden pills must remount to measure changed entries or units.
977
+ useEffect(() => {
978
+ layer.placer.remeasure()
979
+ layer.clock.invalidate()
980
+ }, [entries, layer, unit])
981
+ useEffect(() => {
982
+ layer.placer.showAll(shown)
983
+ if (shown) layer.clock.invalidate()
984
+ else layer.reveal.clear()
985
+ }, [layer, shown])
986
+ useEffect(() => {
987
+ if (!host) return
988
+ const { reveal } = layer
989
+ const labelAt = (target: EventTarget | null) => {
990
+ const label =
991
+ target instanceof Element ? target.closest<HTMLElement>('[data-room-label]') : null
992
+ if (!(label && layer.container()?.contains(label))) return null
993
+ return label.dataset.roomLabel ?? null
994
+ }
995
+ const move = (event: PointerEvent) => {
996
+ const label = labelAt(event.target)
997
+ const over =
998
+ event.buttons === 0 &&
999
+ event.pointerType !== 'touch' &&
1000
+ (label !== null ||
1001
+ (event.target instanceof Node && !!layer.surface()?.contains(event.target)))
1002
+ layer.pointer = over ? { x: event.clientX, y: event.clientY } : null
1003
+ layer.pointerMoved = true
1004
+ if (shownRef.current && layer.clock.atRest) reveal.hover(over ? label : null)
1005
+ }
1006
+ const leave = () => {
1007
+ layer.pointer = null
1008
+ reveal.hover(null)
1009
+ reveal.pick(null)
1010
+ }
1011
+ const focusIn = (event: FocusEvent) => {
1012
+ const id = labelAt(event.target)
1013
+ if (id && shownRef.current && (event.target as Element).matches(':focus-visible'))
1014
+ reveal.focus(id)
1015
+ }
1016
+ const focusOut = () => reveal.focus(null)
1017
+ // The canvas and the plan pan while Space is held; on a pill, Space presses it.
1018
+ const keepSpace = (event: KeyboardEvent) => {
1019
+ if (event.code === 'Space' && labelAt(event.target)) event.stopPropagation()
1020
+ }
1021
+ const dismiss = (event: KeyboardEvent) => {
1022
+ if (event.key === 'Escape') reveal.dismiss()
1023
+ }
1024
+ // Captured: a plan or canvas handler stopping the move must not blind the layer.
1025
+ host.addEventListener('pointermove', move, { capture: true, passive: true })
1026
+ // A drag ending with the pointer held still picks the room under it once the view rests.
1027
+ host.addEventListener('pointerup', move, { capture: true, passive: true })
1028
+ host.addEventListener('pointerleave', leave)
1029
+ host.addEventListener('focusin', focusIn)
1030
+ host.addEventListener('focusout', focusOut)
1031
+ host.addEventListener('keydown', keepSpace)
1032
+ host.addEventListener('keyup', keepSpace)
1033
+ document.addEventListener('keydown', dismiss)
1034
+ return () => {
1035
+ host.removeEventListener('pointermove', move, { capture: true })
1036
+ host.removeEventListener('pointerup', move, { capture: true })
1037
+ host.removeEventListener('pointerleave', leave)
1038
+ host.removeEventListener('focusin', focusIn)
1039
+ host.removeEventListener('focusout', focusOut)
1040
+ host.removeEventListener('keydown', keepSpace)
1041
+ host.removeEventListener('keyup', keepSpace)
1042
+ document.removeEventListener('keydown', dismiss)
1043
+ }
1044
+ }, [host, layer])
1045
+ return { layer, gone }
1046
+ }
1047
+
1048
+ /**
1049
+ * The pills of one level: every detected room, and every drawn zone that
1050
+ * bounds none. A smaller one inside a larger one (a zone drawn in a room) is
1051
+ * a hole in the larger: the label there, and the room under the pointer
1052
+ * there, are the smaller one's.
1053
+ */
1054
+ export function roomLabelEntries(
1055
+ rooms: readonly RoomSelectionRecord[],
1056
+ zones: readonly ZoneNode[],
1057
+ ): RoomLabelEntry[] {
1058
+ const hiddenIds = new Set<string>(
1059
+ zones.filter((zone) => zone.visible === false).map((zone) => zone.id),
1060
+ )
1061
+ const roomIds = new Set(rooms.map((room) => room.zoneId))
1062
+ const entries: RoomLabelEntry[] = [
1063
+ ...rooms
1064
+ .filter((room) => !hiddenIds.has(room.zoneId))
1065
+ .map((room) => ({
1066
+ zoneId: room.zoneId,
1067
+ polygon: room.polygon,
1068
+ holes: room.holes,
1069
+ area: room.area,
1070
+ })),
1071
+ ...zones
1072
+ .filter((zone) => zone.visible !== false && !roomIds.has(zone.id) && zone.polygon.length >= 3)
1073
+ .map((zone) => ({
1074
+ zoneId: zone.id,
1075
+ polygon: zone.polygon,
1076
+ holes: zone.holes ?? [],
1077
+ area: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
1078
+ })),
1079
+ ]
1080
+ const insides = entries.map((entry) =>
1081
+ polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
1082
+ )
1083
+ return entries.map((entry) => {
1084
+ const outline = [{ outer: entry.polygon, holes: entry.holes }]
1085
+ const nested = entries.filter(
1086
+ (other, i) => other.area < entry.area && containsPoint(outline, insides[i]!),
1087
+ )
1088
+ return nested.length === 0
1089
+ ? entry
1090
+ : { ...entry, holes: [...entry.holes, ...nested.map((other) => other.polygon)] }
1091
+ })
1092
+ }
1093
+
1094
+ function useRoomLabelEntries(levelId: string | null): LabelEntry[] {
1095
+ const rooms = useRoomRecords(levelId)
1096
+ const zones = useScene(
1097
+ useShallow((s) =>
1098
+ levelId
1099
+ ? Object.values(s.nodes).filter(
1100
+ (node): node is ZoneNode => node.type === 'zone' && node.parentId === levelId,
1101
+ )
1102
+ : [],
1103
+ ),
1104
+ )
1105
+ return useMemo(() => roomLabelEntries(rooms, zones), [rooms, zones])
1106
+ }
1107
+
1108
+ /** A label's colour dot, name and area: a pill hides what its tier leaves out, the detail shows all. */
1109
+ function LabelParts({
1110
+ color,
1111
+ name,
1112
+ areaLabel,
1113
+ full,
1114
+ }: {
1115
+ color: string
1116
+ name: string
1117
+ areaLabel: string
1118
+ full: boolean
1119
+ }) {
1120
+ return (
1121
+ <>
1122
+ <span
1123
+ className="h-2 w-2 shrink-0 rounded-full"
1124
+ data-room-label-dot=""
1125
+ style={{ backgroundColor: color }}
1126
+ />
1127
+ <span
1128
+ className={
1129
+ full
1130
+ ? 'min-w-0 font-medium [overflow-wrap:anywhere]'
1131
+ : 'max-w-40 truncate font-medium [[data-room-label-tier=dot]>&]:hidden'
1132
+ }
1133
+ >
1134
+ {name}
1135
+ </span>
1136
+ <span
1137
+ className="shrink-0 whitespace-nowrap font-mono text-white/60 tabular-nums [[data-room-label-tier]>&]:hidden"
1138
+ data-room-label-area=""
1139
+ >
1140
+ {areaLabel}
1141
+ </span>
1142
+ </>
1143
+ )
1144
+ }
1145
+
1146
+ /**
1147
+ * The pill itself, a button: a colour dot, the name (cut at 160 px) and the
1148
+ * area in the active unit, as much of it as its placement shows.
1149
+ */
1150
+ export function RoomPillChip({
1151
+ zoneId,
1152
+ view,
1153
+ color,
1154
+ name,
1155
+ areaM2,
1156
+ elements,
1157
+ onClick,
1158
+ onHover,
1159
+ onLeave,
1160
+ }: {
1161
+ zoneId: string
1162
+ view: '2d' | '3d'
1163
+ color: string
1164
+ name: string
1165
+ areaM2: number
1166
+ elements: Map<string, HTMLElement>
1167
+ onClick: (event: ReactMouseEvent) => void
1168
+ onHover: (event: ReactPointerEvent) => void
1169
+ onLeave: () => void
1170
+ }) {
1171
+ const unit = useViewer((s) => s.unit)
1172
+ const areaLabel = formatRoomArea(areaM2, unit)
1173
+ // biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes.
1174
+ useLayoutEffect(() => {
1175
+ if (elements instanceof PillElements) elements.measure(zoneId)
1176
+ }, [areaM2, elements, name, unit, zoneId])
1177
+ const register = useCallback(
1178
+ (element: HTMLElement | null) => {
1179
+ if (element) elements.set(zoneId, element)
1180
+ else elements.delete(zoneId)
1181
+ },
1182
+ [elements, zoneId],
1183
+ )
1184
+ return (
1185
+ <button
1186
+ aria-label={`${name}, ${areaLabel}`}
1187
+ // Fades in once placed, and out while hidden (then out of the tab order)
1188
+ // or under the detail chip (taking no pointer unless it is the open
1189
+ // room's); eases into its nudged place and its tier.
1190
+ className="pointer-events-auto flex cursor-pointer select-none items-center gap-1.5 whitespace-nowrap rounded-full border border-white/10 bg-neutral-950/75 px-2.5 py-1 text-white text-xs shadow-sm backdrop-blur-sm transition-[opacity,translate,visibility] duration-180 starting:opacity-0 focus-visible:outline-2 focus-visible:outline-white focus-visible:outline-offset-2 focus-visible:ring-6 focus-visible:ring-neutral-950/85 data-room-label-hidden:pointer-events-none data-room-label-suppressed:pointer-events-none data-room-label-hidden:invisible data-room-label-hidden:opacity-0 data-room-label-open:opacity-0 data-room-label-suppressed:opacity-0 data-room-label-open:duration-150 data-room-label-suppressed:duration-150 data-[room-label-tier=dot]:gap-0 data-[room-label-tier=dot]:p-[3px] motion-reduce:transition-none"
1191
+ data-room-label={zoneId}
1192
+ data-room-label-view={view}
1193
+ onClick={(event) => {
1194
+ event.stopPropagation()
1195
+ onClick(event)
1196
+ }}
1197
+ onPointerDown={(event) => event.stopPropagation()}
1198
+ // Move as well as enter: leaving the canvas for the pill ends the canvas
1199
+ // hover after the pill's enter has run.
1200
+ onPointerEnter={onHover}
1201
+ onPointerLeave={onLeave}
1202
+ onPointerMove={onHover}
1203
+ ref={register}
1204
+ type="button"
1205
+ >
1206
+ <LabelParts areaLabel={areaLabel} color={color} full={false} name={name} />
1207
+ </button>
1208
+ )
1209
+ }
1210
+
1211
+ /**
1212
+ * The detail chip: the open room's whole label, wrapping to fit its layer.
1213
+ * It only reads: the pill under it is the control, and the pointer goes
1214
+ * through it to the rooms it covers.
1215
+ */
1216
+ export function RoomPillDetail({
1217
+ zoneId,
1218
+ view,
1219
+ color,
1220
+ name,
1221
+ areaM2,
1222
+ }: {
1223
+ zoneId: string
1224
+ view: '2d' | '3d'
1225
+ color: string
1226
+ name: string
1227
+ areaM2: number
1228
+ }) {
1229
+ const unit = useViewer((s) => s.unit)
1230
+ return (
1231
+ <div
1232
+ aria-hidden
1233
+ className="flex select-none items-center gap-1.5 rounded-[13px] border border-white/10 bg-neutral-950/90 px-2.5 py-1 text-white text-xs shadow-elevation-3 backdrop-blur-sm"
1234
+ data-room-label-detail={zoneId}
1235
+ data-room-label-view={view}
1236
+ >
1237
+ <LabelParts areaLabel={formatRoomArea(areaM2, unit)} color={color} full name={name} />
1238
+ </div>
1239
+ )
1240
+ }
1241
+
1242
+ function useLabelZone(zoneId: string) {
1243
+ const zone = useScene((s) => s.nodes[zoneId as AnyNodeId] as ZoneNode | undefined)
1244
+ return zone && zone.visible !== false ? zone : null
1245
+ }
1246
+
1247
+ const labelName = (zone: ZoneNode) => zone.name?.trim() || zoneKindLabel(zone)
1248
+
1249
+ /**
1250
+ * One room's pill in the editor. Hover and click take the same rule as the
1251
+ * zone's area in the plan (`hoverZoneArea`, `clickZoneArea`); only the pill
1252
+ * itself takes the pointer.
1253
+ */
1254
+ function RoomPill({
1255
+ entry,
1256
+ view,
1257
+ elements,
1258
+ }: {
1259
+ entry: LabelEntry
1260
+ view: '2d' | '3d'
1261
+ elements: Map<string, HTMLElement>
1262
+ }) {
1263
+ const zone = useLabelZone(entry.zoneId)
1264
+ // A pill that disappears under the cursor (its click selected something)
1265
+ // fires no leave: end the hover it set.
1266
+ useEffect(() => () => leaveZoneArea(entry.zoneId), [entry.zoneId])
1267
+ if (!zone) return null
1268
+ return (
1269
+ <RoomPillChip
1270
+ areaM2={entry.area}
1271
+ color={zone.color}
1272
+ elements={elements}
1273
+ name={labelName(zone)}
1274
+ onClick={(event) => clickZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
1275
+ onHover={(event) => hoverZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
1276
+ onLeave={() => leaveZoneArea(entry.zoneId)}
1277
+ view={view}
1278
+ zoneId={entry.zoneId}
1279
+ />
1280
+ )
1281
+ }
1282
+
1283
+ function RoomDetail({ entry, view }: { entry: LabelEntry; view: '2d' | '3d' }) {
1284
+ const zone = useLabelZone(entry.zoneId)
1285
+ if (!zone) return null
1286
+ return (
1287
+ <RoomPillDetail
1288
+ areaM2={entry.area}
1289
+ color={zone.color}
1290
+ name={labelName(zone)}
1291
+ view={view}
1292
+ zoneId={entry.zoneId}
1293
+ />
1294
+ )
1295
+ }
1296
+
1297
+ /**
1298
+ * The open room's whole label over its layer: opened with its colour dot on
1299
+ * the pill's, or centred where a hidden room's label would sit, and kept 8 px
1300
+ * inside the layer. The pills it covers fade out of its way; nothing moves.
1301
+ * One chip serves every room, so a handoff moves it without fading it again.
1302
+ */
1303
+ function RoomLabelDetail({
1304
+ detail,
1305
+ onPlaced,
1306
+ children,
1307
+ }: {
1308
+ detail: RoomLabelDetailState
1309
+ onPlaced: (rect: LabelRect | null) => void
1310
+ children: ReactNode
1311
+ }) {
1312
+ const ref = useRef<HTMLDivElement>(null)
1313
+ const { open, at } = detail
1314
+ useLayoutEffect(() => {
1315
+ const frame = ref.current
1316
+ if (!(frame && open)) {
1317
+ onPlaced(null)
1318
+ return
1319
+ }
1320
+ const place = () => {
1321
+ const width = frame.offsetWidth
1322
+ const height = frame.offsetHeight
1323
+ const dot = frame.querySelector<HTMLElement>('[data-room-label-dot]')
1324
+ const x = at.onDot && dot ? at.x - dot.offsetLeft - dot.offsetWidth / 2 : at.x - width / 2
1325
+ const left = Math.max(DETAIL_INSET, Math.min(x, at.bounds.width - DETAIL_INSET - width))
1326
+ const top = Math.max(
1327
+ DETAIL_INSET,
1328
+ Math.min(at.y - height / 2, at.bounds.height - DETAIL_INSET - height),
1329
+ )
1330
+ frame.style.left = `${left}px`
1331
+ frame.style.top = `${top}px`
1332
+ onPlaced({ left, top, right: left + width, bottom: top + height })
1333
+ }
1334
+ place()
1335
+ // Placed again when its text reflows: a rename, a unit change, a font arriving.
1336
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(place)
1337
+ observer?.observe(frame)
1338
+ return () => observer?.disconnect()
1339
+ }, [at, onPlaced, open])
1340
+ // Gone with its room: the pills it covered come back.
1341
+ useLayoutEffect(() => () => onPlaced(null), [onPlaced])
1342
+ return (
1343
+ <div
1344
+ className="pointer-events-none absolute z-30 w-max origin-left rounded-[13px] transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] starting:scale-[0.98] starting:opacity-0 data-closed:scale-[0.98] data-closed:opacity-0 data-closed:duration-50 data-focused:outline-2 data-focused:outline-white data-focused:outline-offset-2 data-focused:ring-6 data-focused:ring-neutral-950/85 motion-reduce:transition-none"
1345
+ data-closed={open ? undefined : ''}
1346
+ data-focused={open && detail.focused ? '' : undefined}
1347
+ ref={ref}
1348
+ style={{ maxWidth: at.bounds.width - 2 * DETAIL_INSET }}
1349
+ >
1350
+ {children}
1351
+ </div>
1352
+ )
1353
+ }
1354
+
1355
+ /**
1356
+ * The open room's detail, read from the layer's own store where it renders,
1357
+ * so a reveal re-renders the chip alone, not the canvas tree or every pill.
1358
+ */
1359
+ function OpenRoomLabelDetail({
1360
+ layer,
1361
+ entries,
1362
+ renderDetail,
1363
+ }: {
1364
+ layer: PillLayer
1365
+ entries: readonly LabelEntry[]
1366
+ renderDetail: (entry: LabelEntry) => ReactNode
1367
+ }) {
1368
+ const detail = useSyncExternalStore(layer.subscribeDetail, layer.getDetail, layer.getDetail)
1369
+ const entry = detail && entries.find((candidate) => candidate.zoneId === detail.id)
1370
+ if (!(detail && entry)) return null
1371
+ return (
1372
+ <RoomLabelDetail detail={detail} onPlaced={layer.placeDetail}>
1373
+ {renderDetail(entry)}
1374
+ </RoomLabelDetail>
1375
+ )
1376
+ }
1377
+
1378
+ const renderDetail2D = (entry: LabelEntry) => <RoomDetail entry={entry} view="2d" />
1379
+
1380
+ /** Light name-and-area pills over each room and drawn zone of the active level. */
1381
+ export function RoomLabels3D() {
1382
+ const levelId = useViewer((s) => s.selection.levelId)
1383
+ const sceneShown = useEditor((s) => isViewVisible(s, VIEW_3D))
1384
+ const visible = useRoomLabelsVisible() && sceneShown
1385
+ const { levels, drop } = useFadingLevels(levelId, visible)
1386
+ return levels.map((id) => (
1387
+ <LevelRoomLabels3D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
1388
+ ))
1389
+ }
1390
+
1391
+ function LevelRoomLabels3D({
1392
+ levelId,
1393
+ shown,
1394
+ onGone,
1395
+ }: {
1396
+ levelId: string
1397
+ shown: boolean
1398
+ onGone: (levelId: string) => void
1399
+ }) {
1400
+ const present = useFadePresence(shown, () => onGone(levelId))
1401
+ const entries = useRoomLabelEntries(present ? levelId : null)
1402
+ if (!present) return null
1403
+ return (
1404
+ <RoomPillsLayer3D
1405
+ entries={entries}
1406
+ levelObject={() => sceneRegistry.nodes.get(levelId)}
1407
+ renderDetail={(entry) => <RoomDetail entry={entry} view="3d" />}
1408
+ renderLabel={(entry, anchors, elements) => (
1409
+ <EditorRoomLabel3D anchors={anchors} elements={elements} entry={entry} />
1410
+ )}
1411
+ shown={shown}
1412
+ />
1413
+ )
1414
+ }
1415
+
1416
+ function EditorRoomLabel3D({
1417
+ entry,
1418
+ anchors,
1419
+ elements,
1420
+ }: {
1421
+ entry: LabelEntry
1422
+ anchors: Map<string, Vector3>
1423
+ elements: Map<string, HTMLElement>
1424
+ }) {
1425
+ const floorY = useScene((s) => roomFloorElevation(s.nodes, entry.zoneId))
1426
+ return (
1427
+ <RoomLabelAnchor3D anchors={anchors} entry={entry} floorY={floorY}>
1428
+ <RoomPill elements={elements} entry={entry} view="3d" />
1429
+ </RoomLabelAnchor3D>
1430
+ )
1431
+ }
1432
+
1433
+ const toScreen = new Matrix4()
1434
+
1435
+ /** Where a level-local point lands in the canvas, in px, with its depth; null behind the camera. */
1436
+ function screenPoint(x: number, y: number, z: number, size: PaneSize) {
1437
+ const e = toScreen.elements
1438
+ const w = e[3] * x + e[7] * y + e[11] * z + e[15]
1439
+ if (!(w > 0)) return null
1440
+ return [
1441
+ (((e[0] * x + e[4] * y + e[8] * z + e[12]) / w + 1) / 2) * size.width,
1442
+ ((1 - (e[1] * x + e[5] * y + e[9] * z + e[13]) / w) / 2) * size.height,
1443
+ (e[2] * x + e[6] * y + e[10] * z + e[14]) / w,
1444
+ ] as const
1445
+ }
1446
+
1447
+ // How far in front of the camera an outline is cut off (in clip-space w, the
1448
+ // depth along the view for a perspective camera): past it, points project
1449
+ // ever further out and then flip to the other side of the screen.
1450
+ const NEAR_W = 1e-3
1451
+
1452
+ /**
1453
+ * A level-local ring at height `y` on screen: cut to the part in front of the
1454
+ * camera (Sutherland–Hodgman against w = NEAR_W, which is linear in the
1455
+ * level's coordinates), then projected. Null when none of it is in front.
1456
+ */
1457
+ function screenRing(points: readonly [number, number][], y: number, size: PaneSize): Ring | null {
1458
+ const e = toScreen.elements
1459
+ const w = ([x, z]: readonly [number, number]) => e[3] * x + e[7] * y + e[11] * z + e[15]
1460
+ const ring: Ring = []
1461
+ const add = (x: number, z: number) => {
1462
+ const point = screenPoint(x, y, z, size)
1463
+ if (point) ring.push([point[0], point[1]])
1464
+ }
1465
+ for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
1466
+ const a = points[j]!
1467
+ const b = points[i]!
1468
+ const wa = w(a)
1469
+ const wb = w(b)
1470
+ if (wa >= NEAR_W !== wb >= NEAR_W) {
1471
+ const t = (wa - NEAR_W) / (wa - wb)
1472
+ add(a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
1473
+ }
1474
+ if (wb >= NEAR_W) add(b[0], b[1])
1475
+ }
1476
+ return ring.length >= 3 ? ring : null
1477
+ }
1478
+
1479
+ /** Where the pills of a level land on screen, each with the part of its room in front of the camera. */
1480
+ export function candidates3D(
1481
+ entries: readonly LabelEntry[],
1482
+ anchors: ReadonlyMap<string, Vector3>,
1483
+ sizes: ReadonlyMap<string, PillSize>,
1484
+ camera: Camera,
1485
+ level: Object3D,
1486
+ size: PaneSize,
1487
+ ): PillCandidate[] {
1488
+ toScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
1489
+ toScreen.multiply(level.matrixWorld)
1490
+ const candidates: PillCandidate[] = []
1491
+ for (const entry of entries) {
1492
+ const anchor = anchors.get(entry.zoneId)
1493
+ const pill = sizes.get(entry.zoneId)
1494
+ if (!(anchor && pill)) continue
1495
+ const at = screenPoint(anchor.x, anchor.y, anchor.z, size)
1496
+ // Behind the camera or past its far plane: not on screen to collide.
1497
+ if (!at || Math.abs(at[2]) > 1) continue
1498
+ // The room's outline at its pill's height, where the pill has to fit.
1499
+ const outer = screenRing(entry.polygon, anchor.y, size)
1500
+ if (!outer) continue
1501
+ const holes: Ring[] = []
1502
+ for (const hole of entry.holes) {
1503
+ const ring = screenRing(hole, anchor.y, size)
1504
+ if (ring) holes.push(ring)
1505
+ }
1506
+ candidates.push({
1507
+ id: entry.zoneId,
1508
+ x: at[0],
1509
+ y: at[1],
1510
+ size: pill,
1511
+ area: entry.area,
1512
+ outline: { outer, holes },
1513
+ })
1514
+ }
1515
+ return candidates
1516
+ }
1517
+
1518
+ const pickRaycaster = new Raycaster()
1519
+ const pickPointer = new Vector2()
1520
+ const levelInverse = new Matrix4()
1521
+ const levelRay = new Ray()
1522
+
1523
+ /** The room whose floor the pointer's ray meets first, testing each room at its own floor. */
1524
+ function pick3D(
1525
+ entries: readonly LabelEntry[],
1526
+ anchors: ReadonlyMap<string, Vector3>,
1527
+ pointer: PointerAt,
1528
+ camera: Camera,
1529
+ level: Object3D,
1530
+ size: PaneSize & { left: number; top: number },
1531
+ ): string | null {
1532
+ pickPointer.set(
1533
+ ((pointer.x - size.left) / size.width) * 2 - 1,
1534
+ -((pointer.y - size.top) / size.height) * 2 + 1,
1535
+ )
1536
+ pickRaycaster.setFromCamera(pickPointer, camera)
1537
+ levelRay.copy(pickRaycaster.ray).applyMatrix4(levelInverse.copy(level.matrixWorld).invert())
1538
+ const { origin, direction } = levelRay
1539
+ return roomAt(entries, (entry) => {
1540
+ const anchor = anchors.get(entry.zoneId)
1541
+ if (!anchor) return null
1542
+ const t = (anchor.y - LABEL_HEIGHT - origin.y) / direction.y
1543
+ return t > 0 && Number.isFinite(t)
1544
+ ? [origin.x + t * direction.x, origin.z + t * direction.z, t]
1545
+ : null
1546
+ })
1547
+ }
1548
+
1549
+ // The detail chip's own Html sits at its layer's top-left corner and places
1550
+ // the chip itself, so drei must never hide it: the object it rides is not
1551
+ // where it shows.
1552
+ const atLayerOrigin = () => [0, 0]
1553
+ const keepShown = () => {}
1554
+
1555
+ /**
1556
+ * The pills of one level in 3D, riding the level's object (so they follow
1557
+ * level stacking) and placed from where their anchors land on screen. They
1558
+ * live in their own stacking layer clipped to the canvas, so every piece of
1559
+ * UI over the canvas stays above them; the open room's chip, the label being
1560
+ * read, has a layer of its own above the canvas's flags.
1561
+ */
1562
+ export function RoomPillsLayer3D({
1563
+ entries,
1564
+ levelObject,
1565
+ renderLabel,
1566
+ renderDetail,
1567
+ shown = true,
1568
+ }: {
1569
+ entries: readonly LabelEntry[]
1570
+ levelObject: () => Object3D | undefined
1571
+ renderLabel: (
1572
+ entry: LabelEntry,
1573
+ anchors: Map<string, Vector3>,
1574
+ elements: Map<string, HTMLElement>,
1575
+ ) => ReactNode
1576
+ renderDetail: (entry: LabelEntry) => ReactNode
1577
+ shown?: boolean
1578
+ }) {
1579
+ const ref = useRef<Group>(null)
1580
+ const [anchors] = useState(() => new RoomAnchors())
1581
+ const view = useRef(new Float64Array(50))
1582
+ const host = useThree((s) => (s.events.connected ?? s.gl.domElement.parentNode) as HTMLElement)
1583
+ const [portals] = useState(() =>
1584
+ typeof document === 'undefined'
1585
+ ? null
1586
+ : {
1587
+ pills: { current: document.createElement('div') },
1588
+ detail: { current: document.createElement('div') },
1589
+ },
1590
+ )
1591
+ const canvas = useThree((s) => s.gl.domElement)
1592
+ const { layer, gone } = usePillLayer(
1593
+ shown,
1594
+ entries,
1595
+ host,
1596
+ () => portals?.pills.current ?? null,
1597
+ () => canvas,
1598
+ )
1599
+ useEffect(() => {
1600
+ if (!(host && portals)) return
1601
+ const { pills, detail } = portals
1602
+ pills.current.className = 'pointer-events-none absolute inset-0 isolate z-0 overflow-hidden'
1603
+ // Over the flags and guides on the canvas (z-index 25 at most), unlike the pills.
1604
+ detail.current.className = 'pointer-events-none absolute inset-0 z-30'
1605
+ host.append(pills.current, detail.current)
1606
+ return () => {
1607
+ pills.current.remove()
1608
+ detail.current.remove()
1609
+ }
1610
+ }, [host, portals])
1611
+
1612
+ useEffect(() => {
1613
+ anchors.onMove = () => layer.clock.invalidate()
1614
+ return () => {
1615
+ anchors.onMove = null
1616
+ }
1617
+ }, [anchors, layer])
1618
+
1619
+ useEffect(() => {
1620
+ const ids = new Set(entries.map((entry) => entry.zoneId))
1621
+ for (const id of anchors.keys()) if (!ids.has(id)) anchors.delete(id)
1622
+ layer.clock.invalidate()
1623
+ }, [anchors, entries, layer])
1624
+
1625
+ useFrame(({ camera, clock: frameClock, size }) => {
1626
+ if (!ref.current) return
1627
+ const level = levelObject()
1628
+ ref.current.visible = !!level
1629
+ if (!level) return
1630
+ level.updateWorldMatrix(true, false)
1631
+ ref.current.matrix.copy(level.matrixWorld)
1632
+ if (!shown) return
1633
+
1634
+ const flat = view.current
1635
+ flat.set(camera.matrixWorld.elements, 0)
1636
+ flat.set(camera.projectionMatrix.elements, 16)
1637
+ flat.set(level.matrixWorld.elements, 32)
1638
+ flat[48] = size.width
1639
+ flat[49] = size.height
1640
+ const placed = layer.clock.shouldRead(flat, frameClock.elapsedTime)
1641
+ if (placed) {
1642
+ timeSpan('room-labels', () =>
1643
+ layer.place(candidates3D(entries, anchors, layer.sizes, camera, level, size), size),
1644
+ )
1645
+ }
1646
+ layer.settle(placed, (pointer) => pick3D(entries, anchors, pointer, camera, level, size))
1647
+ })
1648
+
1649
+ if (entries.length === 0 || !portals) return null
1650
+ return (
1651
+ <PillPortalContext.Provider value={portals.pills}>
1652
+ <HiddenPillsContext.Provider value={gone}>
1653
+ <group matrixAutoUpdate={false} ref={ref}>
1654
+ {entries.map((entry) => (
1655
+ <Fragment key={entry.zoneId}>{renderLabel(entry, anchors, layer.elements)}</Fragment>
1656
+ ))}
1657
+ <Html
1658
+ calculatePosition={atLayerOrigin}
1659
+ onOcclude={keepShown}
1660
+ portal={portals.detail}
1661
+ style={{ pointerEvents: 'none' }}
1662
+ >
1663
+ <OpenRoomLabelDetail entries={entries} layer={layer} renderDetail={renderDetail} />
1664
+ </Html>
1665
+ </group>
1666
+ </HiddenPillsContext.Provider>
1667
+ </PillPortalContext.Provider>
1668
+ )
1669
+ }
1670
+
1671
+ const PillPortalContext = createContext<{ current: HTMLElement } | null>(null)
1672
+ const HiddenPillsContext = createContext<ReadonlySet<string>>(new Set())
1673
+
1674
+ /** Where a room's pill floats: over its interior, at mid-storey above its floor. */
1675
+ export function RoomLabelAnchor3D({
1676
+ entry,
1677
+ floorY,
1678
+ anchors,
1679
+ children,
1680
+ }: {
1681
+ entry: LabelEntry
1682
+ floorY: number
1683
+ anchors: Map<string, Vector3>
1684
+ children: ReactNode
1685
+ }) {
1686
+ const portal = useContext(PillPortalContext)
1687
+ const gone = useContext(HiddenPillsContext)
1688
+ const position = useMemo(() => {
1689
+ const [x, z] = polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes })
1690
+ return new Vector3(x, floorY + LABEL_HEIGHT, z)
1691
+ }, [entry.polygon, entry.holes, floorY])
1692
+ // Hidden HTML still needs a live anchor so moving a room can give its pill
1693
+ // room again. The layer drops anchors of rooms that left.
1694
+ useEffect(() => {
1695
+ anchors.set(entry.zoneId, position)
1696
+ }, [anchors, entry.zoneId, position])
1697
+ if (gone.has(entry.zoneId)) return null
1698
+ return (
1699
+ <Html
1700
+ center
1701
+ portal={portal ?? undefined}
1702
+ position={position}
1703
+ // The wrapper lets the canvas keep everything but the pill itself.
1704
+ style={{ pointerEvents: 'none' }}
1705
+ zIndexRange={[20, 0]}
1706
+ >
1707
+ {children}
1708
+ </Html>
1709
+ )
1710
+ }
1711
+
1712
+ /**
1713
+ * The same pills over the 2D plan: screen-positioned from the plan's transform,
1714
+ * re-placed as in 3D once the plan rests, and clipped to the plan's pane in the
1715
+ * plan's own stacking layer, under the UI over it.
1716
+ */
1717
+ export function RoomLabels2D({ levelId }: { levelId: string | null }) {
1718
+ const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
1719
+ const visible = useRoomLabelsVisible() && planShown
1720
+ const { levels, drop } = useFadingLevels(levelId, visible)
1721
+ return levels.map((id) => (
1722
+ <LevelRoomLabels2D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
1723
+ ))
1724
+ }
1725
+
1726
+ function LevelRoomLabels2D({
1727
+ levelId,
1728
+ shown,
1729
+ onGone,
1730
+ }: {
1731
+ levelId: string
1732
+ shown: boolean
1733
+ onGone: (levelId: string) => void
1734
+ }) {
1735
+ const present = useFadePresence(shown, () => onGone(levelId))
1736
+ const entries = useRoomLabelEntries(present ? levelId : null)
1737
+ const ref = useRef<SVGGElement>(null)
1738
+ const paneRef = useRef<HTMLDivElement>(null)
1739
+ const [host, setHost] = useState<HTMLElement | null>(null)
1740
+ const [nodes] = useState(() => new PillElements())
1741
+ const { layer, gone } = usePillLayer(
1742
+ shown,
1743
+ entries,
1744
+ host,
1745
+ () => paneRef.current,
1746
+ () => ref.current?.ownerSVGElement,
1747
+ )
1748
+ const shownRef = useRef(shown)
1749
+ shownRef.current = shown
1750
+ const anchors = useMemo(
1751
+ () =>
1752
+ new Map(
1753
+ entries.map((entry) => [
1754
+ entry.zoneId,
1755
+ polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
1756
+ ]),
1757
+ ),
1758
+ [entries],
1759
+ )
1760
+
1761
+ useEffect(() => {
1762
+ setHost(ref.current?.ownerSVGElement?.parentElement ?? null)
1763
+ }, [])
1764
+
1765
+ useEffect(() => {
1766
+ if (!present || entries.length === 0) return
1767
+ let raf = 0
1768
+ let lastKey = ''
1769
+ const view = new Float64Array(10)
1770
+ // A pill wrapper that mounts is placed on the next frame.
1771
+ nodes.onMount = () => {
1772
+ lastKey = ''
1773
+ }
1774
+ layer.clock.invalidate()
1775
+ const tick = (now: number) => {
1776
+ raf = requestAnimationFrame(tick)
1777
+ const group = ref.current
1778
+ const svg = group?.ownerSVGElement
1779
+ const ctm = group?.getScreenCTM()
1780
+ const paneElement = paneRef.current
1781
+ if (!(svg && ctm && paneElement)) return
1782
+ const pane = svg.getBoundingClientRect()
1783
+ const origin = paneElement.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }
1784
+ const key = `${ctm.a},${ctm.b},${ctm.c},${ctm.d},${ctm.e},${ctm.f},${pane.left},${pane.top},${pane.width},${pane.height},${origin.left},${origin.top}`
1785
+ // A plan point in the pane's px.
1786
+ const place = ([x, y]: readonly [number, number]): [number, number] => [
1787
+ ctm.a * x + ctm.c * y + ctm.e - pane.left,
1788
+ ctm.b * x + ctm.d * y + ctm.f - pane.top,
1789
+ ]
1790
+ const inside = ([x, y]: readonly [number, number]) =>
1791
+ x >= 0 && x <= pane.width && y >= 0 && y <= pane.height
1792
+ if (key !== lastKey) {
1793
+ lastKey = key
1794
+ Object.assign(paneElement.style, {
1795
+ left: `${pane.left - origin.left}px`,
1796
+ top: `${pane.top - origin.top}px`,
1797
+ width: `${pane.width}px`,
1798
+ height: `${pane.height}px`,
1799
+ })
1800
+ for (const [id, element] of nodes) {
1801
+ const anchor = anchors.get(id)
1802
+ if (!anchor) continue
1803
+ const at = place(anchor)
1804
+ element.style.left = `${at[0]}px`
1805
+ element.style.top = `${at[1]}px`
1806
+ element.style.display = inside(at) ? '' : 'none'
1807
+ }
1808
+ }
1809
+ view[0] = ctm.a
1810
+ view[1] = ctm.b
1811
+ view[2] = ctm.c
1812
+ view[3] = ctm.d
1813
+ view[4] = ctm.e
1814
+ view[5] = ctm.f
1815
+ view[6] = pane.left
1816
+ view[7] = pane.top
1817
+ view[8] = pane.width
1818
+ view[9] = pane.height
1819
+ if (!shownRef.current) return
1820
+ const placed = layer.clock.shouldRead(view, now / 1000)
1821
+ if (placed) {
1822
+ timeSpan('room-labels', () => {
1823
+ const candidates: PillCandidate[] = []
1824
+ for (const entry of entries) {
1825
+ const anchor = anchors.get(entry.zoneId)
1826
+ const pill = layer.sizes.get(entry.zoneId)
1827
+ if (!(anchor && pill)) continue
1828
+ const at = place(anchor)
1829
+ if (!inside(at)) continue
1830
+ candidates.push({
1831
+ id: entry.zoneId,
1832
+ x: at[0],
1833
+ y: at[1],
1834
+ size: pill,
1835
+ area: entry.area,
1836
+ outline: {
1837
+ outer: entry.polygon.map(place),
1838
+ holes: entry.holes.map((hole) => hole.map(place)),
1839
+ },
1840
+ })
1841
+ }
1842
+ layer.place(candidates, pane)
1843
+ })
1844
+ }
1845
+ layer.settle(placed, (pointer) => {
1846
+ const inverse = ctm.inverse()
1847
+ // The plan is one plane, met at the same distance for every room: the smallest wins.
1848
+ const point: RoomHit = [
1849
+ inverse.a * pointer.x + inverse.c * pointer.y + inverse.e,
1850
+ inverse.b * pointer.x + inverse.d * pointer.y + inverse.f,
1851
+ 0,
1852
+ ]
1853
+ return roomAt(entries, () => point)
1854
+ })
1855
+ }
1856
+ raf = requestAnimationFrame(tick)
1857
+ return () => {
1858
+ cancelAnimationFrame(raf)
1859
+ nodes.onMount = null
1860
+ }
1861
+ }, [anchors, entries, layer, nodes, present])
1862
+
1863
+ return (
1864
+ <g ref={ref}>
1865
+ {present &&
1866
+ host &&
1867
+ createPortal(
1868
+ <div className="pointer-events-none absolute isolate z-0 overflow-hidden" ref={paneRef}>
1869
+ {entries
1870
+ .filter((entry) => !gone.has(entry.zoneId))
1871
+ .map((entry) => (
1872
+ <div
1873
+ className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2"
1874
+ key={entry.zoneId}
1875
+ ref={(element) => {
1876
+ if (element) nodes.set(entry.zoneId, element)
1877
+ else nodes.delete(entry.zoneId)
1878
+ }}
1879
+ style={{ display: 'none' }}
1880
+ >
1881
+ <RoomPill elements={layer.elements} entry={entry} view="2d" />
1882
+ </div>
1883
+ ))}
1884
+ <OpenRoomLabelDetail entries={entries} layer={layer} renderDetail={renderDetail2D} />
1885
+ </div>,
1886
+ host,
1887
+ )}
1888
+ </g>
1889
+ )
1890
+ }