@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react'
5
5
  import { forwardRef, memo, useEffect, useRef } from 'react'
6
6
  import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
7
7
  import useEditor from '../../../../../store/use-editor'
8
+ import { expandSessionSelectionForNode } from '../../../../../store/use-session-groups'
8
9
 
9
10
  export function handleTreeSelection(
10
11
  e: React.MouseEvent,
@@ -48,7 +49,13 @@ export function handleTreeSelection(
48
49
  }
49
50
  }
50
51
 
51
- setSelection({ selectedIds: [nodeId] })
52
+ if (e.altKey) {
53
+ setSelection({ selectedIds: [nodeId] })
54
+ return false
55
+ }
56
+
57
+ const expanded = expandSessionSelectionForNode(nodeId)
58
+ setSelection({ selectedIds: expanded && expanded.length > 1 ? expanded : [nodeId] })
52
59
  return false
53
60
  }
54
61
 
@@ -110,6 +117,12 @@ interface TreeNodeProps {
110
117
  isLast?: boolean
111
118
  }
112
119
 
120
+ type TreeNodeComponent = React.ComponentType<{
121
+ depth: number
122
+ isLast?: boolean
123
+ nodeId: AnyNodeId
124
+ }>
125
+
113
126
  // Per-kind tree-node components keyed by `node.type`. Lookup replaces
114
127
  // the legacy switch — adding a kind to this map is now the only edit
115
128
  // needed in this file (the switch's `case '<kind>':` clauses were
@@ -117,10 +130,7 @@ interface TreeNodeProps {
117
130
  // outside the registry; future work moves these to a
118
131
  // `def.presentation`-driven generic tree-node and removes this map
119
132
  // entirely).
120
- const treeNodeByType: Record<
121
- string,
122
- React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
123
- > = {
133
+ const treeNodeByType: Record<string, TreeNodeComponent> = {
124
134
  building: BuildingTreeNode as React.ComponentType<{
125
135
  depth: number
126
136
  isLast?: boolean
@@ -129,6 +139,7 @@ const treeNodeByType: Record<
129
139
  cabinet: RegistryTreeNode,
130
140
  'cabinet-module': RegistryTreeNode,
131
141
  'box-vent': RegistryTreeNode,
142
+ 'block': RegistryTreeNode,
132
143
  ceiling: CeilingTreeNode,
133
144
  chimney: ChimneyTreeNode,
134
145
  dormer: DormerTreeNode,
@@ -162,6 +173,7 @@ const treeNodeByType: Record<
162
173
  'eyebrow-vent': RegistryTreeNode,
163
174
  skylight: RegistryTreeNode,
164
175
  roof: RoofTreeNode,
176
+ scan: RegistryTreeNode,
165
177
  stair: StairTreeNode,
166
178
  door: DoorTreeNode,
167
179
  window: WindowTreeNode,
@@ -173,6 +185,10 @@ const treeNodeByType: Record<
173
185
  item: ItemTreeNode,
174
186
  }
175
187
 
188
+ export function getTreeNodeComponent(nodeType: string): TreeNodeComponent {
189
+ return treeNodeByType[nodeType] ?? RegistryTreeNode
190
+ }
191
+
176
192
  export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
177
193
  // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
178
194
  // selector so unrelated scene updates don't re-render every row.
@@ -184,8 +200,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
184
200
  const nodeType = useScene((state) => state.nodes[nodeId]?.type)
185
201
  if (shouldHide) return null
186
202
  if (!nodeType) return null
187
- const Component = treeNodeByType[nodeType]
188
- if (!Component) return null
203
+ const Component = getTreeNodeComponent(nodeType)
189
204
  return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
190
205
  })
191
206
 
@@ -13,6 +13,12 @@ export type SidebarTab = {
13
13
  mobileIcon?: ReactNode
14
14
  /** Desktop icon shown in the vertical rail (v2 layout). */
15
15
  icon?: ReactNode
16
+ /**
17
+ * Rail entry that drives the stage instead of opening a sidebar panel:
18
+ * activating it hides the panel column (preserving its collapse state) and
19
+ * keeps the icon highlighted regardless of collapse.
20
+ */
21
+ noPanel?: boolean
16
22
  }
17
23
 
18
24
  interface TabBarProps {
@@ -75,11 +81,13 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
75
81
  const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
76
82
 
77
83
  const renderTab = (tab: SidebarTab) => {
78
- const showActive = activeTab === tab.id && !collapsed
84
+ const showActive = activeTab === tab.id && (!collapsed || tab.noPanel === true)
79
85
  return (
80
86
  <Tooltip key={tab.id}>
81
87
  <TooltipTrigger asChild>
82
88
  <button
89
+ aria-label={tab.label}
90
+ aria-pressed={showActive}
83
91
  className={cn(
84
92
  'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
85
93
  showActive
@@ -12,7 +12,12 @@ import {
12
12
  useSyncExternalStore,
13
13
  } from 'react'
14
14
  import useEditor from '../../../store/use-editor'
15
- import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
15
+ import {
16
+ editorHostPanelRegistry,
17
+ type EditorHostPanel,
18
+ managedPluginIds,
19
+ showsPluginManager,
20
+ } from '../../../lib/plugin-panels'
16
21
  import { ErrorBoundary } from '../primitives/error-boundary'
17
22
  import type { ExtraPanel } from './icon-rail'
18
23
  import { PluginsPanel } from './panels/plugins-panel'
@@ -100,6 +105,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
100
105
  )
101
106
  const workspaceMode = useEditor((s) => s.workspaceMode)
102
107
  const installedPlugins = useScene((s) => s.installedPlugins)
108
+ const readOnly = useScene((s) => s.readOnly)
103
109
  const hostIds = new Set(hostPanels?.map((p) => p.id))
104
110
 
105
111
  useEffect(() => {
@@ -126,7 +132,16 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
126
132
  pluginId: p.pluginId,
127
133
  }),
128
134
  )
135
+ // The manager tab is the one panel the editor contributes itself, so it is
136
+ // also the one that can be alone in the rail — see `showsPluginManager`.
129
137
  const manager =
130
- workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
138
+ !hostIds.has(pluginsManagerPanel.id) &&
139
+ showsPluginManager({
140
+ managedPluginCount: managedPluginIds(registered).length,
141
+ readOnly,
142
+ workspaceMode,
143
+ })
144
+ ? [pluginsManagerPanel]
145
+ : []
131
146
  return [...(hostPanels ?? []), ...fromRegistry, ...manager]
132
147
  }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
5
+
6
+ export type FloorplanCompassButtonProps = {
7
+ northRotationDeg: number
8
+ onAlignNorth: () => void
9
+ needleRef?: React.RefObject<SVGSVGElement | null>
10
+ }
11
+
12
+ export function FloorplanCompassButton({
13
+ northRotationDeg,
14
+ onAlignNorth,
15
+ needleRef,
16
+ }: FloorplanCompassButtonProps) {
17
+ return (
18
+ <Tooltip>
19
+ <TooltipTrigger asChild>
20
+ <button
21
+ aria-label="Align view to north"
22
+ className="group pointer-events-auto absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
23
+ onClick={(event) => {
24
+ event.preventDefault()
25
+ event.stopPropagation()
26
+ onAlignNorth()
27
+ }}
28
+ onPointerDown={(event) => {
29
+ event.stopPropagation()
30
+ }}
31
+ type="button"
32
+ >
33
+ <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
34
+ <svg
35
+ aria-hidden="true"
36
+ className="h-6 w-6"
37
+ ref={needleRef}
38
+ style={{ transform: `rotate(${northRotationDeg}deg)` }}
39
+ viewBox="0 0 48 48"
40
+ >
41
+ <path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
42
+ <path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
43
+ </svg>
44
+ </span>
45
+ </button>
46
+ </TooltipTrigger>
47
+ <TooltipContent side="right">Align view to north</TooltipContent>
48
+ </Tooltip>
49
+ )
50
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { FloorplanGeometry } from '@pascal-app/core'
3
+ import {
4
+ clientToFloorplanPoint,
5
+ getFloorplanBounds,
6
+ padFloorplanBounds,
7
+ panFloorplanViewBox,
8
+ scaleFloorplanViewBox,
9
+ scaleFloorplanViewBoxBetweenClients,
10
+ } from './floorplan-preview-geometry'
11
+
12
+ describe('floorplan preview bounds', () => {
13
+ test('composes nested translation and rotation', () => {
14
+ const geometry: FloorplanGeometry = {
15
+ kind: 'group',
16
+ transform: { translate: [10, 5], rotate: Math.PI / 2 },
17
+ children: [
18
+ {
19
+ kind: 'rect',
20
+ x: 0,
21
+ y: 0,
22
+ width: 4,
23
+ height: 2,
24
+ },
25
+ ],
26
+ }
27
+
28
+ expect(getFloorplanBounds([geometry])).toEqual({
29
+ minX: 8,
30
+ minY: 5,
31
+ maxX: 10,
32
+ maxY: 9,
33
+ })
34
+ })
35
+
36
+ test('includes image rotation and dimension baselines', () => {
37
+ const geometries: FloorplanGeometry[] = [
38
+ {
39
+ kind: 'image',
40
+ url: '/symbol.png',
41
+ center: [2, 3],
42
+ width: 4,
43
+ height: 2,
44
+ rotation: Math.PI / 2,
45
+ },
46
+ {
47
+ kind: 'dimension',
48
+ start: [-3, -2],
49
+ end: [3, -2],
50
+ dimensionStart: [-4, -1],
51
+ dimensionEnd: [4, -1],
52
+ offsetNormal: [0, -1],
53
+ offsetDistance: 1,
54
+ extensionOvershoot: 0.2,
55
+ text: '8 m',
56
+ },
57
+ ]
58
+
59
+ expect(getFloorplanBounds(geometries)).toEqual({
60
+ minX: -4,
61
+ minY: -2,
62
+ maxX: 4,
63
+ maxY: 5,
64
+ })
65
+ })
66
+
67
+ test('pads narrow plans by a usable minimum', () => {
68
+ expect(padFloorplanBounds({ minX: 0, minY: 0, maxX: 0, maxY: 0 })).toEqual({
69
+ minX: -0.75,
70
+ minY: -0.75,
71
+ maxX: 0.75,
72
+ maxY: 0.75,
73
+ })
74
+ })
75
+
76
+ test('maps clients through letterboxed SVG content', () => {
77
+ const viewBox = { x: 0, y: 0, width: 10, height: 10 }
78
+ const viewport = { left: 0, top: 0, width: 1000, height: 500 }
79
+
80
+ expect(clientToFloorplanPoint(viewBox, viewport, 250, 0)).toEqual([0, 0])
81
+ expect(clientToFloorplanPoint(viewBox, viewport, 750, 500)).toEqual([10, 10])
82
+ expect(panFloorplanViewBox(viewBox, viewport, [500, 250], [550, 250])).toEqual({
83
+ x: -1,
84
+ y: 0,
85
+ width: 10,
86
+ height: 10,
87
+ })
88
+ })
89
+
90
+ test('keeps the plan point beneath a moving pinch midpoint', () => {
91
+ const viewBox = { x: 0, y: 0, width: 10, height: 10 }
92
+ const viewport = { left: 0, top: 0, width: 1000, height: 500 }
93
+ const next = scaleFloorplanViewBoxBetweenClients(viewBox, 0.5, viewport, [500, 250], [600, 250])
94
+
95
+ expect(next).toEqual({ x: 1.5, y: 2.5, width: 5, height: 5 })
96
+ expect(clientToFloorplanPoint(next, viewport, 600, 250)).toEqual([5, 5])
97
+ })
98
+
99
+ test('clamps zoom without changing the view box aspect ratio', () => {
100
+ expect(scaleFloorplanViewBox({ x: 0, y: 0, width: 100, height: 1 }, 0.01)).toEqual({
101
+ x: 37.5,
102
+ y: 0.375,
103
+ width: 25,
104
+ height: 0.25,
105
+ })
106
+ })
107
+ })
@@ -0,0 +1,281 @@
1
+ import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
2
+
3
+ export type FloorplanBounds = {
4
+ minX: number
5
+ minY: number
6
+ maxX: number
7
+ maxY: number
8
+ }
9
+
10
+ export type FloorplanViewBox = { x: number; y: number; width: number; height: number }
11
+
12
+ export type FloorplanViewport = {
13
+ left: number
14
+ top: number
15
+ width: number
16
+ height: number
17
+ }
18
+
19
+ type PlanTransform = { tx: number; ty: number; rotation: number }
20
+
21
+ const IDENTITY: PlanTransform = { tx: 0, ty: 0, rotation: 0 }
22
+ const MIN_VIEWBOX_SIZE = 0.25
23
+ const MAX_VIEWBOX_SIZE = 500
24
+
25
+ function clientToViewBoxFraction(
26
+ viewBox: FloorplanViewBox,
27
+ viewport: FloorplanViewport,
28
+ clientX: number,
29
+ clientY: number,
30
+ ): FloorplanPoint {
31
+ const viewportWidth = Math.max(viewport.width, 1)
32
+ const viewportHeight = Math.max(viewport.height, 1)
33
+ const scale = Math.min(viewportWidth / viewBox.width, viewportHeight / viewBox.height)
34
+ const renderedWidth = viewBox.width * scale
35
+ const renderedHeight = viewBox.height * scale
36
+ const renderedLeft = viewport.left + (viewportWidth - renderedWidth) / 2
37
+ const renderedTop = viewport.top + (viewportHeight - renderedHeight) / 2
38
+ return [(clientX - renderedLeft) / renderedWidth, (clientY - renderedTop) / renderedHeight]
39
+ }
40
+
41
+ export function clientToFloorplanPoint(
42
+ viewBox: FloorplanViewBox,
43
+ viewport: FloorplanViewport,
44
+ clientX: number,
45
+ clientY: number,
46
+ ): FloorplanPoint {
47
+ const [fractionX, fractionY] = clientToViewBoxFraction(viewBox, viewport, clientX, clientY)
48
+ return [viewBox.x + fractionX * viewBox.width, viewBox.y + fractionY * viewBox.height]
49
+ }
50
+
51
+ export function scaleFloorplanViewBox(
52
+ viewBox: FloorplanViewBox,
53
+ factor: number,
54
+ anchorX = 0.5,
55
+ anchorY = 0.5,
56
+ ): FloorplanViewBox {
57
+ const minFactor = Math.max(MIN_VIEWBOX_SIZE / viewBox.width, MIN_VIEWBOX_SIZE / viewBox.height)
58
+ const maxFactor = Math.min(MAX_VIEWBOX_SIZE / viewBox.width, MAX_VIEWBOX_SIZE / viewBox.height)
59
+ const clampedFactor = Math.min(Math.max(factor, minFactor), maxFactor)
60
+ const width = viewBox.width * clampedFactor
61
+ const height = viewBox.height * clampedFactor
62
+ return {
63
+ x: viewBox.x + (viewBox.width - width) * anchorX,
64
+ y: viewBox.y + (viewBox.height - height) * anchorY,
65
+ width,
66
+ height,
67
+ }
68
+ }
69
+
70
+ export function scaleFloorplanViewBoxBetweenClients(
71
+ viewBox: FloorplanViewBox,
72
+ factor: number,
73
+ viewport: FloorplanViewport,
74
+ sourceClient: FloorplanPoint,
75
+ targetClient: FloorplanPoint,
76
+ ): FloorplanViewBox {
77
+ const sourcePoint = clientToFloorplanPoint(viewBox, viewport, sourceClient[0], sourceClient[1])
78
+ const [targetX, targetY] = clientToViewBoxFraction(
79
+ viewBox,
80
+ viewport,
81
+ targetClient[0],
82
+ targetClient[1],
83
+ )
84
+ const scaled = scaleFloorplanViewBox(viewBox, factor, 0, 0)
85
+ return {
86
+ ...scaled,
87
+ x: sourcePoint[0] - targetX * scaled.width,
88
+ y: sourcePoint[1] - targetY * scaled.height,
89
+ }
90
+ }
91
+
92
+ export function panFloorplanViewBox(
93
+ viewBox: FloorplanViewBox,
94
+ viewport: FloorplanViewport,
95
+ sourceClient: FloorplanPoint,
96
+ targetClient: FloorplanPoint,
97
+ ): FloorplanViewBox {
98
+ const sourcePoint = clientToFloorplanPoint(viewBox, viewport, sourceClient[0], sourceClient[1])
99
+ const targetPoint = clientToFloorplanPoint(viewBox, viewport, targetClient[0], targetClient[1])
100
+ return {
101
+ ...viewBox,
102
+ x: viewBox.x + sourcePoint[0] - targetPoint[0],
103
+ y: viewBox.y + sourcePoint[1] - targetPoint[1],
104
+ }
105
+ }
106
+
107
+ function applyTransform(point: FloorplanPoint, transform: PlanTransform): FloorplanPoint {
108
+ const cos = Math.cos(transform.rotation)
109
+ const sin = Math.sin(transform.rotation)
110
+ return [
111
+ point[0] * cos - point[1] * sin + transform.tx,
112
+ point[0] * sin + point[1] * cos + transform.ty,
113
+ ]
114
+ }
115
+
116
+ function composeTransform(
117
+ parent: PlanTransform,
118
+ child: NonNullable<Extract<FloorplanGeometry, { kind: 'group' }>['transform']>,
119
+ ): PlanTransform {
120
+ const translated = applyTransform(child.translate ?? [0, 0], parent)
121
+ return {
122
+ tx: translated[0],
123
+ ty: translated[1],
124
+ rotation: parent.rotation + (child.rotate ?? 0),
125
+ }
126
+ }
127
+
128
+ function includePoint(bounds: FloorplanBounds | null, point: FloorplanPoint): FloorplanBounds {
129
+ if (!bounds) return { minX: point[0], minY: point[1], maxX: point[0], maxY: point[1] }
130
+ return {
131
+ minX: Math.min(bounds.minX, point[0]),
132
+ minY: Math.min(bounds.minY, point[1]),
133
+ maxX: Math.max(bounds.maxX, point[0]),
134
+ maxY: Math.max(bounds.maxY, point[1]),
135
+ }
136
+ }
137
+
138
+ function includePoints(
139
+ bounds: FloorplanBounds | null,
140
+ points: readonly FloorplanPoint[],
141
+ transform: PlanTransform,
142
+ ): FloorplanBounds | null {
143
+ let next = bounds
144
+ for (const point of points) next = includePoint(next, applyTransform(point, transform))
145
+ return next
146
+ }
147
+
148
+ function geometryBounds(
149
+ geometry: FloorplanGeometry,
150
+ transform: PlanTransform,
151
+ bounds: FloorplanBounds | null,
152
+ ): FloorplanBounds | null {
153
+ switch (geometry.kind) {
154
+ case 'group': {
155
+ const nextTransform = geometry.transform
156
+ ? composeTransform(transform, geometry.transform)
157
+ : transform
158
+ return geometry.children.reduce(
159
+ (next, child) => geometryBounds(child, nextTransform, next),
160
+ bounds,
161
+ )
162
+ }
163
+ case 'polygon':
164
+ case 'polyline':
165
+ case 'hatch':
166
+ return includePoints(bounds, geometry.points, transform)
167
+ case 'rect':
168
+ return includePoints(
169
+ bounds,
170
+ [
171
+ [geometry.x, geometry.y],
172
+ [geometry.x + geometry.width, geometry.y],
173
+ [geometry.x + geometry.width, geometry.y + geometry.height],
174
+ [geometry.x, geometry.y + geometry.height],
175
+ ],
176
+ transform,
177
+ )
178
+ case 'circle': {
179
+ const center = applyTransform([geometry.cx, geometry.cy], transform)
180
+ return includePoints(
181
+ bounds,
182
+ [
183
+ [center[0] - geometry.r, center[1] - geometry.r],
184
+ [center[0] + geometry.r, center[1] + geometry.r],
185
+ ],
186
+ IDENTITY,
187
+ )
188
+ }
189
+ case 'line':
190
+ case 'hit-line':
191
+ case 'edge-handle':
192
+ return includePoints(
193
+ bounds,
194
+ [
195
+ [geometry.x1, geometry.y1],
196
+ [geometry.x2, geometry.y2],
197
+ ],
198
+ transform,
199
+ )
200
+ case 'text':
201
+ return includePoint(bounds, applyTransform([geometry.x, geometry.y], transform))
202
+ case 'image': {
203
+ const halfWidth = geometry.width / 2
204
+ const halfHeight = geometry.height / 2
205
+ const imageTransform = composeTransform(transform, {
206
+ translate: geometry.center,
207
+ rotate: geometry.rotation,
208
+ })
209
+ return includePoints(
210
+ bounds,
211
+ [
212
+ [-halfWidth, -halfHeight],
213
+ [halfWidth, -halfHeight],
214
+ [halfWidth, halfHeight],
215
+ [-halfWidth, halfHeight],
216
+ ],
217
+ imageTransform,
218
+ )
219
+ }
220
+ case 'endpoint-handle':
221
+ case 'midpoint-handle':
222
+ case 'move-handle':
223
+ case 'move-arrow':
224
+ case 'rotate-arrow':
225
+ case 'equal-spacing-badge':
226
+ return includePoint(bounds, applyTransform(geometry.point, transform))
227
+ case 'dimension-label':
228
+ return includePoint(bounds, applyTransform([geometry.cx, geometry.cy], transform))
229
+ case 'dimension':
230
+ return includePoints(
231
+ bounds,
232
+ [
233
+ geometry.start,
234
+ geometry.end,
235
+ geometry.dimensionStart ?? geometry.start,
236
+ geometry.dimensionEnd ?? geometry.end,
237
+ ],
238
+ transform,
239
+ )
240
+ case 'dimension-string':
241
+ return geometry.segments.reduce(
242
+ (next, segment) =>
243
+ includePoints(
244
+ next,
245
+ [
246
+ segment.start,
247
+ segment.end,
248
+ segment.dimensionStart ?? segment.start,
249
+ segment.dimensionEnd ?? segment.end,
250
+ ],
251
+ transform,
252
+ ),
253
+ bounds,
254
+ )
255
+ case 'path':
256
+ return bounds
257
+ default:
258
+ return bounds
259
+ }
260
+ }
261
+
262
+ export function getFloorplanBounds(
263
+ geometries: readonly FloorplanGeometry[],
264
+ ): FloorplanBounds | null {
265
+ return geometries.reduce(
266
+ (bounds, geometry) => geometryBounds(geometry, IDENTITY, bounds),
267
+ null as FloorplanBounds | null,
268
+ )
269
+ }
270
+
271
+ export function padFloorplanBounds(bounds: FloorplanBounds, ratio = 0.12): FloorplanBounds {
272
+ const width = Math.max(bounds.maxX - bounds.minX, 1)
273
+ const height = Math.max(bounds.maxY - bounds.minY, 1)
274
+ const padding = Math.max(Math.max(width, height) * ratio, 0.75)
275
+ return {
276
+ minX: bounds.minX - padding,
277
+ minY: bounds.minY - padding,
278
+ maxX: bounds.maxX + padding,
279
+ maxY: bounds.maxY + padding,
280
+ }
281
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ cameraAzimuthFromFloorplanRotation,
4
+ floorplanRotationFromCameraAzimuth,
5
+ floorplanViewBoxFromNavigationPose,
6
+ nearestEquivalentDegrees,
7
+ visibleFloorplanViewWidth,
8
+ } from './floorplan-preview-navigation'
9
+
10
+ describe('floorplan preview navigation', () => {
11
+ test('keeps continuous compass rotations across the angle seam', () => {
12
+ expect(nearestEquivalentDegrees(-179, 179)).toBe(181)
13
+ expect(floorplanRotationFromCameraAzimuth((-179 * Math.PI) / 180, 179)).toBeCloseTo(181)
14
+ })
15
+
16
+ test('uses the same north-up azimuth convention as the editor', () => {
17
+ expect(cameraAzimuthFromFloorplanRotation(0)).toBe(0)
18
+ expect(cameraAzimuthFromFloorplanRotation(90)).toBeCloseTo(Math.PI / 2)
19
+ })
20
+
21
+ test('maps a camera pose to an aspect-correct centered view box', () => {
22
+ expect(
23
+ floorplanViewBoxFromNavigationPose(
24
+ {
25
+ source: '3d',
26
+ revision: 1,
27
+ target: [0, 0, 0],
28
+ azimuth: 0,
29
+ viewWidth: 20,
30
+ },
31
+ { x: 4, y: 3 },
32
+ 0,
33
+ { width: 1000, height: 500 },
34
+ ),
35
+ ).toEqual({ x: -6, y: -2, width: 20, height: 10 })
36
+ })
37
+
38
+ test('reports the actual horizontal span for meet-preserved SVG view boxes', () => {
39
+ expect(
40
+ visibleFloorplanViewWidth({ x: 0, y: 0, width: 10, height: 10 }, { width: 200, height: 100 }),
41
+ ).toBe(20)
42
+ })
43
+ })
@@ -0,0 +1,62 @@
1
+ import type { NavigationSyncPose } from '../../store/use-editor'
2
+ import type { FloorplanViewBox } from './floorplan-preview-geometry'
3
+
4
+ export type FloorplanPreviewViewportSize = {
5
+ width: number
6
+ height: number
7
+ }
8
+
9
+ export function nearestEquivalentDegrees(angle: number, reference: number) {
10
+ let next = angle
11
+ while (next - reference > 180) next -= 360
12
+ while (next - reference < -180) next += 360
13
+ return next
14
+ }
15
+
16
+ export function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) {
17
+ return nearestEquivalentDegrees((azimuth * 180) / Math.PI, reference)
18
+ }
19
+
20
+ export function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
21
+ return (rotationDeg * Math.PI) / 180
22
+ }
23
+
24
+ export function rotateFloorplanPoint(
25
+ point: { x: number; y: number },
26
+ rotationDeg: number,
27
+ ): { x: number; y: number } {
28
+ if (rotationDeg === 0) return point
29
+ const radians = (rotationDeg * Math.PI) / 180
30
+ const cos = Math.cos(radians)
31
+ const sin = Math.sin(radians)
32
+ return {
33
+ x: point.x * cos - point.y * sin,
34
+ y: point.x * sin + point.y * cos,
35
+ }
36
+ }
37
+
38
+ export function visibleFloorplanViewWidth(
39
+ viewBox: FloorplanViewBox,
40
+ viewport: FloorplanPreviewViewportSize,
41
+ ) {
42
+ const aspect = Math.max(viewport.width, 1) / Math.max(viewport.height, 1)
43
+ return Math.max(viewBox.width, viewBox.height * aspect)
44
+ }
45
+
46
+ export function floorplanViewBoxFromNavigationPose(
47
+ pose: NavigationSyncPose,
48
+ localCenter: { x: number; y: number },
49
+ sceneRotationDeg: number,
50
+ viewport: FloorplanPreviewViewportSize,
51
+ ): FloorplanViewBox {
52
+ const center = rotateFloorplanPoint(localCenter, sceneRotationDeg)
53
+ const aspect = Math.max(viewport.width, 1) / Math.max(viewport.height, 1)
54
+ const width = Math.max(pose.viewWidth, 0.001)
55
+ const height = width / aspect
56
+ return {
57
+ x: center.x - width / 2,
58
+ y: center.y - height / 2,
59
+ width,
60
+ height,
61
+ }
62
+ }