@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
@@ -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
- }