@pascal-app/editor 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +9 -15
  2. package/src/components/editor/action-menu-button.tsx +1 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
  4. package/src/components/editor/custom-camera-controls.tsx +11 -11
  5. package/src/components/editor/editor-layout-v2.tsx +1 -1
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +20 -1981
  8. package/src/components/editor/floating-action-menu.tsx +1 -1
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floor-region-controls.tsx +6 -2
  11. package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
  12. package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
  13. package/src/components/editor/floorplan-panel.tsx +26 -22
  14. package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
  15. package/src/components/editor/follow-pascal.test.tsx +1 -2
  16. package/src/components/editor/follow-pascal.tsx +1 -1
  17. package/src/components/editor/footprint-height-handle.tsx +1 -1
  18. package/src/components/editor/group-actions.ts +1 -2
  19. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  20. package/src/components/editor/group-move-3d.ts +1 -2
  21. package/src/components/editor/group-rotate-handle.tsx +1 -2
  22. package/src/components/editor/handles/use-handle-drag.ts +1 -2
  23. package/src/components/editor/index.tsx +24 -14
  24. package/src/components/editor/inspector-extension-actions.tsx +1 -1
  25. package/src/components/editor/node-arrow-handles.tsx +1 -1
  26. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  27. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  28. package/src/components/editor/room-controls.tsx +7 -4
  29. package/src/components/editor/room-handles-2d.tsx +2 -3
  30. package/src/components/editor/room-handles.tsx +1 -2
  31. package/src/components/editor/room-highlight.tsx +6 -105
  32. package/src/components/editor/room-labels.test.ts +8 -822
  33. package/src/components/editor/room-labels.tsx +40 -1671
  34. package/src/components/editor/room-webgpu-materials.test.ts +1 -1
  35. package/src/components/editor/selection-manager.tsx +5 -3
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
  38. package/src/components/editor/use-floorplan-background-placement.ts +2 -1
  39. package/src/components/editor/views/plugins-view.tsx +1 -1
  40. package/src/components/editor/views/view-bar.tsx +9 -9
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +1 -1
  43. package/src/components/editor/wall-pull-grip.tsx +2 -2
  44. package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
  45. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
  46. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
  47. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
  48. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
  49. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
  50. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
  51. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
  52. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
  53. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
  54. package/src/components/systems/roof/roof-edit-system.tsx +4 -3
  55. package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
  56. package/src/components/tools/item/placement-history.test.tsx +1 -1
  57. package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
  58. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
  59. package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
  60. package/src/components/tools/shared/cursor-sphere.tsx +1 -1
  61. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  62. package/src/components/tools/shared/placement-box.tsx +1 -1
  63. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  64. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  65. package/src/components/ui/action-menu/camera-actions.tsx +2 -2
  66. package/src/components/ui/action-menu/control-modes.tsx +1 -1
  67. package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
  68. package/src/components/ui/action-menu/index.tsx +3 -3
  69. package/src/components/ui/action-menu/measurement-control.tsx +3 -3
  70. package/src/components/ui/action-menu/placement.ts +0 -18
  71. package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
  72. package/src/components/ui/action-menu/view-toggles.tsx +5 -5
  73. package/src/components/ui/controls/finish-swatch.tsx +1 -1
  74. package/src/components/ui/controls/material-paint-panel.tsx +2 -2
  75. package/src/components/ui/controls/scene-material-list.tsx +2 -2
  76. package/src/components/ui/controls/select-control.tsx +1 -1
  77. package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
  78. package/src/components/ui/floating-level-selector.tsx +3 -3
  79. package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
  80. package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
  81. package/src/components/ui/helpers/helper-manager.tsx +1 -1
  82. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  83. package/src/components/ui/node-icon-image.test.tsx +46 -0
  84. package/src/components/ui/node-icon-image.tsx +33 -1
  85. package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
  86. package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
  87. package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
  88. package/src/components/ui/panels/node-display.ts +1 -1
  89. package/src/components/ui/panels/panel-manager.tsx +1 -1
  90. package/src/components/ui/panels/panel-wrapper.tsx +1 -1
  91. package/src/components/ui/panels/parametric-inspector.tsx +1 -1
  92. package/src/components/ui/panels/room-construction-rows.tsx +6 -6
  93. package/src/components/ui/panels/room-name-options.ts +1 -5
  94. package/src/components/ui/panels/room-panel.tsx +6 -8
  95. package/src/components/ui/primitives/color-dot.tsx +2 -2
  96. package/src/components/ui/primitives/opacity-control.tsx +2 -2
  97. package/src/components/ui/primitives/sidebar.tsx +3 -3
  98. package/src/components/ui/right-stack.tsx +1 -1
  99. package/src/components/ui/sidebar/icon-rail.tsx +1 -1
  100. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  101. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  102. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
  103. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
  105. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
  106. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  107. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
  108. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
  109. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
  110. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  111. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
  112. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
  113. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
  114. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
  115. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
  116. package/src/components/ui/sidebar/tab-bar.tsx +1 -1
  117. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  118. package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
  119. package/src/components/viewer-overlay.tsx +1 -2
  120. package/src/hooks/use-auto-frame.ts +1 -2
  121. package/src/hooks/use-keyboard.ts +1 -2
  122. package/src/hooks/use-selected-room.ts +5 -7
  123. package/src/i18n/floor-footprint-name.ts +1 -1
  124. package/src/i18n/locales/en/messages.po +180 -182
  125. package/src/i18n/locales/es/messages.po +180 -182
  126. package/src/i18n/locales/fr/messages.po +180 -182
  127. package/src/i18n/locales/ja/messages.po +180 -182
  128. package/src/i18n/locales/zh-CN/messages.po +180 -182
  129. package/src/i18n/locales.test.ts +1 -1
  130. package/src/index.tsx +109 -131
  131. package/src/lib/below-grade.test.tsx +4 -4
  132. package/src/lib/camera-facing.test.ts +1 -1
  133. package/src/lib/camera-facing.ts +2 -3
  134. package/src/lib/editor-level-display.ts +1 -1
  135. package/src/lib/floor-footprints.test.ts +2 -2
  136. package/src/lib/floor-opening-draft.ts +1 -1
  137. package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
  138. package/src/lib/floorplan/annotation-visibility.ts +1 -1
  139. package/src/lib/floorplan/floorplan-export.test.ts +1 -1
  140. package/src/lib/floorplan/floorplan-export.tsx +8 -11
  141. package/src/lib/floorplan/floorplan-extension.ts +4 -157
  142. package/src/lib/floorplan/floorplan-mode.ts +2 -1
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
  144. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
  145. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
  146. package/src/lib/floorplan/geometry.ts +2 -112
  147. package/src/lib/floorplan/index.ts +0 -9
  148. package/src/lib/floorplan/items.ts +2 -1
  149. package/src/lib/floorplan/schedules.ts +6 -3
  150. package/src/lib/floorplan/sheet-export.ts +7 -3
  151. package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
  152. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
  153. package/src/lib/follow-pascal-presentation.ts +1 -1
  154. package/src/lib/glb-export.ts +1 -1
  155. package/src/lib/material-paint.ts +1 -1
  156. package/src/lib/measurements.test.ts +2 -1
  157. package/src/lib/mezzanine-selection.test.ts +1 -1
  158. package/src/lib/paint-region-mode.ts +1 -1
  159. package/src/lib/point-ask/session.ts +1 -2
  160. package/src/lib/roof-duplication.test.ts +100 -0
  161. package/src/lib/roof-duplication.ts +30 -5
  162. package/src/lib/room-handle-drag.ts +2 -12
  163. package/src/lib/room-name-catalog.test.ts +2 -1
  164. package/src/lib/room-name-messages.test.ts +2 -1
  165. package/src/lib/room-name-messages.ts +1 -118
  166. package/src/lib/room-name-storage.test.ts +2 -1
  167. package/src/lib/room-selection-commands.ts +2 -2
  168. package/src/lib/room-selection.test.ts +6 -6
  169. package/src/lib/room-transform-session.ts +1 -2
  170. package/src/lib/room-zone-routing.ts +2 -2
  171. package/src/lib/selection-routing.test.ts +0 -28
  172. package/src/lib/selection-routing.ts +1 -40
  173. package/src/lib/sfx-bus.ts +8 -32
  174. package/src/lib/sfx-player.test.ts +72 -1
  175. package/src/lib/sfx-player.ts +49 -337
  176. package/src/lib/viewer-selection.test.ts +38 -10
  177. package/src/lib/wall-pull.test.ts +1 -1
  178. package/src/lib/wall-pull.ts +1 -27
  179. package/src/store/use-editor.tsx +17 -26
  180. package/src/store/use-floorplan-annotation-visibility.ts +5 -5
  181. package/src/store/use-wall-region-handles-selection.ts +1 -2
  182. package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
  183. package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
  184. package/src/components/editor/first-person/build-collider-world.ts +0 -549
  185. package/src/components/editor/first-person/ground-holes.test.ts +0 -128
  186. package/src/components/editor/first-person/ground-holes.ts +0 -130
  187. package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
  188. package/src/components/editor/first-person/mechanism-targets.ts +0 -88
  189. package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
  190. package/src/components/editor/first-person/terrain-collider.ts +0 -212
  191. package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
  192. package/src/components/editor/floorplan-camera-sync.ts +0 -213
  193. package/src/components/editor/localized-html.tsx +0 -22
  194. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
  195. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
  196. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
  197. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
  198. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
  199. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
  200. package/src/components/editor-2d/svg-paths.ts +0 -124
  201. package/src/components/ui/action-menu/action-button.tsx +0 -89
  202. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
  203. package/src/components/ui/primitives/button.tsx +0 -69
  204. package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
  205. package/src/components/ui/primitives/popover.tsx +0 -42
  206. package/src/components/ui/primitives/switch.tsx +0 -29
  207. package/src/components/ui/primitives/tooltip.tsx +0 -57
  208. package/src/components/ui/scene-loader.tsx +0 -73
  209. package/src/components/viewer/floorplan-compass-button.tsx +0 -67
  210. package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
  211. package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
  212. package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
  213. package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
  214. package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
  215. package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
  216. package/src/components/viewer/floorplan-preview.tsx +0 -1065
  217. package/src/components/viewer/render-settings-menu.tsx +0 -294
  218. package/src/components/viewer/sky-control.tsx +0 -710
  219. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
  220. package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
  221. package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
  222. package/src/components/viewer/viewer-controls-bar.tsx +0 -496
  223. package/src/components/viewer/viewer-hover-label.tsx +0 -42
  224. package/src/components/viewer/viewer-rooms.tsx +0 -248
  225. package/src/components/viewer/viewer-scene-header.tsx +0 -286
  226. package/src/components/viewer/viewer-selection-manager.tsx +0 -124
  227. package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
  228. package/src/components/viewer/viewer-stage-modes.ts +0 -49
  229. package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
  230. package/src/components/viewer/viewer-stage.test.tsx +0 -47
  231. package/src/components/viewer/viewer-stage.tsx +0 -221
  232. package/src/components/walkthrough-hud.tsx +0 -193
  233. package/src/hooks/use-mobile.ts +0 -19
  234. package/src/i18n/level-name.ts +0 -39
  235. package/src/i18n/scene-theme-name.ts +0 -16
  236. package/src/lib/below-grade.ts +0 -133
  237. package/src/lib/camera-pose.test.ts +0 -237
  238. package/src/lib/camera-pose.ts +0 -172
  239. package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
  240. package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
  241. package/src/lib/floorplan/floorplan-readonly.ts +0 -95
  242. package/src/lib/floorplan/marks.test.ts +0 -213
  243. package/src/lib/floorplan/marks.ts +0 -238
  244. package/src/lib/measurements.ts +0 -8
  245. package/src/lib/node-display-label.ts +0 -37
  246. package/src/lib/room-assembly-overlay.test.ts +0 -275
  247. package/src/lib/room-assembly-overlay.ts +0 -265
  248. package/src/lib/room-selection.ts +0 -417
  249. package/src/lib/scene-bounds.test.ts +0 -327
  250. package/src/lib/scene-bounds.ts +0 -233
  251. package/src/lib/typing-target.ts +0 -9
  252. package/src/lib/viewer-selection.ts +0 -595
  253. package/src/lib/zone-content.ts +0 -133
  254. package/src/store/camera-pose-store.test.ts +0 -29
  255. package/src/store/camera-pose-store.ts +0 -27
  256. package/src/store/navigation-sync-pose-store.test.ts +0 -34
  257. package/src/store/navigation-sync-pose-store.ts +0 -27
  258. package/src/store/use-audio.test.ts +0 -146
  259. package/src/store/use-audio.tsx +0 -169
  260. package/src/store/use-first-person-hud.ts +0 -40
  261. package/src/store/use-follow-pascal.test.ts +0 -62
  262. package/src/store/use-follow-pascal.ts +0 -53
@@ -1,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