@pascal-app/editor 1.0.0-beta.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,224 @@
1
+ import {
2
+ type AnyNodeId,
3
+ getLevelDisplayName,
4
+ type LevelNode,
5
+ type UnitNode,
6
+ unitWarnings,
7
+ useScene,
8
+ type ZoneNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { AlertTriangle, Trash2, X } from 'lucide-react'
12
+ import { memo, useCallback, useState } from 'react'
13
+ import { useShallow } from 'zustand/react/shallow'
14
+ import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
15
+ import { assignZoneToUnit, enterUnitFocus } from './../../../../../lib/units'
16
+ import { InlineRenameInput } from './inline-rename-input'
17
+ import { routeTreeSelectionToNode, TreeNodeWrapper } from './tree-node'
18
+
19
+ type UnitWarningCode = ReturnType<typeof unitWarnings>[number]['code']
20
+
21
+ const WARNING_LABELS: Record<UnitWarningCode, string> = {
22
+ empty: 'No zones in this unit',
23
+ 'non-adjacent-levels': 'Members sit on non-adjacent levels',
24
+ }
25
+
26
+ const ACTION_BUTTON_CLASS =
27
+ 'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10'
28
+
29
+ interface UnitZoneRowProps {
30
+ zoneId: ZoneNode['id']
31
+ depth: number
32
+ isLast?: boolean
33
+ onRemove?: (zoneId: ZoneNode['id']) => void
34
+ }
35
+
36
+ /** One member zone under a unit: name + level, click selects it. */
37
+ export const UnitZoneRow = memo(function UnitZoneRow({
38
+ zoneId,
39
+ depth,
40
+ isLast,
41
+ onRemove,
42
+ }: UnitZoneRowProps) {
43
+ const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
44
+ const levelLabel = useScene((s) => {
45
+ const level = zone?.parentId ? (s.nodes[zone.parentId as AnyNodeId] as LevelNode | undefined) : undefined
46
+ return level?.type === 'level' ? getLevelDisplayName(level) : null
47
+ })
48
+ const isSelected = useViewer((s) => s.selection.zoneId === zoneId)
49
+ const isHovered = useViewer((s) => s.hoveredId === zoneId)
50
+ const setSelection = useViewer((s) => s.setSelection)
51
+ const setHoveredId = useViewer((s) => s.setHoveredId)
52
+
53
+ const handleClick = useCallback(
54
+ (event: React.MouseEvent) => {
55
+ event.stopPropagation()
56
+ if (!zone) return
57
+ setSelection({ levelId: zone.parentId as LevelNode['id'], zoneId })
58
+ routeTreeSelectionToNode(zone)
59
+ },
60
+ [zone, zoneId, setSelection],
61
+ )
62
+
63
+ if (!zone) return null
64
+
65
+ return (
66
+ <TreeNodeWrapper
67
+ actions={
68
+ onRemove ? (
69
+ <button
70
+ className={ACTION_BUTTON_CLASS}
71
+ onClick={(event) => {
72
+ event.stopPropagation()
73
+ onRemove(zoneId)
74
+ }}
75
+ title="Remove from unit"
76
+ type="button"
77
+ >
78
+ <X className="h-3 w-3" />
79
+ </button>
80
+ ) : undefined
81
+ }
82
+ depth={depth}
83
+ expanded={false}
84
+ hasChildren={false}
85
+ icon={
86
+ <span
87
+ className="h-3 w-3 rounded-sm border border-border/50"
88
+ style={{ backgroundColor: zone.color }}
89
+ />
90
+ }
91
+ isHovered={isHovered}
92
+ isLast={isLast}
93
+ isSelected={isSelected}
94
+ keepIconColor
95
+ label={
96
+ <span className="flex min-w-0 items-baseline gap-1.5">
97
+ <span className="truncate">{zone.name || 'Zone'}</span>
98
+ {levelLabel && (
99
+ <span className="shrink-0 text-muted-foreground text-xs">{levelLabel}</span>
100
+ )}
101
+ </span>
102
+ }
103
+ nodeId={zoneId}
104
+ onClick={handleClick}
105
+ onMouseEnter={() => setHoveredId(zoneId)}
106
+ onMouseLeave={() => setHoveredId(null)}
107
+ onToggle={() => {}}
108
+ />
109
+ )
110
+ })
111
+
112
+ interface UnitTreeNodeProps {
113
+ nodeId: AnyNodeId
114
+ depth: number
115
+ isLast?: boolean
116
+ }
117
+
118
+ export const UnitTreeNode = memo(function UnitTreeNode({
119
+ nodeId,
120
+ depth,
121
+ isLast,
122
+ }: UnitTreeNodeProps) {
123
+ const unitId = nodeId as UnitNode['id']
124
+ const [isEditing, setIsEditing] = useState(false)
125
+ const [expanded, setExpanded] = useState(false)
126
+ const updateNode = useScene((s) => s.updateNode)
127
+ const deleteNode = useScene((s) => s.deleteNode)
128
+ const color = useScene((s) => (s.nodes[nodeId] as UnitNode | undefined)?.color)
129
+ const members = useScene(
130
+ useShallow((s) => (s.nodes[nodeId] as UnitNode | undefined)?.members ?? []),
131
+ )
132
+ const warningCodes = useScene(
133
+ useShallow((s) => {
134
+ const unit = s.nodes[nodeId]
135
+ return unit?.type === 'unit' ? unitWarnings(unit, s.nodes).map((w) => w.code) : []
136
+ }),
137
+ )
138
+ const isSelected = useViewer((s) => s.selection.selectedIds.includes(nodeId))
139
+ const isHovered = useViewer((s) => s.hoveredId === nodeId)
140
+ const setHoveredId = useViewer((s) => s.setHoveredId)
141
+ const isFocused = useViewer((s) => s.focusedUnitId === unitId)
142
+
143
+ const handleClick = useCallback(
144
+ (event: React.MouseEvent) => {
145
+ event.stopPropagation()
146
+ enterUnitFocus(unitId)
147
+ },
148
+ [unitId],
149
+ )
150
+
151
+ const handleRemoveMember = useCallback(
152
+ (zoneId: ZoneNode['id']) => assignZoneToUnit(zoneId, null),
153
+ [],
154
+ )
155
+
156
+ const warningTitle = [...new Set(warningCodes)].map((code) => WARNING_LABELS[code]).join('\n')
157
+
158
+ return (
159
+ <TreeNodeWrapper
160
+ actions={
161
+ <button
162
+ className={ACTION_BUTTON_CLASS}
163
+ onClick={(event) => {
164
+ event.stopPropagation()
165
+ deleteNode(unitId)
166
+ }}
167
+ title="Delete unit"
168
+ type="button"
169
+ >
170
+ <Trash2 className="h-3 w-3" />
171
+ </button>
172
+ }
173
+ depth={depth}
174
+ expanded={expanded}
175
+ hasChildren={members.length > 0}
176
+ icon={
177
+ <ColorDot
178
+ color={color ?? '#f59e0b'}
179
+ onChange={(next) => updateNode(unitId, { color: next })}
180
+ />
181
+ }
182
+ isHovered={isHovered}
183
+ isLast={isLast}
184
+ isSelected={isSelected}
185
+ keepIconColor
186
+ label={
187
+ <span className="flex min-w-0 items-center gap-1.5">
188
+ <InlineRenameInput
189
+ defaultName="Unit"
190
+ isEditing={isEditing}
191
+ nodeId={nodeId}
192
+ onStartEditing={() => setIsEditing(true)}
193
+ onStopEditing={() => setIsEditing(false)}
194
+ />
195
+ {isFocused && (
196
+ <span className="shrink-0 rounded-full bg-primary/15 px-1.5 text-[10px] text-primary leading-4">
197
+ Focused
198
+ </span>
199
+ )}
200
+ {warningCodes.length > 0 && (
201
+ <span className="shrink-0 text-muted-foreground" title={warningTitle}>
202
+ <AlertTriangle className="h-3 w-3" />
203
+ </span>
204
+ )}
205
+ </span>
206
+ }
207
+ nodeId={nodeId}
208
+ onClick={handleClick}
209
+ onMouseEnter={() => setHoveredId(nodeId)}
210
+ onMouseLeave={() => setHoveredId(null)}
211
+ onToggle={() => setExpanded((value) => !value)}
212
+ >
213
+ {members.map((zoneId, index) => (
214
+ <UnitZoneRow
215
+ depth={depth + 1}
216
+ isLast={index === members.length - 1}
217
+ key={zoneId}
218
+ onRemove={handleRemoveMember}
219
+ zoneId={zoneId}
220
+ />
221
+ ))}
222
+ </TreeNodeWrapper>
223
+ )
224
+ })
@@ -0,0 +1,22 @@
1
+ import { Checkbox } from './../../../primitives/checkbox'
2
+
3
+ /** Membership toggle shown on zone rows while a unit is focused. */
4
+ export function ZoneMembershipCheckbox({
5
+ checked,
6
+ onToggle,
7
+ unitName,
8
+ }: {
9
+ checked: boolean
10
+ onToggle: () => void
11
+ unitName: string
12
+ }) {
13
+ return (
14
+ <Checkbox
15
+ aria-label={`In ${unitName}`}
16
+ checked={checked}
17
+ className="mr-2"
18
+ onCheckedChange={onToggle}
19
+ onDoubleClick={(event) => event.stopPropagation()}
20
+ />
21
+ )
22
+ }
@@ -3,9 +3,11 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  import { memo, useCallback, useState } from 'react'
4
4
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
5
  import { formatAreaLabel } from './../../../../../lib/measurements'
6
+ import { toggleZoneMembership } from './../../../../../lib/units'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, TreeNodeWrapper } from './tree-node'
8
9
  import { TreeNodeActions } from './tree-node-actions'
10
+ import { ZoneMembershipCheckbox } from './zone-membership-checkbox'
9
11
 
10
12
  interface ZoneTreeNodeProps {
11
13
  nodeId: ZoneNode['id']
@@ -28,6 +30,11 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
28
30
  const setSelection = useViewer((state) => state.setSelection)
29
31
  const setHoveredId = useViewer((state) => state.setHoveredId)
30
32
  const unit = useViewer((state) => state.unit)
33
+ const focusedUnitId = useViewer((state) => state.focusedUnitId)
34
+ const focusedUnit = useScene((s) => {
35
+ const focused = focusedUnitId ? s.nodes[focusedUnitId] : undefined
36
+ return focused?.type === 'unit' ? focused : null
37
+ })
31
38
 
32
39
  const handleClick = useCallback(() => setSelection({ zoneId: nodeId }), [nodeId, setSelection])
33
40
  const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId])
@@ -50,15 +57,25 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
50
57
  isHovered={isHovered}
51
58
  isLast={isLast}
52
59
  isSelected={isSelected}
60
+ keepIconColor
53
61
  isVisible={isVisible}
54
62
  label={
55
- <InlineRenameInput
56
- defaultName={defaultName}
57
- isEditing={isEditing}
58
- nodeId={nodeId}
59
- onStartEditing={handleStartEditing}
60
- onStopEditing={handleStopEditing}
61
- />
63
+ <span className="flex min-w-0 items-center">
64
+ {focusedUnit && (
65
+ <ZoneMembershipCheckbox
66
+ checked={focusedUnit.members.includes(nodeId)}
67
+ onToggle={() => toggleZoneMembership(focusedUnit.id, nodeId)}
68
+ unitName={focusedUnit.name || 'Unit'}
69
+ />
70
+ )}
71
+ <InlineRenameInput
72
+ defaultName={defaultName}
73
+ isEditing={isEditing}
74
+ nodeId={nodeId}
75
+ onStartEditing={handleStartEditing}
76
+ onStopEditing={handleStopEditing}
77
+ />
78
+ </span>
62
79
  }
63
80
  onClick={handleClick}
64
81
  onDoubleClick={handleDoubleClick}
@@ -86,6 +86,8 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
86
86
  <Tooltip key={tab.id}>
87
87
  <TooltipTrigger asChild>
88
88
  <button
89
+ aria-label={tab.label}
90
+ aria-pressed={showActive}
89
91
  className={cn(
90
92
  'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
91
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
  }
@@ -1,4 +1,5 @@
1
1
  import type { AnyNode, AssetInput } from '@pascal-app/core'
2
+ import { isProceduralItem } from '@pascal-app/core/procedural-items'
2
3
  import type { ReactNode } from 'react'
3
4
  import { cn } from '../../lib/utils'
4
5
 
@@ -35,6 +36,7 @@ export function resolveAssetSnapTarget(attachTo: AssetInput['attachTo']): SnapTa
35
36
 
36
37
  export function resolveNodeSnapTarget(node: AnyNode | null | undefined): SnapTarget | null {
37
38
  if (!node) return null
39
+ if (isProceduralItem(node)) return resolveAssetSnapTarget(node.recipe.mounting?.attachTo)
38
40
  if ('roofSegmentId' in node && typeof node.roofSegmentId === 'string') return 'roof'
39
41
  if (node.type === 'downspout') return 'roof'
40
42
  if (node.type === 'door' || node.type === 'window') return 'wall'
@@ -9,12 +9,13 @@ import {
9
9
  useScene,
10
10
  type ZoneNode,
11
11
  } from '@pascal-app/core'
12
- import { useViewer } from '@pascal-app/viewer'
12
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
13
13
  import { ArrowLeft, ChevronRight, Layers } from 'lucide-react'
14
14
  import Link from 'next/link'
15
15
  import type { ReactNode } from 'react'
16
16
  import { useShallow } from 'zustand/react/shallow'
17
17
  import { cn } from '../../lib/utils'
18
+ import { ViewerUnitsPanel } from './viewer-units-panel'
18
19
 
19
20
  const getNodeName = (node: AnyNode): string => {
20
21
  if ('name' in node && node.name) return node.name
@@ -63,6 +64,7 @@ export const ViewerSceneHeader = ({
63
64
  const selectedNode = useScene((s) =>
64
65
  firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
65
66
  )
67
+ const sceneNodes = useScene((s) => s.nodes)
66
68
  // Highest first so the list reads top-down like a building section.
67
69
  const levels = useScene(
68
70
  useShallow((s) => {
@@ -76,6 +78,7 @@ export const ViewerSceneHeader = ({
76
78
 
77
79
  const handleLevelClick = (levelId: LevelNode['id']) => {
78
80
  // When switching levels, deselect zone and items
81
+ if (levelId !== selection.levelId) markPerfAction('level-switch', levelId)
79
82
  useViewer.getState().setSelection({ levelId })
80
83
  }
81
84
 
@@ -230,6 +233,7 @@ export const ViewerSceneHeader = ({
230
233
  </div>
231
234
  </div>
232
235
  )}
236
+ {building && <ViewerUnitsPanel nodes={sceneNodes} />}
233
237
  </div>
234
238
  )
235
239
  }
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
4
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
5
5
  import type { ReactNode } from 'react'
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react'
7
7
  import { useShallow } from 'zustand/react/shallow'
@@ -134,6 +134,9 @@ export function ViewerStage({
134
134
  const chooseLevel = useCallback(
135
135
  (nextLevelId: string, notify = true) => {
136
136
  setInternalLevelId(nextLevelId)
137
+ if (notify && nextLevelId !== useViewer.getState().selection.levelId) {
138
+ markPerfAction('level-switch', nextLevelId)
139
+ }
137
140
  selectViewerLevel(scene?.nodes ?? useScene.getState().nodes, nextLevelId)
138
141
  if (notify) onLevelChange?.(nextLevelId)
139
142
  },
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ buildUnitReport,
6
+ type LevelNode,
7
+ type UnitKind,
8
+ type UnitNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useMemo } from 'react'
12
+ import { getAreaUnitLabel, squareMetersToAreaUnit } from '../../lib/measurements'
13
+ import { cn } from '../../lib/utils'
14
+
15
+ const UNIT_KIND_LABELS: Record<UnitKind, string> = {
16
+ apartment: 'Apartment',
17
+ 'hotel-room': 'Hotel room',
18
+ commercial: 'Commercial',
19
+ common: 'Common',
20
+ }
21
+
22
+ type UnitRow = {
23
+ unit: UnitNode
24
+ zoneCount: number
25
+ areaM2: number
26
+ /** Levels carrying member zones, lowest ordinal first. */
27
+ levelIds: LevelNode['id'][]
28
+ }
29
+
30
+ /**
31
+ * Read-only unit list shared by the editor preview and the published viewer.
32
+ * Clicking a row focuses the unit and reveals a member floor through the shared
33
+ * `useViewer.selection`; a second click clears the focus. Hidden without units.
34
+ */
35
+ export function ViewerUnitsPanel({ nodes }: { nodes: Readonly<Record<string, AnyNode>> }) {
36
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
37
+ const areaUnit = useViewer((s) => s.unit)
38
+
39
+ const rows = useMemo<UnitRow[]>(
40
+ () =>
41
+ Object.values(nodes)
42
+ .filter((node): node is UnitNode => node.type === 'unit')
43
+ .map((unit) => {
44
+ const report = buildUnitReport(unit, nodes)
45
+ const ordinals = new Map<LevelNode['id'], number>()
46
+ for (const member of report.members) {
47
+ if (member.levelId && member.levelOrdinal !== null) {
48
+ ordinals.set(member.levelId, member.levelOrdinal)
49
+ }
50
+ }
51
+ return {
52
+ unit,
53
+ zoneCount: report.memberCount,
54
+ areaM2: report.grossAreaM2,
55
+ levelIds: [...ordinals.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id),
56
+ }
57
+ }),
58
+ [nodes],
59
+ )
60
+
61
+ if (rows.length === 0) return null
62
+
63
+ return (
64
+ <div className="corner-smooth pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl">
65
+ <span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
66
+ Units
67
+ </span>
68
+ <div className="flex flex-col">
69
+ {rows.map((row) => {
70
+ const isFocused = row.unit.id === focusedUnitId
71
+ const area = Math.round(squareMetersToAreaUnit(row.areaM2, areaUnit))
72
+ const details = `${UNIT_KIND_LABELS[row.unit.kind]} · ${row.zoneCount} ${row.zoneCount === 1 ? 'zone' : 'zones'} · ${area} ${getAreaUnitLabel(areaUnit)}`
73
+ return (
74
+ <button
75
+ className={cn(
76
+ 'group/row relative flex w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 py-1.5 text-sm transition-all duration-200',
77
+ isFocused
78
+ ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
79
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
80
+ )}
81
+ key={row.unit.id}
82
+ onClick={() => {
83
+ const viewer = useViewer.getState()
84
+ if (isFocused) {
85
+ viewer.setFocusedUnit(null)
86
+ return
87
+ }
88
+ viewer.setFocusedUnit(row.unit.id)
89
+ // Same rule as the editor: keep the current floor when it
90
+ // already carries a member zone, otherwise reveal the lowest one.
91
+ const current = viewer.selection.levelId
92
+ const levelId =
93
+ current && row.levelIds.includes(current) ? current : row.levelIds[0]
94
+ if (levelId && levelId !== current) viewer.setSelection({ levelId })
95
+ }}
96
+ type="button"
97
+ >
98
+ <div className="flex min-w-0 flex-1 items-center gap-2">
99
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
100
+ <span
101
+ className="h-2.5 w-2.5 rounded-full"
102
+ style={{ backgroundColor: row.unit.color }}
103
+ />
104
+ </span>
105
+ <div className="min-w-0 flex-1 text-left">
106
+ <div className="truncate">{row.unit.name}</div>
107
+ <div className="truncate text-[11px] text-muted-foreground">{details}</div>
108
+ </div>
109
+ </div>
110
+ </button>
111
+ )
112
+ })}
113
+ </div>
114
+ </div>
115
+ )
116
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { flushSync } from 'react-dom'
4
+ import { requestWalkthroughPointerLock } from '../lib/walkthrough-pointer-lock'
4
5
  import useEditor from '../store/use-editor'
5
6
  import { ViewerControlsBar } from './viewer/viewer-controls-bar'
6
7
  import { ViewerSceneHeader } from './viewer/viewer-scene-header'
@@ -12,28 +13,6 @@ type ProjectOwner = {
12
13
  image: string | null
13
14
  }
14
15
 
15
- function requestWalkthroughPointerLock() {
16
- const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
17
- if (!canvas) return
18
-
19
- if (!canvas.hasAttribute('tabindex')) {
20
- canvas.tabIndex = -1
21
- }
22
- canvas.focus({ preventScroll: true })
23
-
24
- if (document.pointerLockElement === canvas) return
25
-
26
- try {
27
- // The request can also reject ASYNC (browser cooldown after a recent
28
- // unlock) — swallow it like the P-resume path; clicking the canvas
29
- // re-requests once the cooldown passes.
30
- const result = canvas.requestPointerLock?.() as Promise<void> | undefined
31
- if (result && typeof result.catch === 'function') result.catch(() => {})
32
- } catch {
33
- return
34
- }
35
- }
36
-
37
16
  interface ViewerOverlayProps {
38
17
  projectName?: string | null
39
18
  owner?: ProjectOwner | null
@@ -13,6 +13,9 @@ export const ViewerZoneSystem = () => {
13
13
  const { levelId, zoneId } = useViewer.getState().selection
14
14
  const structureLayer = useEditor.getState().structureLayer
15
15
  const nodes = useScene.getState().nodes
16
+ // Snapshot capture is a clean, camera-only surface — zone geometry and
17
+ // tags stay out of the framed shot (mirrors the editor ZoneSystem's gate).
18
+ const isCaptureMode = useEditor.getState().isCaptureMode
16
19
  // During any active interaction zone labels step back entirely (Sims-light).
17
20
  const zoneLabelsHidden =
18
21
  resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
@@ -31,7 +34,8 @@ export const ViewerZoneSystem = () => {
31
34
  // The editor ZoneSystem handles the selected zone's opacity animation.
32
35
  const isSelected = id === zoneId
33
36
  const shouldShowGeometry =
34
- (structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
37
+ !isCaptureMode &&
38
+ ((structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected)
35
39
  if (!obj.visible) obj.visible = true
36
40
  obj.traverse((child) => {
37
41
  if ((child as Mesh).isMesh) {
@@ -40,7 +44,7 @@ export const ViewerZoneSystem = () => {
40
44
  })
41
45
 
42
46
  // Labels: always visible on the current level (regardless of mode or zone selection)
43
- const showLabel = !zoneLabelsHidden && !!levelId && isOnSelectedLevel
47
+ const showLabel = !isCaptureMode && !zoneLabelsHidden && !!levelId && isOnSelectedLevel
44
48
  const targetOpacity = showLabel ? '1' : '0'
45
49
  const labelEl = document.getElementById(`${id}-label`)
46
50
  if (labelEl && labelEl.style.opacity !== targetOpacity) {
@@ -17,6 +17,24 @@ export type WalkthroughHudProps = {
17
17
  children?: ReactNode
18
18
  }
19
19
 
20
+ /** The centered walkthrough pointer: a dot that grows into a green ring over
21
+ * an interactable (door / window / elevator). Also mounted by the snapshot
22
+ * capture overlay so walk / drone framing keeps the same E-to-open pointer. */
23
+ export function WalkthroughCrosshair({ interact }: { interact: WalkthroughInteract }) {
24
+ return (
25
+ <div className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
26
+ <div
27
+ className={cn(
28
+ 'rounded-full transition-all duration-150',
29
+ interact
30
+ ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
31
+ : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
32
+ )}
33
+ />
34
+ </div>
35
+ )
36
+ }
37
+
20
38
  export function WalkthroughHud({
21
39
  floorLabel,
22
40
  zoneLabel,
@@ -51,16 +69,7 @@ export function WalkthroughHud({
51
69
  {children}
52
70
  </div>
53
71
 
54
- <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
55
- <div
56
- className={cn(
57
- 'rounded-full transition-all duration-150',
58
- interact
59
- ? 'h-4 w-4 border-2 border-emerald-400 bg-emerald-400/10'
60
- : 'h-1.5 w-1.5 bg-white/80 shadow-[0_0_2px_rgba(0,0,0,0.6)]',
61
- )}
62
- />
63
- </div>
72
+ <WalkthroughCrosshair interact={interact} />
64
73
 
65
74
  <div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center gap-2">
66
75
  {suspended ? (