@pascal-app/editor 1.1.0 → 1.1.2

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 (262) 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/node-icon-image.test.tsx +46 -0
  84. package/src/components/ui/node-icon-image.tsx +33 -1
  85. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  86. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  87. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  88. package/src/components/ui/panels/node-display.ts +1 -1
  89. package/src/components/ui/panels/panel-manager.tsx +1 -1
  90. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  91. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  92. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  93. package/src/components/ui/panels/room-name-options.ts +1 -5
  94. package/src/components/ui/panels/room-panel.tsx +6 -8
  95. package/src/components/ui/primitives/color-dot.tsx +2 -2
  96. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  97. package/src/components/ui/primitives/sidebar.tsx +3 -3
  98. package/src/components/ui/right-stack.tsx +1 -1
  99. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  100. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  107. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  109. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  110. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  112. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  113. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  114. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  115. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  116. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  117. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  118. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  119. package/src/components/viewer-overlay.tsx +1 -2
  120. package/src/hooks/use-auto-frame.ts +1 -2
  121. package/src/hooks/use-keyboard.ts +1 -2
  122. package/src/hooks/use-selected-room.ts +5 -7
  123. package/src/i18n/floor-footprint-name.ts +1 -1
  124. package/src/i18n/locales/en/messages.po +180 -182
  125. package/src/i18n/locales/es/messages.po +180 -182
  126. package/src/i18n/locales/fr/messages.po +180 -182
  127. package/src/i18n/locales/ja/messages.po +180 -182
  128. package/src/i18n/locales/zh-CN/messages.po +180 -182
  129. package/src/i18n/locales.test.ts +1 -1
  130. package/src/index.tsx +109 -131
  131. package/src/lib/below-grade.test.tsx +4 -4
  132. package/src/lib/camera-facing.test.ts +1 -1
  133. package/src/lib/camera-facing.ts +2 -3
  134. package/src/lib/editor-level-display.ts +1 -1
  135. package/src/lib/floor-footprints.test.ts +2 -2
  136. package/src/lib/floor-opening-draft.ts +1 -1
  137. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  138. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  139. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  140. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  141. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  142. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  144. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  145. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  146. package/src/lib/floorplan/geometry.ts +2 -112
  147. package/src/lib/floorplan/index.ts +0 -9
  148. package/src/lib/floorplan/items.ts +2 -1
  149. package/src/lib/floorplan/schedules.ts +6 -3
  150. package/src/lib/floorplan/sheet-export.ts +7 -3
  151. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  152. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  153. package/src/lib/follow-pascal-presentation.ts +1 -1
  154. package/src/lib/glb-export.ts +1 -1
  155. package/src/lib/material-paint.ts +1 -1
  156. package/src/lib/measurements.test.ts +2 -1
  157. package/src/lib/mezzanine-selection.test.ts +1 -1
  158. package/src/lib/paint-region-mode.ts +1 -1
  159. package/src/lib/point-ask/session.ts +1 -2
  160. package/src/lib/roof-duplication.test.ts +100 -0
  161. package/src/lib/roof-duplication.ts +30 -5
  162. package/src/lib/room-handle-drag.ts +2 -12
  163. package/src/lib/room-name-catalog.test.ts +2 -1
  164. package/src/lib/room-name-messages.test.ts +2 -1
  165. package/src/lib/room-name-messages.ts +1 -118
  166. package/src/lib/room-name-storage.test.ts +2 -1
  167. package/src/lib/room-selection-commands.ts +2 -2
  168. package/src/lib/room-selection.test.ts +6 -6
  169. package/src/lib/room-transform-session.ts +1 -2
  170. package/src/lib/room-zone-routing.ts +2 -2
  171. package/src/lib/selection-routing.test.ts +0 -28
  172. package/src/lib/selection-routing.ts +1 -40
  173. package/src/lib/sfx-bus.ts +8 -32
  174. package/src/lib/sfx-player.test.ts +72 -1
  175. package/src/lib/sfx-player.ts +49 -337
  176. package/src/lib/viewer-selection.test.ts +38 -10
  177. package/src/lib/wall-pull.test.ts +1 -1
  178. package/src/lib/wall-pull.ts +1 -27
  179. package/src/store/use-editor.tsx +17 -26
  180. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  181. package/src/store/use-wall-region-handles-selection.ts +1 -2
  182. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  183. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  184. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  185. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  186. package/src/components/editor/first-person/ground-holes.ts +0 -130
  187. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  188. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  189. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  190. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  191. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  192. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  193. package/src/components/editor/localized-html.tsx +0 -22
  194. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  195. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  196. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  197. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  198. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  199. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  200. package/src/components/editor-2d/svg-paths.ts +0 -124
  201. package/src/components/ui/action-menu/action-button.tsx +0 -89
  202. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  203. package/src/components/ui/primitives/button.tsx +0 -69
  204. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  205. package/src/components/ui/primitives/popover.tsx +0 -42
  206. package/src/components/ui/primitives/switch.tsx +0 -29
  207. package/src/components/ui/primitives/tooltip.tsx +0 -57
  208. package/src/components/ui/scene-loader.tsx +0 -73
  209. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  210. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  211. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  212. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  213. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  214. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  215. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  216. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  217. package/src/components/viewer/render-settings-menu.tsx +0 -294
  218. package/src/components/viewer/sky-control.tsx +0 -710
  219. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  220. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  221. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  222. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  223. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  224. package/src/components/viewer/viewer-rooms.tsx +0 -248
  225. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  226. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  227. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  228. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  229. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  230. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  231. package/src/components/viewer/viewer-stage.tsx +0 -221
  232. package/src/components/walkthrough-hud.tsx +0 -193
  233. package/src/hooks/use-mobile.ts +0 -19
  234. package/src/i18n/level-name.ts +0 -39
  235. package/src/i18n/scene-theme-name.ts +0 -16
  236. package/src/lib/below-grade.ts +0 -133
  237. package/src/lib/camera-pose.test.ts +0 -237
  238. package/src/lib/camera-pose.ts +0 -172
  239. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  240. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  241. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  242. package/src/lib/floorplan/marks.test.ts +0 -213
  243. package/src/lib/floorplan/marks.ts +0 -238
  244. package/src/lib/measurements.ts +0 -8
  245. package/src/lib/node-display-label.ts +0 -37
  246. package/src/lib/room-assembly-overlay.test.ts +0 -275
  247. package/src/lib/room-assembly-overlay.ts +0 -265
  248. package/src/lib/room-selection.ts +0 -417
  249. package/src/lib/scene-bounds.test.ts +0 -327
  250. package/src/lib/scene-bounds.ts +0 -233
  251. package/src/lib/typing-target.ts +0 -9
  252. package/src/lib/viewer-selection.ts +0 -595
  253. package/src/lib/zone-content.ts +0 -133
  254. package/src/store/camera-pose-store.test.ts +0 -29
  255. package/src/store/camera-pose-store.ts +0 -27
  256. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  257. package/src/store/navigation-sync-pose-store.ts +0 -27
  258. package/src/store/use-audio.test.ts +0 -146
  259. package/src/store/use-audio.tsx +0 -169
  260. package/src/store/use-first-person-hud.ts +0 -40
  261. package/src/store/use-follow-pascal.test.ts +0 -62
  262. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,595 +0,0 @@
1
- import {
2
- type AnyNode,
3
- area,
4
- type BuildingNode,
5
- buildUnitReport,
6
- type CameraControlFrameEvent,
7
- containsPoint,
8
- DEFAULT_LEVEL_HEIGHT,
9
- emitter,
10
- getLevelDisplayName,
11
- type LevelNode,
12
- type NodeEvent,
13
- resolveSelectionProxyId,
14
- roomFloorElevation,
15
- sceneRegistry,
16
- type UnitNode,
17
- type ZoneNode,
18
- } from '@pascal-app/core'
19
- import { useViewer } from '@pascal-app/viewer'
20
- import type { CameraControlsImpl } from '@react-three/drei'
21
- import { useEffect } from 'react'
22
- import {
23
- Box3,
24
- type Camera,
25
- type Object3D,
26
- type OrthographicCamera,
27
- type PerspectiveCamera,
28
- Sphere,
29
- Vector3,
30
- } from 'three'
31
- import { useBelowGrade } from './below-grade'
32
- import { nodeDisplayLabel } from './node-display-label'
33
- import { type DescriptorTranslator, displayRoomName } from './room-name-messages'
34
- import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
35
- import type { SelectionModifierKeys } from './selection-routing'
36
-
37
- // One selection model for every viewer surface: the published viewer (baked
38
- // GLB or parametric), its embed and the editor's Preview. Each renderer only
39
- // turns a pointer hit into a `ViewerPick`; these rules decide what it selects,
40
- // on `useViewer.selection`:
41
- // levelId the current floor
42
- // zoneId the selected room (or zone)
43
- // selectedIds the selected element or item
44
-
45
- export type ViewerNodes = Readonly<Record<string, AnyNode>>
46
-
47
- /** The scene a viewer surface shows: its graph, and the 3D object standing for an id. */
48
- export type ViewerSource = {
49
- nodes: ViewerNodes
50
- objectFor: (id: string) => Object3D | undefined
51
- }
52
-
53
- /** A pointer hit, whichever renderer took it: the node and the hit in its level's local XZ. */
54
- export type ViewerPick = { nodeId: string; point: readonly [number, number] | null }
55
-
56
- export type ViewerSelection = {
57
- buildingId: BuildingNode['id'] | null
58
- levelId: LevelNode['id'] | null
59
- zoneId: ZoneNode['id'] | null
60
- selectedIds: string[]
61
- }
62
-
63
- type ViewerTarget =
64
- | { kind: 'room'; levelId: string; zoneId: string }
65
- | { kind: 'node'; levelId: string | null; nodeId: string; zoneId: string | null }
66
-
67
- const NO_MODIFIERS: SelectionModifierKeys = { alt: false, ctrl: false, meta: false, shift: false }
68
-
69
- /** Surfaces a room is picked through, room first: its walls, its floor, its ceiling. */
70
- const ROOM_SURFACES = new Set(['wall', 'slab', 'ceiling'])
71
-
72
- /** Kinds a viewer click never lands on: they pass the pointer to what lies behind. */
73
- export const VIEWER_PASS_THROUGH_KINDS: ReadonlySet<string> = new Set([
74
- 'site',
75
- 'building',
76
- 'level',
77
- 'zone',
78
- 'ceiling',
79
- 'scan',
80
- 'guide',
81
- 'spawn',
82
- ])
83
-
84
- const indexes = new WeakMap<ViewerNodes, Map<string, RoomSelectionIndex>>()
85
-
86
- function roomIndex(levelId: string, nodes: ViewerNodes) {
87
- let levels = indexes.get(nodes)
88
- if (!levels) {
89
- levels = new Map()
90
- indexes.set(nodes, levels)
91
- }
92
- let index = levels.get(levelId)
93
- if (!index) {
94
- index = new RoomSelectionIndex(levelId)
95
- levels.set(levelId, index)
96
- }
97
- index.update(nodes as Record<string, AnyNode>)
98
- return index
99
- }
100
-
101
- /** The rooms the walls of a level close, as the editor detects them. */
102
- export function viewerRooms(levelId: string, nodes: ViewerNodes): RoomSelectionRecord[] {
103
- return roomIndex(levelId, nodes).update(nodes as Record<string, AnyNode>)
104
- }
105
-
106
- export function levelOfNode(nodeId: string, nodes: ViewerNodes): string | null {
107
- const seen = new Set<string>()
108
- let current = nodes[nodeId]
109
- while (current && !seen.has(current.id)) {
110
- if (current.type === 'level') return current.id
111
- seen.add(current.id)
112
- current = current.parentId ? nodes[current.parentId] : undefined
113
- }
114
- return null
115
- }
116
-
117
- function buildingOfLevel(levelId: string | null, nodes: ViewerNodes): BuildingNode['id'] | null {
118
- const level = levelId ? nodes[levelId] : undefined
119
- const parent = level?.parentId ? nodes[level.parentId] : undefined
120
- if (parent?.type === 'building') return parent.id
121
- const building = Object.values(nodes).find((node) => node.type === 'building')
122
- return (building?.id as BuildingNode['id'] | undefined) ?? null
123
- }
124
-
125
- const hitPoint = new Vector3()
126
-
127
- /**
128
- * The parametric hit source: a node event as a pick, with the hit in its
129
- * level's local XZ. A hit on a hidden floor, or on a kind a click passes
130
- * through, is no pick: the pointer goes on to what lies behind it.
131
- */
132
- export function viewerPickFromNodeEvent(event: NodeEvent, nodes: ViewerNodes): ViewerPick | null {
133
- if (VIEWER_PASS_THROUGH_KINDS.has(event.node.type)) return null
134
- const levelId = levelOfNode(event.node.id, nodes)
135
- const level = levelId ? sceneRegistry.nodes.get(levelId) : undefined
136
- if (level?.visible === false) return null
137
- if (!level) return { nodeId: event.node.id, point: null }
138
- level.updateWorldMatrix(true, false)
139
- hitPoint.set(...event.position)
140
- level.worldToLocal(hitPoint)
141
- return { nodeId: event.node.id, point: [hitPoint.x, hitPoint.z] }
142
- }
143
-
144
- /**
145
- * What a pick acts on. A wall, floor or ceiling picks the room it bounds;
146
- * once that room is selected, its own surfaces are picked as themselves.
147
- * Anything else (an item, a door, a stair) is picked directly. Alt, Shift,
148
- * Ctrl and Meta skip the room, as in the editor.
149
- */
150
- export function resolveViewerTarget(
151
- selection: Pick<ViewerSelection, 'zoneId'>,
152
- pick: ViewerPick,
153
- nodes: ViewerNodes,
154
- modifiers: SelectionModifierKeys = NO_MODIFIERS,
155
- ): ViewerTarget | null {
156
- const node = nodes[pick.nodeId]
157
- if (!node || VIEWER_PASS_THROUGH_KINDS.has(node.type)) return null
158
- const levelId = levelOfNode(node.id, nodes)
159
- const bypass = modifiers.alt || modifiers.shift || modifiers.ctrl || modifiers.meta
160
- if (!bypass && levelId && pick.point && ROOM_SURFACES.has(node.type)) {
161
- const room = resolveRoomHit(
162
- roomIndex(levelId, nodes),
163
- levelId,
164
- node,
165
- [pick.point[0], pick.point[1]],
166
- '3d',
167
- )
168
- if (room) {
169
- const { zoneId } = room.key
170
- return selection.zoneId === zoneId
171
- ? { kind: 'node', levelId, nodeId: node.id, zoneId }
172
- : { kind: 'room', levelId, zoneId }
173
- }
174
- }
175
- const parent = node.parentId ? nodes[node.parentId] : undefined
176
- const owner = node.type === 'roof-segment' && parent?.type === 'roof' ? parent : node
177
- const nodeId = resolveSelectionProxyId(owner, nodes as Record<string, AnyNode | undefined>)
178
- return { kind: 'node', levelId, nodeId, zoneId: null }
179
- }
180
-
181
- /**
182
- * The selection a click makes. Empty space clears the room and the element
183
- * and keeps the floor; nothing pops the user up a level.
184
- */
185
- export function resolveViewerClick(
186
- selection: ViewerSelection,
187
- pick: ViewerPick | null,
188
- nodes: ViewerNodes,
189
- modifiers: SelectionModifierKeys = NO_MODIFIERS,
190
- ): ViewerSelection {
191
- const target = pick ? resolveViewerTarget(selection, pick, nodes, modifiers) : null
192
- if (!target) return { ...selection, zoneId: null, selectedIds: [] }
193
- const levelId = (target.levelId as LevelNode['id'] | null) ?? selection.levelId
194
- const buildingId = buildingOfLevel(levelId, nodes) ?? selection.buildingId
195
- if (target.kind === 'room') {
196
- return { buildingId, levelId, zoneId: target.zoneId as ZoneNode['id'], selectedIds: [] }
197
- }
198
- const toggles = modifiers.shift || modifiers.ctrl || modifiers.meta
199
- const sameFloor = levelId === selection.levelId
200
- const selectedIds =
201
- toggles && sameFloor
202
- ? selection.selectedIds.includes(target.nodeId)
203
- ? selection.selectedIds.filter((id) => id !== target.nodeId)
204
- : [...selection.selectedIds, target.nodeId]
205
- : [target.nodeId]
206
- return {
207
- buildingId,
208
- levelId,
209
- zoneId: target.zoneId as ZoneNode['id'] | null,
210
- selectedIds,
211
- }
212
- }
213
-
214
- /** What a hover would select: a room's id (its highlight) or the element's (its outline). */
215
- export function resolveViewerHover(
216
- selection: Pick<ViewerSelection, 'zoneId'>,
217
- pick: ViewerPick | null,
218
- nodes: ViewerNodes,
219
- ): string | null {
220
- const target = pick ? resolveViewerTarget(selection, pick, nodes) : null
221
- if (!target) return null
222
- return target.kind === 'room' ? target.zoneId : target.nodeId
223
- }
224
-
225
- /**
226
- * The current floor reads as the editor shows it: the levels above it step
227
- * aside (in the stacked display) so its rooms are in view and in reach, and the
228
- * ground is veiled over it when it lies below grade. Mount it in the surface's DOM tree,
229
- * beside the editor's own level display, so one hands the flag to the other in
230
- * order.
231
- */
232
- export function useViewerFloorDisplay() {
233
- // A walkthrough tours the whole building.
234
- const walking = useViewer((s) => s.walkthroughMode)
235
- useBelowGrade(!walking)
236
- useEffect(() => {
237
- if (walking) return
238
- useViewer.setState({ hideLevelsAboveSelection: true })
239
- return () => {
240
- useViewer.setState({ hideLevelsAboveSelection: false })
241
- }
242
- }, [walking])
243
- }
244
-
245
- // ── Applying it to the shared store ──────────────────────────────────────────
246
-
247
- function writeSelection(selection: ViewerSelection) {
248
- const viewer = useViewer.getState()
249
- viewer.setSelection(selection)
250
- if (viewer.focusedUnitId) viewer.setFocusedUnit(null)
251
- viewer.setHoveredId(null)
252
- }
253
-
254
- export function applyViewerClick(
255
- pick: ViewerPick | null,
256
- nodes: ViewerNodes,
257
- modifiers: SelectionModifierKeys = NO_MODIFIERS,
258
- ) {
259
- writeSelection(resolveViewerClick(useViewer.getState().selection, pick, nodes, modifiers))
260
- }
261
-
262
- export function applyViewerHover(pick: ViewerPick | null, nodes: ViewerNodes) {
263
- const viewer = useViewer.getState()
264
- const hovered = resolveViewerHover(viewer.selection, pick, nodes)
265
- viewer.setHoveredId(hovered as never)
266
- }
267
-
268
- /** A floor from the floor list: the floor alone, framed. */
269
- export function selectViewerLevel(levelId: string, source: ViewerSource) {
270
- writeSelection({
271
- buildingId: buildingOfLevel(levelId, source.nodes),
272
- levelId: levelId as LevelNode['id'],
273
- zoneId: null,
274
- selectedIds: [],
275
- })
276
- frameViewerNode(levelId, source)
277
- }
278
-
279
- /** The whole building again, framed. */
280
- export function selectViewerBuilding(source: ViewerSource) {
281
- const buildingId = buildingOfLevel(null, source.nodes)
282
- writeSelection({ buildingId, levelId: null, zoneId: null, selectedIds: [] })
283
- if (buildingId) frameViewerNode(buildingId, source)
284
- }
285
-
286
- /** A room or zone from its pill, the bar or the sidebar: selected on its floor, framed. */
287
- export function selectViewerRoom(zoneId: string, source: ViewerSource) {
288
- const zone = source.nodes[zoneId]
289
- if (zone?.type !== 'zone' || !zone.parentId) return
290
- const levelId = zone.parentId as LevelNode['id']
291
- writeSelection({
292
- buildingId: buildingOfLevel(levelId, source.nodes),
293
- levelId,
294
- zoneId: zone.id,
295
- selectedIds: [],
296
- })
297
- frameViewerNode(zone.id, source)
298
- }
299
-
300
- /**
301
- * A unit from the sidebar: its rooms highlighted and framed. The floor stays
302
- * when it holds one of the unit's rooms, else the unit's lowest floor shows.
303
- */
304
- export function selectViewerUnit(unitId: string, source: ViewerSource) {
305
- const unit = source.nodes[unitId]
306
- if (unit?.type !== 'unit') return
307
- const viewer = useViewer.getState()
308
- const levels = unitLevels(unit, source.nodes)
309
- const current = viewer.selection.levelId
310
- const levelId = (current && levels.includes(current) ? current : (levels[0] ?? current)) as
311
- | LevelNode['id']
312
- | null
313
- viewer.setSelection({
314
- buildingId: buildingOfLevel(levelId, source.nodes),
315
- levelId,
316
- zoneId: null,
317
- selectedIds: [],
318
- })
319
- viewer.setHoveredId(null)
320
- viewer.setFocusedUnit(unit.id)
321
- frameViewerNode(unit.id, source)
322
- }
323
-
324
- function unitLevels(unit: UnitNode, nodes: ViewerNodes): string[] {
325
- const ordinals = new Map<string, number>()
326
- for (const member of buildUnitReport(unit, nodes).members) {
327
- if (member.levelId && member.levelOrdinal !== null)
328
- ordinals.set(member.levelId, member.levelOrdinal)
329
- }
330
- return [...ordinals.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id)
331
- }
332
-
333
- // ── Framing ──────────────────────────────────────────────────────────────────
334
-
335
- const corner = new Vector3()
336
- const box = new Box3()
337
-
338
- function expandByZone(target: Box3, zone: ZoneNode, source: ViewerSource) {
339
- const level = zone.parentId ? source.objectFor(zone.parentId) : undefined
340
- level?.updateWorldMatrix(true, false)
341
- const floorY = roomFloorElevation(source.nodes as Record<string, AnyNode>, zone.id)
342
- for (const [x, z] of zone.polygon) {
343
- for (const y of [floorY, floorY + DEFAULT_LEVEL_HEIGHT]) {
344
- corner.set(x, y, z)
345
- if (level) corner.applyMatrix4(level.matrixWorld)
346
- target.expandByPoint(corner)
347
- }
348
- }
349
- }
350
-
351
- /** The frame request for a node: the box it spans, and its saved view when it has one. */
352
- function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameEvent | null {
353
- const node = source.nodes[id]
354
- if (!node) return null
355
- const saved = node.camera
356
- const pose =
357
- saved?.position && saved.target ? { position: saved.position, target: saved.target } : null
358
- box.makeEmpty()
359
- if (node.type === 'zone') expandByZone(box, node, source)
360
- else if (node.type === 'unit') {
361
- for (const memberId of node.members) {
362
- const member = source.nodes[memberId]
363
- if (member?.type === 'zone') expandByZone(box, member, source)
364
- }
365
- } else {
366
- const object = source.objectFor(id)
367
- if (object) box.setFromObject(object)
368
- }
369
- const bounds = box.isEmpty() ? null : { min: box.min.toArray(), max: box.max.toArray() }
370
- if (pose) return { bounds, pose }
371
- return bounds ? { bounds } : null
372
- }
373
-
374
- /** Framing glides to its pose, or jumps there when the person asks for reduced motion. */
375
- export function framingAnimates(): boolean {
376
- return !(
377
- typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
378
- )
379
- }
380
-
381
- export function frameViewerNode(id: string, source: ViewerSource) {
382
- const event = viewerFrameEvent(id, source)
383
- if (event) emitter.emit('camera-controls:frame', event)
384
- }
385
-
386
- type Vec3 = [number, number, number]
387
-
388
- /**
389
- * Where a camera frames a box: about 45° above it, far enough for the box to
390
- * fill the view, along the heading the camera already has so the scene does
391
- * not swing round.
392
- */
393
- export function framedLookAt(
394
- event: CameraControlFrameEvent,
395
- from: { position: Vec3; target: Vec3 },
396
- fovDegrees = 50,
397
- ): { position: Vec3; target: Vec3 } | null {
398
- if (event.pose) return event.pose
399
- if (!event.bounds) return null
400
- const { min, max } = event.bounds
401
- const center: Vec3 = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]
402
- const radius = Math.max(Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2, 1)
403
- const halfFov = (Math.min(Math.max(fovDegrees, 10), 120) * Math.PI) / 360
404
- const distance = Math.max((radius / Math.sin(halfFov)) * 1.6, 6)
405
- const dx = from.position[0] - from.target[0]
406
- const dz = from.position[2] - from.target[2]
407
- const heading = Math.hypot(dx, dz) > 1e-6 ? Math.atan2(dx, dz) : Math.PI / 4
408
- const elevation = Math.PI / 4
409
- const flat = Math.cos(elevation) * distance
410
- return {
411
- position: [
412
- center[0] + Math.sin(heading) * flat,
413
- center[1] + Math.sin(elevation) * distance,
414
- center[2] + Math.cos(heading) * flat,
415
- ],
416
- target: center,
417
- }
418
- }
419
-
420
- const framePosition = new Vector3()
421
- const frameTarget = new Vector3()
422
- const frameSphere = new Sphere()
423
-
424
- /** Apply the shared framing pose, fitting the orthographic viewport as well. */
425
- export function frameViewerCamera(
426
- control: CameraControlsImpl,
427
- camera: Camera,
428
- event: CameraControlFrameEvent,
429
- ) {
430
- control.getPosition(framePosition)
431
- control.getTarget(frameTarget)
432
- const pose = framedLookAt(
433
- event,
434
- { position: framePosition.toArray(), target: frameTarget.toArray() },
435
- (camera as PerspectiveCamera).isPerspectiveCamera
436
- ? (camera as PerspectiveCamera).fov
437
- : undefined,
438
- )
439
- if (!pose) return
440
- const animate = framingAnimates()
441
- control.setLookAt(...pose.position, ...pose.target, animate)
442
- if ((camera as OrthographicCamera).isOrthographicCamera && event.bounds && !event.pose) {
443
- box.min.set(...event.bounds.min)
444
- box.max.set(...event.bounds.max)
445
- box.getBoundingSphere(frameSphere)
446
- frameSphere.radius = Math.max(frameSphere.radius * 1.3, 1)
447
- control.fitToSphere(frameSphere, animate)
448
- }
449
- }
450
-
451
- // ── Breadcrumb ───────────────────────────────────────────────────────────────
452
-
453
- export type ViewerCrumb = {
454
- kind: 'building' | 'level' | 'unit' | 'room' | 'node'
455
- id: string
456
- label: string
457
- }
458
-
459
- /**
460
- * The viewer chrome's name for a zone, in the editor's UI language (the scene
461
- * keeps its stored name). `i18n` is the caller's scoped `usePascalI18n()`.
462
- */
463
- export function zoneLabel(zone: ZoneNode, i18n: DescriptorTranslator): string {
464
- const name = zone.name?.trim()
465
- if (name) return displayRoomName(name, i18n)
466
- return zone.spaceRole === 'room'
467
- ? i18n._({ id: 'editor.roomLabel.room', message: 'Room' })
468
- : i18n._({ id: 'editor.roomLabel.zone', message: 'Zone' })
469
- }
470
-
471
- /**
472
- * The bar: building › floor › room › element. An element inside a room shows
473
- * that room (`roomOf`) without selecting it; a focused unit stands where the
474
- * room would.
475
- */
476
- export function viewerBreadcrumb(
477
- selection: ViewerSelection,
478
- focusedUnitId: string | null,
479
- nodes: ViewerNodes,
480
- i18n: DescriptorTranslator,
481
- roomOf: (nodeId: string, levelId: string) => string | null = () => null,
482
- ): ViewerCrumb[] {
483
- const crumbs: ViewerCrumb[] = []
484
- const elementId = selection.selectedIds[0] ?? null
485
- const element = elementId ? nodes[elementId] : undefined
486
- const levelId = selection.levelId ?? (element ? levelOfNode(element.id, nodes) : null)
487
- const buildingId = selection.buildingId ?? buildingOfLevel(levelId, nodes)
488
- const building = buildingId ? nodes[buildingId] : undefined
489
- if (building?.type === 'building') {
490
- crumbs.push({ kind: 'building', id: building.id, label: building.name || 'Building' })
491
- }
492
- const level = levelId ? nodes[levelId] : undefined
493
- if (level?.type !== 'level') return crumbs
494
- crumbs.push({ kind: 'level', id: level.id, label: getLevelDisplayName(level) })
495
- const unit = focusedUnitId ? nodes[focusedUnitId] : undefined
496
- if (unit?.type === 'unit') crumbs.push({ kind: 'unit', id: unit.id, label: unit.name })
497
- const roomId = selection.zoneId ?? (element ? roomOf(element.id, level.id) : null)
498
- const room = roomId ? nodes[roomId] : undefined
499
- if (room?.type === 'zone')
500
- crumbs.push({ kind: 'room', id: room.id, label: zoneLabel(room, i18n) })
501
- if (element) crumbs.push({ kind: 'node', id: element.id, label: nodeDisplayLabel(element) })
502
- return crumbs
503
- }
504
-
505
- /** The room or zone of a level around a level-local point: a detected room first. */
506
- function roomAtLevelPoint(
507
- levelId: string,
508
- point: readonly [number, number],
509
- nodes: ViewerNodes,
510
- ): string | null {
511
- const at: [number, number] = [point[0], point[1]]
512
- const room = roomIndex(levelId, nodes).roomAtPoint(levelId, at)
513
- if (room) return room.key.zoneId
514
- for (const node of Object.values(nodes)) {
515
- if (node.type !== 'zone' || node.parentId !== levelId || node.polygon.length < 3) continue
516
- if (containsPoint([{ outer: node.polygon, holes: node.holes ?? [] }], at)) return node.id
517
- }
518
- return null
519
- }
520
-
521
- const center = new Vector3()
522
-
523
- /** The room an element stands in, from where its object sits on its floor. */
524
- export function roomOfObject(nodeId: string, levelId: string, source: ViewerSource) {
525
- const object = source.objectFor(nodeId)
526
- const level = source.objectFor(levelId)
527
- if (!(object && level)) return null
528
- box.setFromObject(object)
529
- if (box.isEmpty()) object.getWorldPosition(center)
530
- else box.getCenter(center)
531
- level.updateWorldMatrix(true, false)
532
- level.worldToLocal(center)
533
- return roomAtLevelPoint(levelId, [center.x, center.z], source.nodes)
534
- }
535
-
536
- // ── Sidebar ──────────────────────────────────────────────────────────────────
537
-
538
- export type ViewerSidebarRow = { id: string; label: string; color: string; areaM2: number }
539
- export type ViewerSidebarSection = { kind: 'units' | 'rooms' | 'zones'; rows: ViewerSidebarRow[] }
540
-
541
- /**
542
- * What sits under the current floor: its units, its rooms, its other zones.
543
- * An empty section is left out.
544
- */
545
- export function viewerSidebarSections(
546
- levelId: string,
547
- nodes: ViewerNodes,
548
- i18n: DescriptorTranslator,
549
- ): ViewerSidebarSection[] {
550
- const zones = Object.values(nodes).filter(
551
- (node): node is ZoneNode =>
552
- node.type === 'zone' && node.parentId === levelId && node.visible !== false,
553
- )
554
- const zoneRow = (zone: ZoneNode): ViewerSidebarRow => ({
555
- id: zone.id,
556
- label: zoneLabel(zone, i18n),
557
- color: zone.color,
558
- areaM2: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
559
- })
560
- const byName = (a: ViewerSidebarRow, b: ViewerSidebarRow) => a.label.localeCompare(b.label)
561
- const units = Object.values(nodes)
562
- .filter((node): node is UnitNode => node.type === 'unit')
563
- .flatMap((unit) => {
564
- const report = buildUnitReport(unit, nodes)
565
- const here = report.members.filter((member) => member.levelId === levelId)
566
- if (here.length === 0) return []
567
- return [
568
- {
569
- id: unit.id,
570
- label: unit.name,
571
- color: unit.color,
572
- areaM2: here.reduce((sum, member) => sum + member.areaM2, 0),
573
- },
574
- ]
575
- })
576
- .sort(byName)
577
- const sections: ViewerSidebarSection[] = [
578
- { kind: 'units', rows: units },
579
- {
580
- kind: 'rooms',
581
- rows: zones
582
- .filter((zone) => zone.spaceRole === 'room')
583
- .map(zoneRow)
584
- .sort(byName),
585
- },
586
- {
587
- kind: 'zones',
588
- rows: zones
589
- .filter((zone) => zone.spaceRole !== 'room')
590
- .map(zoneRow)
591
- .sort(byName),
592
- },
593
- ]
594
- return sections.filter((section) => section.rows.length > 0)
595
- }