@pascal-app/editor 1.0.4 → 1.0.6

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -5,17 +5,21 @@ import {
5
5
  type CeilingNode,
6
6
  createSceneApi,
7
7
  type FenceNode,
8
+ isStoreyContent,
9
+ type LevelNode,
8
10
  nodeRegistry,
9
11
  type SlabNode,
12
+ storeyOf,
10
13
  useScene,
11
14
  type WallNode,
12
15
  type ZoneNode,
13
16
  } from '@pascal-app/core'
14
17
  import { useViewer } from '@pascal-app/viewer'
15
- import { type ComponentType, lazy, Suspense, useMemo } from 'react'
18
+ import { type ComponentType, lazy, Suspense, useEffect, useMemo } from 'react'
16
19
  import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
17
20
  import { useCeilingEditCeilingId } from '../../lib/ceiling-edit-session'
18
21
  import type { ReshapeKind } from '../../lib/interaction/scope'
22
+ import { selectPlacedNode } from '../../lib/level-selection'
19
23
  import { paintRegionTargets, usePaintRegionMode } from '../../lib/paint-region-mode'
20
24
  import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
21
25
  import useEditor, { type Tool } from '../../store/use-editor'
@@ -48,7 +52,6 @@ import {
48
52
  import { FacingPoseIndicator } from './shared/facing-pose-indicator'
49
53
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
50
54
  import { TerrainSculptTool } from './site/terrain-sculpt-tool'
51
- import { StairTool } from './stair/stair-tool'
52
55
  import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
53
56
  import { ZoneTool } from './zone/zone-tool'
54
57
 
@@ -109,7 +112,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
109
112
  // item / shelf / spawn now go through the registry path above.
110
113
  const tools: Partial<Record<Tool, React.FC>> = {
111
114
  'property-line': SiteBoundaryEditor,
112
- stair: StairTool,
113
115
  zone: ZoneTool,
114
116
  }
115
117
 
@@ -178,16 +180,23 @@ export const ToolManager: React.FC = () => {
178
180
  const unit = useViewer((state) => state.unit)
179
181
  const setSelection = useViewer((state) => state.setSelection)
180
182
  const nodes = useScene((state) => state.nodes)
183
+ // Placing a roof makes its level active (selectPlacedNode); a wall, room, slab, stair or item tool
184
+ // armed there, then or after a level change, draws on the storey the roof level covers.
185
+ useEffect(() => {
186
+ if (!(tool && activeLevelId && isStoreyContent(tool, undefined))) return
187
+ const storeyId = storeyOf(useScene.getState().nodes, activeLevelId)
188
+ if (storeyId !== activeLevelId) setSelection({ levelId: storeyId as LevelNode['id'] })
189
+ }, [tool, activeLevelId, setSelection])
181
190
  const registrySceneApi = useMemo(() => createSceneApi(useScene), [])
182
191
  const registryToolContext = useMemo(
183
192
  () => ({
184
193
  activeLevelId: activeLevelId ?? null,
185
194
  isCameraDragging: () => useViewer.getState().cameraDragging,
186
195
  sceneApi: registrySceneApi,
187
- selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }),
196
+ selectNode: selectPlacedNode,
188
197
  unit,
189
198
  }),
190
- [activeLevelId, registrySceneApi, setSelection, unit],
199
+ [activeLevelId, registrySceneApi, unit],
191
200
  )
192
201
 
193
202
  // Building transform for the local group — all building-relative tools live inside this group
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNodeId,
5
5
  applyHeightPatch,
6
6
  BlockNode,
7
+ BuildingNode,
7
8
  CeilingNode,
8
9
  createTerrainField,
9
10
  DoorNode as DoorSchema,
@@ -12,6 +13,7 @@ import {
12
13
  GROUND_SUPPORT_ID,
13
14
  getFloorPlacedElevation,
14
15
  initSpaceDetectionSync,
16
+ LevelNode as LevelSchema,
15
17
  nodeRegistry,
16
18
  registerNode,
17
19
  resolveTerrainWallConstructionOptions,
@@ -180,6 +182,116 @@ describe('createWallOnCurrentLevel', () => {
180
182
  useScene.temporal.getState().resume()
181
183
  })
182
184
 
185
+ describe('on a level below grade', () => {
186
+ const seedBasement = (thickness?: number) => {
187
+ const basement = LevelSchema.parse({
188
+ id: 'level_cellar',
189
+ parentId: 'building_house',
190
+ level: -1,
191
+ baseElevation: -2.5,
192
+ height: 2.5,
193
+ })
194
+ const main = LevelSchema.parse({
195
+ id: 'level_main',
196
+ parentId: 'building_house',
197
+ level: 0,
198
+ height: 2.5,
199
+ })
200
+ const building = BuildingNode.parse({
201
+ id: 'building_house',
202
+ children: [basement.id, main.id],
203
+ })
204
+ useScene.setState({
205
+ nodes: Object.fromEntries(
206
+ [building, basement, main].map((node) => [node.id, node as AnyNode]),
207
+ ),
208
+ rootNodeIds: [building.id],
209
+ dirtyNodes: new Set(),
210
+ collections: {},
211
+ } as never)
212
+ useEditor.setState({
213
+ toolDefaults: {
214
+ ...useEditor.getState().toolDefaults,
215
+ wall: thickness === undefined ? {} : { thickness },
216
+ },
217
+ } as never)
218
+ useScene.temporal.getState().clear()
219
+ useScene.temporal.getState().resume()
220
+ }
221
+ const select = (levelId: string) =>
222
+ useViewer.setState({
223
+ selection: { buildingId: 'building_house', levelId, zoneId: null, selectedIds: [] },
224
+ } as never)
225
+ let previousDefaults: ReturnType<typeof useEditor.getState>['toolDefaults']
226
+ beforeEach(() => {
227
+ previousDefaults = useEditor.getState().toolDefaults
228
+ })
229
+ afterEach(() => {
230
+ useEditor.setState({ toolDefaults: previousDefaults } as never)
231
+ })
232
+
233
+ test('the wall tool draws an 8 in CMU foundation wall with 2x4 furring flat inside, 0.241 m thick', () => {
234
+ seedBasement()
235
+ select('level_cellar')
236
+ const created = createWallOnCurrentLevel([0, 0], [4, 0])
237
+ expect(created?.thickness).toBe(0.241)
238
+ expect(created?.assembly?.layers).toMatchObject([
239
+ { material: 'cmu', core: true, thickness: 0.203 },
240
+ { role: 'furring', thickness: 0.038 },
241
+ ])
242
+ const stored = useScene.getState().nodes[created!.id] as WallNode
243
+ expect(stored.assembly).toEqual(created?.assembly)
244
+ })
245
+
246
+ test('only the walls against the ground are foundation walls: the perimeter is, a partition and a wall inside are not', () => {
247
+ seedBasement()
248
+ select('level_cellar')
249
+ const sides: Array<[[number, number], [number, number]]> = [
250
+ [
251
+ [0, 0],
252
+ [8, 0],
253
+ ],
254
+ [
255
+ [8, 0],
256
+ [8, 6],
257
+ ],
258
+ [
259
+ [8, 6],
260
+ [0, 6],
261
+ ],
262
+ [
263
+ [0, 6],
264
+ [0, 0],
265
+ ],
266
+ ]
267
+ for (const [start, end] of sides) {
268
+ expect(createWallOnCurrentLevel(start, end)?.thickness).toBe(0.241)
269
+ }
270
+ const partition = createWallOnCurrentLevel([4, 0], [4, 6])
271
+ expect(partition?.assembly).toBeUndefined()
272
+ const dangling = createWallOnCurrentLevel([0, 3], [3, 3])
273
+ expect(dangling?.assembly).toBeUndefined()
274
+ // a bay on the outside of the east wall is on the outline again
275
+ const bay = createWallOnCurrentLevel([8, 1], [11, 1])
276
+ expect(bay?.thickness).toBe(0.241)
277
+ })
278
+
279
+ test('the ground floor over it keeps the plain wall', () => {
280
+ seedBasement()
281
+ select('level_main')
282
+ const created = createWallOnCurrentLevel([0, 0], [4, 0])
283
+ expect(created?.assembly).toBeUndefined()
284
+ })
285
+
286
+ test('a thickness set in the tool defaults is kept, with no stack', () => {
287
+ seedBasement(0.3)
288
+ select('level_cellar')
289
+ const created = createWallOnCurrentLevel([0, 0], [4, 0])
290
+ expect(created?.thickness).toBe(0.3)
291
+ expect(created?.assembly).toBeUndefined()
292
+ })
293
+ })
294
+
183
295
  test('endpoint near an existing corner attaches to the corner instead of splitting', () => {
184
296
  const created = createWallOnCurrentLevel([2, 2], [3.99, 0])
185
297
 
@@ -726,63 +838,64 @@ describe('createWallOnCurrentLevel', () => {
726
838
  expect(useScene.temporal.getState().pastStates.length - before).toBe(1)
727
839
  })
728
840
 
729
- test.each([
730
- 0.05, 0.18,
731
- ])('a room divider preserves the footprint floor at %s and splits ceilings', (elevation) => {
732
- const walls = [
733
- makeWall([0, 0], [8, 0], 'wall_bottom'),
734
- makeWall([8, 0], [8, 6], 'wall_right'),
735
- makeWall([8, 6], [0, 6], 'wall_top'),
736
- makeWall([0, 6], [0, 0], 'wall_left'),
737
- ]
738
- const slab = SlabNode.parse({
739
- id: 'slab_auto',
740
- parentId: LEVEL_ID,
741
- polygon: [
742
- [0, 0],
743
- [8, 0],
744
- [8, 6],
745
- [0, 6],
746
- ],
747
- elevation,
748
- floorHeight: elevation,
749
- thickness: 0.32,
750
- autoFromWalls: true,
751
- })
752
- const ceiling = CeilingNode.parse({
753
- id: 'ceiling_auto',
754
- parentId: LEVEL_ID,
755
- polygon: slab.polygon,
756
- autoFromWalls: true,
757
- })
758
- seedLevel(walls, [slab, ceiling])
759
- useScene.setState({
760
- nodes: migrateFloorPlates(
761
- migrateCeilingRoomLinks(migrateRoomZones(useScene.getState().nodes).nodes).nodes,
762
- ).nodes as Record<AnyNodeId, AnyNode>,
763
- })
764
- const stopDetection = initSpaceDetectionSync(useScene, useEditor)
765
-
766
- try {
767
- expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull()
768
-
769
- const nodes = Object.values(useScene.getState().nodes)
770
- const slabs = nodes.filter((node) => node.type === 'slab')
771
- const ceilings = nodes.filter((node) => node.type === 'ceiling')
772
- expect(slabs).toHaveLength(1)
773
- expect(slabs[0]).toMatchObject({ plateRole: 'base', floorHeight: elevation })
774
- expect(slabs[0]!.zoneIds).toHaveLength(2)
775
- expect(ceilings).toHaveLength(2)
776
- expect(
777
- slabs.every(
778
- (node) => node.autoFromWalls && node.elevation === elevation && node.thickness === 0.32,
779
- ),
780
- ).toBe(true)
781
- expect(ceilings.every((node) => node.autoFromWalls)).toBe(true)
782
- } finally {
783
- stopDetection()
784
- }
785
- })
841
+ test.each([0.05, 0.18])(
842
+ 'a room divider preserves the footprint floor at %s and splits ceilings',
843
+ (elevation) => {
844
+ const walls = [
845
+ makeWall([0, 0], [8, 0], 'wall_bottom'),
846
+ makeWall([8, 0], [8, 6], 'wall_right'),
847
+ makeWall([8, 6], [0, 6], 'wall_top'),
848
+ makeWall([0, 6], [0, 0], 'wall_left'),
849
+ ]
850
+ const slab = SlabNode.parse({
851
+ id: 'slab_auto',
852
+ parentId: LEVEL_ID,
853
+ polygon: [
854
+ [0, 0],
855
+ [8, 0],
856
+ [8, 6],
857
+ [0, 6],
858
+ ],
859
+ elevation,
860
+ floorHeight: elevation,
861
+ thickness: 0.32,
862
+ autoFromWalls: true,
863
+ })
864
+ const ceiling = CeilingNode.parse({
865
+ id: 'ceiling_auto',
866
+ parentId: LEVEL_ID,
867
+ polygon: slab.polygon,
868
+ autoFromWalls: true,
869
+ })
870
+ seedLevel(walls, [slab, ceiling])
871
+ useScene.setState({
872
+ nodes: migrateFloorPlates(
873
+ migrateCeilingRoomLinks(migrateRoomZones(useScene.getState().nodes).nodes).nodes,
874
+ ).nodes as Record<AnyNodeId, AnyNode>,
875
+ })
876
+ const stopDetection = initSpaceDetectionSync(useScene, useEditor)
877
+
878
+ try {
879
+ expect(createWallOnCurrentLevel([4, 0], [4, 6])).not.toBeNull()
880
+
881
+ const nodes = Object.values(useScene.getState().nodes)
882
+ const slabs = nodes.filter((node) => node.type === 'slab')
883
+ const ceilings = nodes.filter((node) => node.type === 'ceiling')
884
+ expect(slabs).toHaveLength(1)
885
+ expect(slabs[0]).toMatchObject({ plateRole: 'base', floorHeight: elevation })
886
+ expect(slabs[0]!.zoneIds).toHaveLength(2)
887
+ expect(ceilings).toHaveLength(2)
888
+ expect(
889
+ slabs.every(
890
+ (node) => node.autoFromWalls && node.elevation === elevation && node.thickness === 0.32,
891
+ ),
892
+ ).toBe(true)
893
+ expect(ceilings.every((node) => node.autoFromWalls)).toBe(true)
894
+ } finally {
895
+ stopDetection()
896
+ }
897
+ },
898
+ )
786
899
 
787
900
  test('close crossings reject the whole insertion without mutating the scene', () => {
788
901
  seedLevel([
@@ -7,6 +7,7 @@ import {
7
7
  } from './../../../components/ui/primitives/tooltip'
8
8
  import { triggerSFX } from './../../../lib/sfx-bus'
9
9
  import { cn } from './../../../lib/utils'
10
+ import { useActionMenuPopupSide } from './placement'
10
11
 
11
12
  interface ActionButtonProps extends React.ComponentProps<typeof Button> {
12
13
  label: string
@@ -14,6 +15,8 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
14
15
  isActive?: boolean
15
16
  tooltipContent?: React.ReactNode
16
17
  tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
18
+ /** The tooltip portals to `<body>`, so a button in a themed scope passes the scope here. */
19
+ tooltipClassName?: string
17
20
  }
18
21
 
19
22
  export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
@@ -26,12 +29,14 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
26
29
  isActive,
27
30
  tooltipContent,
28
31
  tooltipSide,
32
+ tooltipClassName,
29
33
  onClick,
30
34
  onMouseEnter,
31
35
  ...props
32
36
  },
33
37
  ref,
34
38
  ) => {
39
+ const popupSide = useActionMenuPopupSide()
35
40
  return (
36
41
  <Tooltip>
37
42
  <TooltipTrigger asChild>
@@ -68,7 +73,7 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
68
73
  )}
69
74
  </Button>
70
75
  </TooltipTrigger>
71
- <TooltipContent side={tooltipSide}>
76
+ <TooltipContent className={tooltipClassName} side={tooltipSide ?? popupSide}>
72
77
  {tooltipContent || (
73
78
  <p>
74
79
  {label} {shortcut && `(${shortcut})`}
@@ -5,6 +5,7 @@ import Image from 'next/image'
5
5
  import useEditor from '../../../store/use-editor'
6
6
  import { visibleScene } from '../../../store/view-layout'
7
7
  import { ActionButton } from './action-button'
8
+ import { FollowPascalToggle } from './follow-pascal-toggle'
8
9
 
9
10
  export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
10
11
  // Orbit stays useful in 2D-only (it spins the synced floorplan view), but
@@ -29,7 +30,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
29
30
  <>
30
31
  {/* Orbit CCW */}
31
32
  <ActionButton
32
- className="group hover:bg-white/5"
33
+ className="group hover:bg-black/4"
33
34
  label="Orbit Left"
34
35
  onClick={orbitCCW}
35
36
  size="icon"
@@ -46,7 +47,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
46
47
 
47
48
  {/* Orbit CW */}
48
49
  <ActionButton
49
- className="group hover:bg-white/5"
50
+ className="group hover:bg-black/4"
50
51
  label="Orbit Right"
51
52
  onClick={orbitCW}
52
53
  size="icon"
@@ -63,10 +64,13 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
63
64
  </>
64
65
  )}
65
66
 
67
+ {/* Follow Pascal */}
68
+ {!is2dOnly && <FollowPascalToggle />}
69
+
66
70
  {/* Top View */}
67
71
  {!is2dOnly && (
68
72
  <ActionButton
69
- className="group hover:bg-white/5"
73
+ className="group hover:bg-black/4"
70
74
  label="Top View"
71
75
  onClick={goToTopView}
72
76
  size="icon"
@@ -8,6 +8,7 @@ import { cn } from './../../../lib/utils'
8
8
  import useEditor from './../../../store/use-editor'
9
9
  import { ActionButton } from './action-button'
10
10
  import { MeasurementControl } from './measurement-control'
11
+ import { PointAskButton } from './point-ask-button'
11
12
 
12
13
  type ControlId = 'select' | 'box-select' | 'delete'
13
14
 
@@ -29,16 +30,16 @@ const controls: ControlConfig[] = [
29
30
  imageSrc: '/icons/select.webp',
30
31
  label: 'Select',
31
32
  shortcut: 'V',
32
- color: 'hover:bg-blue-500/20 hover:text-blue-400',
33
- activeColor: 'bg-blue-500/20 text-blue-400',
33
+ color: 'hover:bg-blue-500/20 hover:text-blue-600 dark:hover:text-blue-400',
34
+ activeColor: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 dark:hover:text-accent-foreground',
34
35
  },
35
36
  {
36
37
  id: 'delete',
37
38
  icon: Trash2,
38
39
  label: 'Delete',
39
40
  shortcut: 'X',
40
- color: 'hover:bg-red-500/20 hover:text-red-400',
41
- activeColor: 'bg-red-500/20 text-red-400',
41
+ color: 'hover:bg-red-500/20 hover:text-red-600 dark:hover:text-red-400',
42
+ activeColor: 'bg-red-500/20 text-red-600 dark:text-red-400 dark:hover:text-accent-foreground',
42
43
  },
43
44
  ]
44
45
 
@@ -75,14 +76,19 @@ export function ControlModes() {
75
76
 
76
77
  return (
77
78
  <Fragment key={c.id}>
78
- {c.id === 'delete' ? <MeasurementControl /> : null}
79
+ {c.id === 'delete' ? (
80
+ <>
81
+ <PointAskButton />
82
+ <MeasurementControl />
83
+ </>
84
+ ) : null}
79
85
  <ActionButton
80
86
  className={cn(
81
87
  'group text-muted-foreground',
82
88
  !(isImageMode || isActive) && c.color,
83
89
  !isImageMode && isActive && c.activeColor,
84
- isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
85
- isImageMode && !isActive && 'hover:bg-white/5',
90
+ isImageMode && isActive && 'bg-black/6 hover:bg-black/6 dark:bg-white/10',
91
+ isImageMode && !isActive && 'hover:bg-black/4',
86
92
  )}
87
93
  // A static hook for a host app that wants to point a first-run
88
94
  // tour at this button. Nothing here reads it.
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { AnimatePresence, motion } from 'motion/react'
4
+ import { useReducedMotion } from '../../../hooks/use-reduced-motion'
5
+ import useFollowPascal from '../../../store/use-follow-pascal'
6
+
7
+ /**
8
+ * Offered while a build plays and the person has the camera: one press and the camera goes back to
9
+ * following. It comes up from where the toolbar is (origin-aware) and goes the same way.
10
+ */
11
+ export function FollowPascalPill() {
12
+ const shows = useFollowPascal((state) => state.on && state.paused && state.building)
13
+ const resume = useFollowPascal((state) => state.resume)
14
+ const reduced = useReducedMotion()
15
+ return (
16
+ <AnimatePresence>
17
+ {shows ? (
18
+ <motion.button
19
+ animate={{ opacity: 1, y: 0, scale: 1 }}
20
+ className="-translate-x-1/2 fixed bottom-24 left-1/2 z-50 rounded-full border border-violet-400/40 bg-background/90 px-4 py-2 font-medium text-sm text-violet-700 shadow-xl dark:text-violet-200 backdrop-blur-md active:scale-[0.97]"
21
+ exit={{ opacity: 0, y: reduced ? 0 : 8, scale: reduced ? 1 : 0.96 }}
22
+ initial={{ opacity: 0, y: reduced ? 0 : 8, scale: reduced ? 1 : 0.96 }}
23
+ onClick={resume}
24
+ transition={reduced ? { duration: 0.12 } : { type: 'spring', bounce: 0, duration: 0.3 }}
25
+ type="button"
26
+ >
27
+ Resume following
28
+ </motion.button>
29
+ ) : null}
30
+ </AnimatePresence>
31
+ )
32
+ }
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { Video } from 'lucide-react'
4
+ import { cn } from '../../../lib/utils'
5
+ import useFollowPascal from '../../../store/use-follow-pascal'
6
+ import { ActionButton } from './action-button'
7
+
8
+ /** The toolbar's switch for Follow Pascal: the camera follows what the agent builds. */
9
+ export function FollowPascalToggle({
10
+ className,
11
+ tooltipClassName,
12
+ }: {
13
+ className?: string
14
+ tooltipClassName?: string
15
+ }) {
16
+ const on = useFollowPascal((state) => state.on)
17
+ const setOn = useFollowPascal((state) => state.setOn)
18
+ return (
19
+ <ActionButton
20
+ aria-pressed={on}
21
+ className={cn(
22
+ on
23
+ ? 'bg-violet-500/20 text-violet-600 dark:text-violet-300 dark:hover:text-accent-foreground'
24
+ : 'hover:bg-black/4 hover:text-violet-600 dark:hover:text-violet-300',
25
+ className,
26
+ )}
27
+ label={`Follow Pascal: ${on ? 'On' : 'Off'}`}
28
+ onClick={() => setOn(!on)}
29
+ size="icon"
30
+ tooltipClassName={tooltipClassName}
31
+ variant="ghost"
32
+ >
33
+ <Video className="h-6 w-6" />
34
+ </ActionButton>
35
+ )
36
+ }
@@ -11,6 +11,8 @@ import { cn } from './../../../lib/utils'
11
11
  import useEditor from './../../../store/use-editor'
12
12
  import { CameraActions } from './camera-actions'
13
13
  import { ControlModes } from './control-modes'
14
+ import { FollowPascalPill } from './follow-pascal-pill'
15
+ import { type ActionMenuPlacement, ActionMenuPlacementProvider } from './placement'
14
16
  import { SecondaryToggles } from './view-toggles'
15
17
 
16
18
  // Mobile bottom offset matches the viewer's overlap behind the sheet's
@@ -18,7 +20,16 @@ import { SecondaryToggles } from './view-toggles'
18
20
  // just above that strip instead of inside it.
19
21
  const MOBILE_BOTTOM_OFFSET = 24
20
22
 
21
- export function ActionMenu({ className }: { className?: string }) {
23
+ export function ActionMenu({
24
+ className,
25
+ inline = false,
26
+ placement = 'bottom',
27
+ }: {
28
+ className?: string
29
+ // Rendered in the flow of a layout slot instead of floating over the viewer.
30
+ inline?: boolean
31
+ placement?: ActionMenuPlacement
32
+ }) {
22
33
  const isMobile = useIsMobile()
23
34
  const readOnly = useScene((s) => s.readOnly)
24
35
  const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
@@ -47,41 +58,53 @@ export function ActionMenu({ className }: { className?: string }) {
47
58
  ? { duration: 0 }
48
59
  : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
49
60
 
61
+ // Mobile keeps the bottom rail: the sheet owns the rest of the screen.
62
+ const effectivePlacement = isMobile ? 'bottom' : placement
63
+
50
64
  return (
51
- <TooltipProvider>
52
- <motion.div
53
- className={cn(
54
- 'left-1/2 z-50 -translate-x-1/2',
55
- isMobile ? 'absolute origin-bottom scale-90' : 'fixed bottom-6',
56
- 'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
57
- 'transition-colors duration-200 ease-out',
58
- className,
59
- )}
60
- layout
61
- style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
62
- transition={transition}
63
- >
64
- {isMobile ? (
65
- <div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
66
- {/* Row 1: control modes only */}
67
- <div className="flex items-center justify-center gap-1">
68
- <ControlModes />
65
+ <ActionMenuPlacementProvider value={effectivePlacement}>
66
+ <TooltipProvider>
67
+ <FollowPascalPill />
68
+ <motion.div
69
+ className={cn(
70
+ 'z-50',
71
+ isMobile
72
+ ? 'absolute left-1/2 origin-bottom -translate-x-1/2 scale-90'
73
+ : inline
74
+ ? 'relative'
75
+ : effectivePlacement === 'top'
76
+ ? 'fixed top-3 left-1/2 -translate-x-1/2'
77
+ : 'fixed bottom-6 left-1/2 -translate-x-1/2',
78
+ 'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
79
+ 'transition-colors duration-200 ease-out',
80
+ className,
81
+ )}
82
+ layout
83
+ style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
84
+ transition={transition}
85
+ >
86
+ {isMobile ? (
87
+ <div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
88
+ {/* Row 1: control modes only */}
89
+ <div className="flex items-center justify-center gap-1">
90
+ <ControlModes />
91
+ </div>
92
+ {/* Row 2: secondary toggles (orbit + top view hidden) */}
93
+ <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
94
+ <SecondaryToggles />
95
+ </div>
69
96
  </div>
70
- {/* Row 2: secondary toggles (orbit + top view hidden) */}
71
- <div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
97
+ ) : (
98
+ <div className="flex items-center justify-center gap-1 px-2 py-1.5">
99
+ <ControlModes />
100
+ <div className="mx-1 h-5 w-px bg-border" />
72
101
  <SecondaryToggles />
102
+ <div className="mx-1 h-5 w-px bg-border" />
103
+ <CameraActions />
73
104
  </div>
74
- </div>
75
- ) : (
76
- <div className="flex items-center justify-center gap-1 px-2 py-1.5">
77
- <ControlModes />
78
- <div className="mx-1 h-5 w-px bg-border" />
79
- <SecondaryToggles />
80
- <div className="mx-1 h-5 w-px bg-border" />
81
- <CameraActions />
82
- </div>
83
- )}
84
- </motion.div>
85
- </TooltipProvider>
105
+ )}
106
+ </motion.div>
107
+ </TooltipProvider>
108
+ </ActionMenuPlacementProvider>
86
109
  )
87
110
  }