@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,1065 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- type AnyNode,
5
- type AnyNodeId,
6
- type FloorplanGeometry,
7
- type FloorplanPalette,
8
- type GeometryContext,
9
- isNodeKindEnabled,
10
- nodeRegistry,
11
- useLocale,
12
- usePascalI18n,
13
- useScene,
14
- } from '@pascal-app/core'
15
- import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
16
- import { useViewer } from '@pascal-app/viewer'
17
- import { Maximize2, Minus, Plus } from 'lucide-react'
18
- import {
19
- type KeyboardEvent as ReactKeyboardEvent,
20
- type PointerEvent as ReactPointerEvent,
21
- type WheelEvent as ReactWheelEvent,
22
- useCallback,
23
- useEffect,
24
- useId,
25
- useLayoutEffect,
26
- useMemo,
27
- useRef,
28
- useState,
29
- } from 'react'
30
- import { createPortal } from 'react-dom'
31
- import { localizedLevelName } from '../../i18n/level-name'
32
- import {
33
- FLOORPLAN_VIEW_ROTATION_DEG,
34
- floorplanLocalToWorldPoint,
35
- worldToFloorplanLocalPoint,
36
- } from '../../lib/floorplan'
37
- import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
38
- import { buildFloorplanContext, floorplanLayerRank } from '../../lib/floorplan/floorplan-readonly'
39
- import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
40
- import useEditor, { type NavigationSyncPose } from '../../store/use-editor'
41
- import {
42
- subscribeFloorplanCameraNavigation,
43
- useFloorplanCameraSyncBridge,
44
- } from '../editor/floorplan-camera-sync'
45
- import {
46
- createFloorplanNavigationSyncScheduler,
47
- setFloorplanCompassRotation,
48
- } from '../editor/floorplan-navigation-presentation'
49
- import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
50
- import { FloorplanCompassButton } from './floorplan-compass-button'
51
- import {
52
- type FloorplanBounds,
53
- type FloorplanViewBox,
54
- getFloorplanBounds,
55
- padFloorplanBounds,
56
- panFloorplanViewBox,
57
- scaleFloorplanViewBox,
58
- scaleFloorplanViewBoxBetweenClients,
59
- } from './floorplan-preview-geometry'
60
- import {
61
- cameraAzimuthFromFloorplanRotation,
62
- floorplanRotationFromCameraAzimuth,
63
- floorplanViewBoxFromNavigationPose,
64
- nearestEquivalentDegrees,
65
- rotateFloorplanPoint,
66
- visibleFloorplanViewWidth,
67
- } from './floorplan-preview-navigation'
68
- import { isVisibleInFloorplan } from './floorplan-preview-visibility'
69
-
70
- const READ_ONLY_PALETTE: FloorplanPalette = {
71
- selectedStroke: '#4f46e5',
72
- selectedFill: '#e0e7ff',
73
- selectedHatch: '#818cf8',
74
- wallHoverStroke: '#64748b',
75
- endpointHandleFill: '#ffffff',
76
- endpointHandleStroke: '#f97316',
77
- endpointHandleHoverStroke: '#fb923c',
78
- endpointHandleActiveFill: '#fed7aa',
79
- endpointHandleActiveStroke: '#ea580c',
80
- curveHandleFill: '#ffffff',
81
- curveHandleStroke: '#0d9488',
82
- curveHandleHoverStroke: '#14b8a6',
83
- measurementStroke: '#475569',
84
- measurementLabelBackground: '#ffffff',
85
- measurementLabelText: '#0f172a',
86
- }
87
- const EMPTY_PREVIEW_NODES: Record<string, AnyNode> = {}
88
- const EMPTY_INSTALLED_PLUGINS: readonly string[] = []
89
-
90
- export type FloorplanPreviewScene = {
91
- nodes: Record<string, AnyNode>
92
- installedPlugins?: readonly string[]
93
- }
94
-
95
- export type FloorplanPreviewProps = {
96
- className?: string
97
- compassHost?: Element | null
98
- levelId?: string | null
99
- navigationVisible?: boolean
100
- onLevelChange?: (levelId: string) => void
101
- scene?: FloorplanPreviewScene | null
102
- showCompass?: boolean
103
- showLevelSelector?: boolean
104
- synchronizeNavigation?: boolean
105
- }
106
-
107
- type PointerPoint = { x: number; y: number }
108
- type DragState = {
109
- mode: 'pan' | 'rotate'
110
- pointerId: number
111
- point: PointerPoint
112
- rotationDeg: number
113
- viewBox: FloorplanViewBox
114
- }
115
- type PinchState = {
116
- pointerIds: [number, number]
117
- midpoint: PointerPoint
118
- distance: number
119
- viewBox: FloorplanViewBox
120
- }
121
- type FloorplanRenderEntry = {
122
- id: string
123
- geometry: FloorplanGeometry
124
- includeInInitialFit: boolean
125
- }
126
- type FloorplanSourceEntry = {
127
- node: AnyNode
128
- contextOverrides?: Pick<GeometryContext, 'children' | 'siblings' | 'parent'>
129
- }
130
- type MeasuredFit = { key: string; bounds: FloorplanBounds }
131
-
132
- const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
133
-
134
- function FloorplanCameraSyncMount() {
135
- useFloorplanCameraSyncBridge()
136
- return null
137
- }
138
-
139
- function boundsToViewBox(bounds: FloorplanBounds): FloorplanViewBox {
140
- return {
141
- x: bounds.minX,
142
- y: bounds.minY,
143
- width: Math.max(bounds.maxX - bounds.minX, 1),
144
- height: Math.max(bounds.maxY - bounds.minY, 1),
145
- }
146
- }
147
-
148
- function collectLevelTree(root: AnyNode, nodes: Record<string, AnyNode>): AnyNode[] {
149
- const result: AnyNode[] = [root]
150
- const seen = new Set<string>()
151
- const queue = [...((root as { children?: AnyNodeId[] }).children ?? [])]
152
- let index = 0
153
- while (index < queue.length) {
154
- const id = queue[index++]
155
- if (!id || seen.has(id)) continue
156
- seen.add(id)
157
- const node = nodes[id]
158
- if (!node) continue
159
- result.push(node)
160
- queue.push(...((node as { children?: AnyNodeId[] }).children ?? []))
161
- }
162
- return result
163
- }
164
-
165
- export function normalizeFloorplanPreviewNodes(
166
- nodes: Record<string, unknown>,
167
- ): Record<string, AnyNode> {
168
- const normalized: Record<string, AnyNode> = {}
169
- for (const [id, node] of Object.entries(nodes)) {
170
- const builtin = AnyNodeSchema.safeParse(node)
171
- if (builtin.success) {
172
- normalized[builtin.data.id] = builtin.data
173
- continue
174
- }
175
- const type =
176
- node && typeof node === 'object' && !Array.isArray(node)
177
- ? (node as { type?: unknown }).type
178
- : null
179
- const registered =
180
- typeof type === 'string' ? nodeRegistry.get(type)?.schema.safeParse(node) : null
181
- if (registered?.success) {
182
- const parsed = registered.data as AnyNode
183
- normalized[parsed.id] = parsed
184
- } else {
185
- console.warn(`[floorplan-preview] Skipping invalid node ${id}`, builtin.error.issues)
186
- }
187
- }
188
- return normalized
189
- }
190
-
191
- function buildFloorplanGeometries(
192
- nodes: Record<string, AnyNode>,
193
- installedPlugins: readonly string[] | undefined,
194
- level: AnyNode,
195
- unit: 'metric' | 'imperial',
196
- metricNotation: 'meters' | 'millimeters',
197
- ): FloorplanRenderEntry[] {
198
- const building = level.parentId ? nodes[level.parentId] : undefined
199
- const levelTree = collectLevelTree(level, nodes)
200
- const entries: FloorplanSourceEntry[] = levelTree.map((node) => ({ node }))
201
- const entryIds = new Set(entries.map((entry) => entry.node.id))
202
- if (building) {
203
- for (const candidate of Object.values(nodes)) {
204
- const definition = nodeRegistry.get(candidate.type)
205
- if (
206
- definition?.floorplanScope === 'building' &&
207
- candidate.parentId === building.id &&
208
- !entryIds.has(candidate.id)
209
- ) {
210
- entries.push({
211
- node: candidate,
212
- contextOverrides: { children: [], siblings: [], parent: level },
213
- })
214
- entryIds.add(candidate.id)
215
- }
216
- }
217
- }
218
- for (const candidate of Object.values(nodes)) {
219
- const definition = nodeRegistry.get(candidate.type)
220
- const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
221
- if (
222
- definition?.floorplan &&
223
- linkedLevelIds?.(candidate).includes(level.id) &&
224
- !entryIds.has(candidate.id)
225
- ) {
226
- const childIds = (candidate as { children?: AnyNodeId[] }).children
227
- const children = Array.isArray(childIds)
228
- ? childIds.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined)
229
- : []
230
- entries.push({
231
- node: candidate,
232
- contextOverrides: { children, siblings: [], parent: level },
233
- })
234
- entryIds.add(candidate.id)
235
- }
236
- }
237
-
238
- const renderable = entries
239
- .filter((entry) => isVisibleInFloorplan(entry.node, nodes))
240
- .filter((entry) => isNodeKindEnabled(entry.node.type, installedPlugins))
241
- .filter((entry) => nodeRegistry.get(entry.node.type)?.floorplan)
242
- .sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
243
- const byType = new Map<string, AnyNode[]>()
244
- for (const node of levelTree) {
245
- if (!isNodeKindEnabled(node.type, installedPlugins)) continue
246
- if (!nodeRegistry.get(node.type)?.computeFloorplanLevelData) continue
247
- const siblings = byType.get(node.type) ?? []
248
- siblings.push(node)
249
- byType.set(node.type, siblings)
250
- }
251
- const levelData = new Map<string, unknown>()
252
- for (const [type, siblings] of byType) {
253
- const definition = nodeRegistry.get(type)
254
- if (definition?.computeFloorplanLevelData) {
255
- levelData.set(type, definition.computeFloorplanLevelData({ siblings, nodes }))
256
- }
257
- }
258
-
259
- const geometries: FloorplanRenderEntry[] = []
260
- for (const { node, contextOverrides } of renderable) {
261
- const definition = nodeRegistry.get(node.type)
262
- if (!definition?.floorplan) continue
263
- const baseContext = buildFloorplanContext(
264
- node,
265
- nodes,
266
- {
267
- automaticDimensions: false,
268
- selected: false,
269
- unit,
270
- metricNotation,
271
- purpose: 'document',
272
- highlighted: false,
273
- hovered: false,
274
- moving: false,
275
- palette: READ_ONLY_PALETTE,
276
- },
277
- levelData.get(node.type),
278
- )
279
- const context = contextOverrides ? { ...baseContext, ...contextOverrides } : baseContext
280
- try {
281
- const geometry = definition.floorplan(node as never, context)
282
- if (geometry) {
283
- geometries.push({
284
- id: node.id,
285
- geometry,
286
- includeInInitialFit: definition.category !== 'furnish',
287
- })
288
- }
289
- } catch (error) {
290
- console.error(`[floorplan-preview] Failed to render ${node.type}:${node.id}`, error)
291
- }
292
- }
293
- return geometries
294
- }
295
-
296
- export function FloorplanPreview({
297
- className,
298
- compassHost,
299
- levelId,
300
- navigationVisible = true,
301
- onLevelChange,
302
- scene,
303
- showCompass = true,
304
- showLevelSelector = true,
305
- synchronizeNavigation = false,
306
- }: FloorplanPreviewProps) {
307
- const i18n = usePascalI18n()
308
- const locale = useLocale()
309
- const storeNodes = useScene((state) => (scene ? EMPTY_PREVIEW_NODES : state.nodes))
310
- const storeInstalledPlugins = useScene((state) =>
311
- scene ? EMPTY_INSTALLED_PLUGINS : state.installedPlugins,
312
- )
313
- const unit = useViewer((state) => state.unit)
314
- const metricNotation = useViewer((state) => state.metricNotation)
315
- const externalNodes = useMemo(
316
- () => (scene?.nodes ? normalizeFloorplanPreviewNodes(scene.nodes) : null),
317
- [scene?.nodes],
318
- )
319
- const nodes = externalNodes ?? storeNodes
320
- const installedPlugins = scene ? scene.installedPlugins : storeInstalledPlugins
321
- const levels = useMemo(
322
- () =>
323
- Object.values(nodes)
324
- .filter((node) => node.type === 'level')
325
- .sort(
326
- (a, b) => ((a as { level?: number }).level ?? 0) - ((b as { level?: number }).level ?? 0),
327
- ),
328
- [nodes],
329
- )
330
- const [internalLevelId, setInternalLevelId] = useState<string | null>(null)
331
- const activeLevelId =
332
- (levelId && levels.some((level) => level.id === levelId) ? levelId : null) ??
333
- (internalLevelId && levels.some((level) => level.id === internalLevelId)
334
- ? internalLevelId
335
- : null) ??
336
- levels[0]?.id ??
337
- null
338
- const activeLevel = activeLevelId ? nodes[activeLevelId as AnyNodeId] : undefined
339
- const activeBuilding = activeLevel?.parentId
340
- ? nodes[activeLevel.parentId as AnyNodeId]
341
- : undefined
342
- const buildingPosition = useMemo<[number, number, number]>(
343
- () => (activeBuilding?.type === 'building' ? activeBuilding.position : [0, 0, 0]),
344
- [activeBuilding],
345
- )
346
- const buildingRotationY = activeBuilding?.type === 'building' ? activeBuilding.rotation[1] : 0
347
- const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
348
- const renderEntries = useMemo(
349
- () =>
350
- activeLevel
351
- ? buildFloorplanGeometries(nodes, installedPlugins, activeLevel, unit, metricNotation)
352
- : [],
353
- [activeLevel, installedPlugins, metricNotation, nodes, unit],
354
- )
355
- const framingEntries = useMemo(() => {
356
- const structural = renderEntries.filter((entry) => entry.includeInInitialFit)
357
- return structural.length > 0 ? structural : renderEntries
358
- }, [renderEntries])
359
- const fitKey = `${activeLevelId ?? 'none'}:${framingEntries.map((entry) => entry.id).join('|')}`
360
- const geometricFitBounds = useMemo(
361
- () => getFloorplanBounds(framingEntries.map((entry) => entry.geometry)),
362
- [framingEntries],
363
- )
364
- const [measuredFit, setMeasuredFit] = useState<MeasuredFit | null>(null)
365
- const fittedViewBox = useMemo(() => {
366
- const bounds = measuredFit?.key === fitKey ? measuredFit.bounds : geometricFitBounds
367
- return bounds
368
- ? boundsToViewBox(padFloorplanBounds(bounds))
369
- : { x: -5, y: -5, width: 10, height: 10 }
370
- }, [fitKey, geometricFitBounds, measuredFit])
371
- const [viewBox, setViewBox] = useState<FloorplanViewBox>(fittedViewBox)
372
- const viewBoxRef = useRef(viewBox)
373
- const interactionRef = useRef<HTMLDivElement | null>(null)
374
- const svgRef = useRef<SVGSVGElement | null>(null)
375
- const gridRef = useRef<SVGRectElement | null>(null)
376
- const sceneRef = useRef<SVGGElement | null>(null)
377
- const compassNeedleRef = useRef<SVGSVGElement | null>(null)
378
- const fitElementRefs = useRef(new Map<string, SVGGElement>())
379
- const pointersRef = useRef(new Map<number, PointerPoint>())
380
- const dragRef = useRef<DragState | null>(null)
381
- const pinchRef = useRef<PinchState | null>(null)
382
- const [isPanning, setIsPanning] = useState(false)
383
- const [isRotating, setIsRotating] = useState(false)
384
- const [viewportSize, setViewportSize] = useState({ width: 1000, height: 1000 })
385
- const [rotationDeg, setRotationDeg] = useState(0)
386
- const rotationDegRef = useRef(rotationDeg)
387
- const latestNavigationPoseRef = useRef<NavigationSyncPose | null>(null)
388
- const gridPatternId = useId().replaceAll(':', '')
389
-
390
- const updateViewBox = useCallback(
391
- (update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox)) => {
392
- setViewBox((current) => {
393
- const next = typeof update === 'function' ? update(current) : update
394
- viewBoxRef.current = next
395
- return next
396
- })
397
- },
398
- [],
399
- )
400
-
401
- const presentViewBox = useCallback((next: FloorplanViewBox) => {
402
- viewBoxRef.current = next
403
- const value = `${next.x} ${next.y} ${next.width} ${next.height}`
404
- svgRef.current?.setAttribute('viewBox', value)
405
- const grid = gridRef.current
406
- if (grid) {
407
- grid.setAttribute('x', String(next.x))
408
- grid.setAttribute('y', String(next.y))
409
- grid.setAttribute('width', String(next.width))
410
- grid.setAttribute('height', String(next.height))
411
- }
412
- }, [])
413
-
414
- const presentRotation = useCallback(
415
- (nextRotationDeg: number) => {
416
- rotationDegRef.current = nextRotationDeg
417
- const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
418
- const sceneElement = sceneRef.current
419
- if (sceneElement) {
420
- if (sceneRotationDeg === 0) sceneElement.removeAttribute('transform')
421
- else sceneElement.setAttribute('transform', `rotate(${sceneRotationDeg})`)
422
- }
423
- setFloorplanCompassRotation(compassNeedleRef.current, nextRotationDeg)
424
- },
425
- [buildingRotationDeg],
426
- )
427
-
428
- const commitPresentation = useCallback(
429
- (nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
430
- presentViewBox(nextViewBox)
431
- presentRotation(nextRotationDeg)
432
- setViewBox(nextViewBox)
433
- setRotationDeg(nextRotationDeg)
434
- },
435
- [presentRotation, presentViewBox],
436
- )
437
-
438
- const publishNavigation = useCallback(
439
- (nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
440
- if (!synchronizeNavigation) return
441
- const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
442
- const displayedCenter = {
443
- x: nextViewBox.x + nextViewBox.width / 2,
444
- y: nextViewBox.y + nextViewBox.height / 2,
445
- }
446
- const localCenter = rotateFloorplanPoint(displayedCenter, -sceneRotationDeg)
447
- const worldCenter = floorplanLocalToWorldPoint(
448
- localCenter,
449
- buildingPosition,
450
- buildingRotationY,
451
- )
452
- useEditor.getState().publishNavigationSyncPose({
453
- source: '2d',
454
- target: [
455
- worldCenter.x,
456
- latestNavigationPoseRef.current?.target[1] ?? buildingPosition[1],
457
- worldCenter.z,
458
- ],
459
- azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
460
- viewWidth: visibleFloorplanViewWidth(nextViewBox, viewportSize),
461
- })
462
- },
463
- [buildingPosition, buildingRotationDeg, buildingRotationY, synchronizeNavigation, viewportSize],
464
- )
465
-
466
- const applyNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
467
- const commitNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
468
- const navigationSchedulerRef = useRef<ReturnType<
469
- typeof createFloorplanNavigationSyncScheduler<NavigationSyncPose>
470
- > | null>(null)
471
- if (!navigationSchedulerRef.current) {
472
- navigationSchedulerRef.current = createFloorplanNavigationSyncScheduler<NavigationSyncPose>({
473
- applyPresentation: (pose) => applyNavigationPresentationRef.current(pose),
474
- commit: (pose) => commitNavigationPresentationRef.current(pose),
475
- })
476
- }
477
-
478
- applyNavigationPresentationRef.current = (pose) => {
479
- latestNavigationPoseRef.current = pose
480
- const nextRotationDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, rotationDegRef.current)
481
- presentRotation(nextRotationDeg)
482
- if (!navigationVisible) return
483
- const localCenter = worldToFloorplanLocalPoint(
484
- pose.target[0],
485
- pose.target[2],
486
- buildingPosition,
487
- buildingRotationY,
488
- )
489
- presentViewBox(
490
- floorplanViewBoxFromNavigationPose(
491
- pose,
492
- localCenter,
493
- FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg,
494
- viewportSize,
495
- ),
496
- )
497
- }
498
-
499
- commitNavigationPresentationRef.current = (_pose) => {
500
- if (!navigationVisible) return
501
- setViewBox(viewBoxRef.current)
502
- setRotationDeg(rotationDegRef.current)
503
- }
504
-
505
- useClientLayoutEffect(() => {
506
- let bounds: FloorplanBounds | null = null
507
- for (const entry of framingEntries) {
508
- const element = fitElementRefs.current.get(entry.id)
509
- if (!element || typeof element.getBBox !== 'function') continue
510
- let box: DOMRect
511
- try {
512
- box = element.getBBox()
513
- } catch {
514
- continue
515
- }
516
- if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) continue
517
- if (box.width === 0 && box.height === 0) continue
518
- const next = {
519
- minX: box.x,
520
- minY: box.y,
521
- maxX: box.x + box.width,
522
- maxY: box.y + box.height,
523
- }
524
- bounds = bounds
525
- ? {
526
- minX: Math.min(bounds.minX, next.minX),
527
- minY: Math.min(bounds.minY, next.minY),
528
- maxX: Math.max(bounds.maxX, next.maxX),
529
- maxY: Math.max(bounds.maxY, next.maxY),
530
- }
531
- : next
532
- }
533
- if (bounds) setMeasuredFit({ key: fitKey, bounds })
534
- }, [fitKey, framingEntries])
535
-
536
- useClientLayoutEffect(() => {
537
- if (synchronizeNavigation && latestNavigationPoseRef.current) return
538
- updateViewBox(fittedViewBox)
539
- pointersRef.current.clear()
540
- dragRef.current = null
541
- pinchRef.current = null
542
- setIsPanning(false)
543
- setIsRotating(false)
544
- }, [fittedViewBox, synchronizeNavigation, updateViewBox])
545
-
546
- useEffect(() => {
547
- const svg = svgRef.current
548
- if (!svg) return
549
- const updateSize = () => {
550
- const rect = svg.getBoundingClientRect()
551
- setViewportSize({ width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) })
552
- }
553
- updateSize()
554
- if (typeof ResizeObserver === 'undefined') {
555
- window.addEventListener('resize', updateSize)
556
- return () => window.removeEventListener('resize', updateSize)
557
- }
558
- const observer = new ResizeObserver(updateSize)
559
- observer.observe(svg)
560
- return () => observer.disconnect()
561
- }, [])
562
-
563
- useClientLayoutEffect(() => {
564
- presentRotation(rotationDegRef.current)
565
- }, [presentRotation])
566
-
567
- useEffect(() => {
568
- if (!synchronizeNavigation) return
569
- const scheduler = navigationSchedulerRef.current
570
- if (!scheduler) return
571
- const receivePose = (pose: NavigationSyncPose) => {
572
- latestNavigationPoseRef.current = pose
573
- if (navigationVisible) scheduler.update(pose)
574
- else applyNavigationPresentationRef.current(pose)
575
- }
576
- const unsubscribeCamera = subscribeFloorplanCameraNavigation(receivePose)
577
- const unsubscribeStored = subscribeNavigationSyncPose((pose) => {
578
- if (pose.source === '2d' && !navigationVisible) receivePose(pose)
579
- })
580
- return () => {
581
- unsubscribeCamera()
582
- unsubscribeStored()
583
- scheduler.discard()
584
- }
585
- }, [navigationVisible, synchronizeNavigation])
586
-
587
- useEffect(() => {
588
- if (!(synchronizeNavigation && navigationVisible && latestNavigationPoseRef.current)) return
589
- navigationSchedulerRef.current?.update(latestNavigationPoseRef.current)
590
- }, [navigationVisible, synchronizeNavigation])
591
-
592
- const chooseLevel = useCallback(
593
- (nextLevelId: string) => {
594
- setInternalLevelId(nextLevelId)
595
- onLevelChange?.(nextLevelId)
596
- },
597
- [onLevelChange],
598
- )
599
-
600
- const updateLocalViewBox = useCallback(
601
- (
602
- update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox),
603
- commit = true,
604
- ) => {
605
- const current = viewBoxRef.current
606
- const next = typeof update === 'function' ? update(current) : update
607
- presentViewBox(next)
608
- if (commit) setViewBox(next)
609
- publishNavigation(next, rotationDegRef.current)
610
- return next
611
- },
612
- [presentViewBox, publishNavigation],
613
- )
614
-
615
- const zoom = useCallback(
616
- (factor: number, anchorX = 0.5, anchorY = 0.5) => {
617
- updateLocalViewBox((current) => scaleFloorplanViewBox(current, factor, anchorX, anchorY))
618
- },
619
- [updateLocalViewBox],
620
- )
621
-
622
- const onWheel = useCallback(
623
- (event: ReactWheelEvent<SVGSVGElement>) => {
624
- event.preventDefault()
625
- const rect = event.currentTarget.getBoundingClientRect()
626
- updateLocalViewBox((current) =>
627
- scaleFloorplanViewBoxBetweenClients(
628
- current,
629
- event.deltaY > 0 ? 1.12 : 0.88,
630
- rect,
631
- [event.clientX, event.clientY],
632
- [event.clientX, event.clientY],
633
- ),
634
- )
635
- },
636
- [updateLocalViewBox],
637
- )
638
-
639
- const onPointerDown = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
640
- if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== 2) return
641
- event.preventDefault()
642
- interactionRef.current?.focus()
643
- try {
644
- event.currentTarget.setPointerCapture(event.pointerId)
645
- } catch {
646
- // Pointer capture is an enhancement; the gesture still works while events stay over the SVG.
647
- }
648
- const point = { x: event.clientX, y: event.clientY }
649
- pointersRef.current.set(event.pointerId, point)
650
- const pointers = Array.from(pointersRef.current.entries())
651
- if (pointers.length === 1) {
652
- dragRef.current = {
653
- mode: event.pointerType === 'mouse' && event.button === 2 ? 'rotate' : 'pan',
654
- pointerId: event.pointerId,
655
- point,
656
- rotationDeg: rotationDegRef.current,
657
- viewBox: viewBoxRef.current,
658
- }
659
- pinchRef.current = null
660
- } else {
661
- const [first, second] = pointers
662
- if (!first || !second) return
663
- const dx = second[1].x - first[1].x
664
- const dy = second[1].y - first[1].y
665
- pinchRef.current = {
666
- pointerIds: [first[0], second[0]],
667
- midpoint: { x: (first[1].x + second[1].x) / 2, y: (first[1].y + second[1].y) / 2 },
668
- distance: Math.max(Math.hypot(dx, dy), 1),
669
- viewBox: viewBoxRef.current,
670
- }
671
- dragRef.current = null
672
- }
673
- setIsPanning(true)
674
- setIsRotating(dragRef.current?.mode === 'rotate')
675
- }, [])
676
-
677
- const onPointerMove = useCallback(
678
- (event: ReactPointerEvent<SVGSVGElement>) => {
679
- if (!pointersRef.current.has(event.pointerId)) return
680
- pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
681
- const rect = event.currentTarget.getBoundingClientRect()
682
- const pinch = pinchRef.current
683
- if (pinch) {
684
- const first = pointersRef.current.get(pinch.pointerIds[0])
685
- const second = pointersRef.current.get(pinch.pointerIds[1])
686
- if (!(first && second)) return
687
- const dx = second.x - first.x
688
- const dy = second.y - first.y
689
- const midpoint: PointerPoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
690
- updateLocalViewBox(
691
- scaleFloorplanViewBoxBetweenClients(
692
- pinch.viewBox,
693
- pinch.distance / Math.max(Math.hypot(dx, dy), 1),
694
- rect,
695
- [pinch.midpoint.x, pinch.midpoint.y],
696
- [midpoint.x, midpoint.y],
697
- ),
698
- false,
699
- )
700
- return
701
- }
702
- const drag = dragRef.current
703
- if (!drag || drag.pointerId !== event.pointerId) return
704
- if (drag.mode === 'rotate') {
705
- const nextRotationDeg = drag.rotationDeg + (event.clientX - drag.point.x) * 0.35
706
- const initialSceneRotationDeg =
707
- FLOORPLAN_VIEW_ROTATION_DEG + drag.rotationDeg - buildingRotationDeg
708
- const nextSceneRotationDeg =
709
- FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
710
- const displayedCenter = {
711
- x: drag.viewBox.x + drag.viewBox.width / 2,
712
- y: drag.viewBox.y + drag.viewBox.height / 2,
713
- }
714
- const localCenter = rotateFloorplanPoint(displayedCenter, -initialSceneRotationDeg)
715
- const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
716
- const nextViewBox = {
717
- ...drag.viewBox,
718
- x: nextCenter.x - drag.viewBox.width / 2,
719
- y: nextCenter.y - drag.viewBox.height / 2,
720
- }
721
- presentRotation(nextRotationDeg)
722
- presentViewBox(nextViewBox)
723
- publishNavigation(nextViewBox, nextRotationDeg)
724
- return
725
- }
726
- updateLocalViewBox(
727
- panFloorplanViewBox(
728
- drag.viewBox,
729
- rect,
730
- [drag.point.x, drag.point.y],
731
- [event.clientX, event.clientY],
732
- ),
733
- false,
734
- )
735
- },
736
- [buildingRotationDeg, presentRotation, presentViewBox, publishNavigation, updateLocalViewBox],
737
- )
738
-
739
- const onPointerUp = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
740
- pointersRef.current.delete(event.pointerId)
741
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
742
- event.currentTarget.releasePointerCapture(event.pointerId)
743
- }
744
- pinchRef.current = null
745
- const [remaining] = pointersRef.current.entries()
746
- if (remaining) {
747
- dragRef.current = {
748
- mode: 'pan',
749
- pointerId: remaining[0],
750
- point: remaining[1],
751
- rotationDeg: rotationDegRef.current,
752
- viewBox: viewBoxRef.current,
753
- }
754
- setIsRotating(false)
755
- } else {
756
- dragRef.current = null
757
- setIsPanning(false)
758
- setIsRotating(false)
759
- setViewBox(viewBoxRef.current)
760
- setRotationDeg(rotationDegRef.current)
761
- }
762
- }, [])
763
-
764
- const onKeyDown = useCallback(
765
- (event: ReactKeyboardEvent<HTMLDivElement>) => {
766
- const panStep = 0.08
767
- switch (event.key) {
768
- case '+':
769
- case '=':
770
- event.preventDefault()
771
- zoom(0.8)
772
- return
773
- case '-':
774
- case '_':
775
- event.preventDefault()
776
- zoom(1.2)
777
- return
778
- case '0':
779
- case 'f':
780
- case 'F':
781
- event.preventDefault()
782
- updateLocalViewBox(fittedViewBox)
783
- return
784
- case 'ArrowLeft':
785
- event.preventDefault()
786
- updateLocalViewBox((current) => ({
787
- ...current,
788
- x: current.x - current.width * panStep,
789
- }))
790
- return
791
- case 'ArrowRight':
792
- event.preventDefault()
793
- updateLocalViewBox((current) => ({
794
- ...current,
795
- x: current.x + current.width * panStep,
796
- }))
797
- return
798
- case 'ArrowUp':
799
- event.preventDefault()
800
- updateLocalViewBox((current) => ({
801
- ...current,
802
- y: current.y - current.height * panStep,
803
- }))
804
- return
805
- case 'ArrowDown':
806
- event.preventDefault()
807
- updateLocalViewBox((current) => ({
808
- ...current,
809
- y: current.y + current.height * panStep,
810
- }))
811
- }
812
- },
813
- [fittedViewBox, updateLocalViewBox, zoom],
814
- )
815
-
816
- const alignToNorth = useCallback(() => {
817
- const currentRotationDeg = rotationDegRef.current
818
- const nextRotationDeg = nearestEquivalentDegrees(0, currentRotationDeg)
819
- if (!navigationVisible) {
820
- const pose = latestNavigationPoseRef.current
821
- if (!pose) return
822
- useEditor.getState().publishNavigationSyncPose({
823
- source: '2d',
824
- target: [...pose.target],
825
- azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
826
- viewWidth: pose.viewWidth,
827
- })
828
- return
829
- }
830
-
831
- const currentViewBox = viewBoxRef.current
832
- const currentSceneRotationDeg =
833
- FLOORPLAN_VIEW_ROTATION_DEG + currentRotationDeg - buildingRotationDeg
834
- const nextSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
835
- const currentCenter = {
836
- x: currentViewBox.x + currentViewBox.width / 2,
837
- y: currentViewBox.y + currentViewBox.height / 2,
838
- }
839
- const localCenter = rotateFloorplanPoint(currentCenter, -currentSceneRotationDeg)
840
- const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
841
- const nextViewBox = {
842
- ...currentViewBox,
843
- x: nextCenter.x - currentViewBox.width / 2,
844
- y: nextCenter.y - currentViewBox.height / 2,
845
- }
846
- commitPresentation(nextViewBox, nextRotationDeg)
847
- publishNavigation(nextViewBox, nextRotationDeg)
848
- }, [buildingRotationDeg, commitPresentation, navigationVisible, publishNavigation])
849
-
850
- const screenUnitsPerPixel = Math.max(
851
- viewBox.width / Math.max(viewportSize.width, 1),
852
- viewBox.height / Math.max(viewportSize.height, 1),
853
- )
854
-
855
- const compassControl = (
856
- <FloorplanCompassButton
857
- needleRef={compassNeedleRef}
858
- northRotationDeg={rotationDeg}
859
- onAlignNorth={alignToNorth}
860
- />
861
- )
862
-
863
- if (levels.length === 0) {
864
- return (
865
- <div
866
- className={className}
867
- style={{ display: 'grid', placeItems: 'center', background: '#f8fafc', color: '#64748b' }}
868
- >
869
- {i18n._({
870
- id: 'viewer.floorplan.empty',
871
- message: 'No floor plans are available for this scene.',
872
- })}
873
- </div>
874
- )
875
- }
876
-
877
- return (
878
- <div
879
- className={className}
880
- style={{ position: 'relative', overflow: 'hidden', background: '#f8fafc' }}
881
- >
882
- {synchronizeNavigation ? <FloorplanCameraSyncMount /> : null}
883
- <div
884
- aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight + - 0 F"
885
- aria-label={i18n._({
886
- id: 'viewer.floorplan.viewLabel',
887
- message: '{floor} 2D view',
888
- values: {
889
- floor: activeLevel
890
- ? localizedLevelName(activeLevel, i18n, locale)
891
- : i18n._({ id: 'viewer.floorplan.floorPlan', message: 'Floor plan' }),
892
- },
893
- })}
894
- onKeyDown={onKeyDown}
895
- role="application"
896
- ref={interactionRef}
897
- style={{ width: '100%', height: '100%' }}
898
- // biome-ignore lint/a11y/noNoninteractiveTabindex: The plan canvas supports keyboard pan, zoom, and fit controls.
899
- tabIndex={0}
900
- >
901
- <svg
902
- aria-hidden="true"
903
- data-floorplan-preview=""
904
- onContextMenu={(event) => event.preventDefault()}
905
- onPointerCancel={onPointerUp}
906
- onPointerDown={onPointerDown}
907
- onPointerMove={onPointerMove}
908
- onPointerUp={onPointerUp}
909
- onWheel={onWheel}
910
- preserveAspectRatio="xMidYMid meet"
911
- ref={svgRef}
912
- style={{
913
- width: '100%',
914
- height: '100%',
915
- cursor: isRotating ? 'ew-resize' : isPanning ? 'grabbing' : 'grab',
916
- touchAction: 'none',
917
- }}
918
- viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
919
- >
920
- <defs>
921
- <pattern height="1" id={gridPatternId} patternUnits="userSpaceOnUse" width="1">
922
- <path
923
- d="M 1 0 L 0 0 0 1"
924
- fill="none"
925
- stroke="#cbd5e1"
926
- strokeOpacity="0.32"
927
- strokeWidth="0.012"
928
- />
929
- </pattern>
930
- </defs>
931
- <rect
932
- fill={`url(#${gridPatternId})`}
933
- height={viewBox.height}
934
- ref={gridRef}
935
- width={viewBox.width}
936
- x={viewBox.x}
937
- y={viewBox.y}
938
- />
939
- <g
940
- pointerEvents="none"
941
- ref={sceneRef}
942
- transform={
943
- FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg === 0
944
- ? undefined
945
- : `rotate(${FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg})`
946
- }
947
- >
948
- {renderEntries.map((entry) => (
949
- <g
950
- key={entry.id}
951
- ref={(element) => {
952
- if (element) fitElementRefs.current.set(entry.id, element)
953
- else fitElementRefs.current.delete(entry.id)
954
- }}
955
- >
956
- <FloorplanGeometryRenderer
957
- geometry={entry.geometry}
958
- pointerEventsOverride="none"
959
- screenUnitsPerPixel={screenUnitsPerPixel}
960
- />
961
- </g>
962
- ))}
963
- </g>
964
- </svg>
965
- </div>
966
-
967
- {showLevelSelector && levels.length > 1 ? (
968
- <label
969
- style={{
970
- position: 'absolute',
971
- bottom: 16,
972
- left: 64,
973
- display: 'grid',
974
- gap: 4,
975
- color: '#475569',
976
- fontSize: 11,
977
- fontWeight: 600,
978
- }}
979
- >
980
- {i18n._({ id: 'viewer.floorplan.floor', message: 'Floor' })}
981
- <select
982
- aria-label={i18n._({ id: 'viewer.floorplan.floor', message: 'Floor' })}
983
- onChange={(event) => chooseLevel(event.target.value)}
984
- style={{
985
- border: '1px solid rgba(148,163,184,.55)',
986
- borderRadius: 999,
987
- background: 'rgba(255,255,255,.94)',
988
- padding: '8px 30px 8px 12px',
989
- color: '#0f172a',
990
- boxShadow: '0 8px 24px rgba(15,23,42,.10)',
991
- }}
992
- value={activeLevelId ?? ''}
993
- >
994
- {levels.map((level) => (
995
- <option key={level.id} value={level.id}>
996
- {localizedLevelName(level, i18n, locale)}
997
- </option>
998
- ))}
999
- </select>
1000
- </label>
1001
- ) : null}
1002
-
1003
- {showCompass
1004
- ? compassHost
1005
- ? createPortal(compassControl, compassHost)
1006
- : compassControl
1007
- : null}
1008
-
1009
- <div
1010
- style={{
1011
- position: 'absolute',
1012
- right: 16,
1013
- bottom: 16,
1014
- display: 'flex',
1015
- gap: 4,
1016
- border: '1px solid rgba(148,163,184,.45)',
1017
- borderRadius: 999,
1018
- background: 'rgba(255,255,255,.94)',
1019
- padding: 4,
1020
- boxShadow: '0 8px 24px rgba(15,23,42,.10)',
1021
- }}
1022
- >
1023
- <button
1024
- aria-label={i18n._({ id: 'viewer.floorplan.zoomOut', message: 'Zoom out' })}
1025
- onClick={() => zoom(1.2)}
1026
- style={controlStyle}
1027
- title={i18n._({ id: 'viewer.floorplan.zoomOut', message: 'Zoom out' })}
1028
- type="button"
1029
- >
1030
- <Minus size={16} />
1031
- </button>
1032
- <button
1033
- aria-label={i18n._({ id: 'viewer.floorplan.fit', message: 'Fit floor plan' })}
1034
- onClick={() => updateLocalViewBox(fittedViewBox)}
1035
- style={controlStyle}
1036
- title={i18n._({ id: 'viewer.floorplan.fit', message: 'Fit floor plan' })}
1037
- type="button"
1038
- >
1039
- <Maximize2 size={15} />
1040
- </button>
1041
- <button
1042
- aria-label={i18n._({ id: 'viewer.floorplan.zoomIn', message: 'Zoom in' })}
1043
- onClick={() => zoom(0.8)}
1044
- style={controlStyle}
1045
- title={i18n._({ id: 'viewer.floorplan.zoomIn', message: 'Zoom in' })}
1046
- type="button"
1047
- >
1048
- <Plus size={16} />
1049
- </button>
1050
- </div>
1051
- </div>
1052
- )
1053
- }
1054
-
1055
- const controlStyle = {
1056
- display: 'grid',
1057
- width: 32,
1058
- height: 32,
1059
- placeItems: 'center',
1060
- border: 0,
1061
- borderRadius: 999,
1062
- background: 'transparent',
1063
- color: '#334155',
1064
- cursor: 'pointer',
1065
- } as const