@pascal-app/editor 1.1.0 → 1.1.1

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