@pascal-app/editor 0.9.2 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
14
14
  import { Icon } from '@iconify/react'
15
15
  import { Move, Trash2 } from 'lucide-react'
16
16
  import { type ComponentType, lazy, Suspense, useCallback } from 'react'
17
+ import { resolveMoveActionNode } from '../../../lib/direct-manipulation'
17
18
  import { sfxEmitter } from '../../../lib/sfx-bus'
18
19
  import { collectZoneContentIds } from '../../../lib/zone-content'
19
20
  import useEditor from '../../../store/use-editor'
@@ -90,10 +91,11 @@ export function ParametricInspector({
90
91
 
91
92
  const handleMove = useCallback(() => {
92
93
  if (!selectedId) return
93
- const node = useScene.getState().nodes[selectedId]
94
+ const sceneNodes = useScene.getState().nodes
95
+ const node = sceneNodes[selectedId]
94
96
  if (!node) return
95
97
  sfxEmitter.emit('sfx:item-pick')
96
- useEditor.getState().setMovingNode(node as any)
98
+ useEditor.getState().setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
97
99
  clearSelection()
98
100
  }, [selectedId, clearSelection])
99
101
 
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { formatSelectionBreakdown } from './selection-breakdown'
3
+
4
+ describe('formatSelectionBreakdown', () => {
5
+ test('counts per type in first-appearance order, pluralizing with +s', () => {
6
+ expect(formatSelectionBreakdown(['slab', 'stair', 'fence', 'fence'])).toBe(
7
+ '1 slab · 1 stair · 2 fences',
8
+ )
9
+ })
10
+
11
+ test('humanizes hyphenated kinds', () => {
12
+ expect(formatSelectionBreakdown(['roof-segment', 'roof-segment', 'wall'])).toBe(
13
+ '2 roof segments · 1 wall',
14
+ )
15
+ })
16
+
17
+ test('skips missing nodes', () => {
18
+ expect(formatSelectionBreakdown(['wall', undefined, null])).toBe('1 wall')
19
+ })
20
+
21
+ test('empty selection formats to an empty string', () => {
22
+ expect(formatSelectionBreakdown([])).toBe('')
23
+ })
24
+ })
@@ -0,0 +1,20 @@
1
+ /**
2
+ * "1 slab · 1 stair · 2 fences" — one entry per node type in first-appearance
3
+ * order so the line stays stable while shift-clicking. Labels derive from the
4
+ * type id ('roof-segment' → 'roof segment'); pluralization is a simple +s
5
+ * (the codebase has no pluralize helper and no current kind needs one).
6
+ * Missing nodes (stale ids) are skipped.
7
+ */
8
+ export function formatSelectionBreakdown(types: Array<string | null | undefined>): string {
9
+ const counts = new Map<string, number>()
10
+ for (const type of types) {
11
+ if (!type) continue
12
+ counts.set(type, (counts.get(type) ?? 0) + 1)
13
+ }
14
+ const parts: string[] = []
15
+ for (const [type, count] of counts) {
16
+ const label = type.replace(/-/g, ' ')
17
+ parts.push(`${count} ${count === 1 ? label : `${label}s`}`)
18
+ }
19
+ return parts.join(' · ')
20
+ }
@@ -1,4 +1,9 @@
1
- import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core'
1
+ import {
2
+ clearSceneHistory,
3
+ emitter,
4
+ useScene,
5
+ validateBuildJson,
6
+ } from '@pascal-app/core'
2
7
  import { useViewer } from '@pascal-app/viewer'
3
8
  import { TreeView, VisualJson } from '@visual-json/react'
4
9
  import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
@@ -20,6 +25,7 @@ import {
20
25
  } from './../../../../../components/ui/primitives/dialog'
21
26
  import { Switch } from './../../../../../components/ui/primitives/switch'
22
27
  import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
28
+ import useFloorplanMode from './../../../../../store/use-floorplan-mode'
23
29
  import { AudioSettingsDialog } from './audio-settings-dialog'
24
30
  import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
25
31
  import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
@@ -187,6 +193,7 @@ export function SettingsPanel({
187
193
  const exportScene = useViewer((state) => state.exportScene)
188
194
  const shadows = useViewer((state) => state.shadows)
189
195
  const setPhase = useEditor((state) => state.setPhase)
196
+ const floorplanMode = useFloorplanMode((state) => state.mode)
190
197
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
191
198
  const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
192
199
  const sceneGraphValue = useMemo(
@@ -236,7 +243,7 @@ export function SettingsPanel({
236
243
  result: {
237
244
  ok: false,
238
245
  parsed: null,
239
- stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 },
246
+ stats: { total: 0, byType: {}, pluginTypes: {}, unknownTypes: {}, floorAreaM2: 0 },
240
247
  errors: [
241
248
  {
242
249
  severity: 'error',
@@ -394,14 +401,17 @@ export function SettingsPanel({
394
401
  </div>
395
402
 
396
403
  <div className="space-y-2">
397
- <div className="font-medium text-muted-foreground text-xs">Floorplan</div>
404
+ <div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
405
+ <span>Floor plan</span>
406
+ <span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
407
+ </div>
398
408
  <Button
399
409
  className="w-full justify-start gap-2"
400
410
  onClick={() => exportFloorplanPdf('full')}
401
411
  variant="outline"
402
412
  >
403
413
  <MapIcon className="size-4" />
404
- Full floorplan
414
+ Full floor plan
405
415
  </Button>
406
416
  <Button
407
417
  className="w-full justify-start gap-2"
@@ -69,6 +69,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
69
69
  {
70
70
  title: 'Selection',
71
71
  shortcuts: [
72
+ {
73
+ keys: ['Cmd/Ctrl', 'C'],
74
+ action: 'Copy the selected objects',
75
+ note: 'The copied selection can be pasted into another level, project, or browser tab.',
76
+ },
77
+ {
78
+ keys: ['Cmd/Ctrl', 'X'],
79
+ action: 'Cut the selected objects',
80
+ note: 'Copies the selection to the clipboard, then removes it from this scene.',
81
+ },
82
+ {
83
+ keys: ['Cmd/Ctrl', 'V'],
84
+ action: 'Paste and place copied objects',
85
+ note:
86
+ 'Carries a preview under the cursor. Click to place it, or press Escape to cancel.',
87
+ },
72
88
  {
73
89
  keys: ['Cmd/Ctrl', 'Left click'],
74
90
  action: 'Add or remove an object from multi-selection',
@@ -1,4 +1,4 @@
1
- import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
1
+ import { type BuildingNode, DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { Building2, Plus } from 'lucide-react'
4
4
  import { memo, useState } from 'react'
@@ -43,6 +43,7 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({
43
43
  const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length
44
44
  const newLevel = LevelNode.parse({
45
45
  level: levelCount,
46
+ height: DEFAULT_LEVEL_HEIGHT,
46
47
  children: [],
47
48
  parentId: nodeId,
48
49
  })
@@ -2,6 +2,7 @@ import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
4
  type BuildingNode,
5
+ DEFAULT_LEVEL_HEIGHT,
5
6
  emitter,
6
7
  type GuideNode,
7
8
  LevelNode,
@@ -926,6 +927,7 @@ const LevelsSection = memo(function LevelsSection({
926
927
  const handleAddLevel = () => {
927
928
  const newLevel = LevelNode.parse({
928
929
  level: levels.length,
930
+ height: DEFAULT_LEVEL_HEIGHT,
929
931
  children: [],
930
932
  parentId: building.id,
931
933
  })
@@ -0,0 +1,466 @@
1
+ 'use client'
2
+
3
+ import { emitter } from '@pascal-app/core'
4
+ import {
5
+ CLAY_PALETTE,
6
+ type EdgeMode,
7
+ getSceneTheme,
8
+ SCENE_THEMES,
9
+ useViewer,
10
+ } from '@pascal-app/viewer'
11
+ import {
12
+ Box,
13
+ Camera,
14
+ Check,
15
+ Contrast,
16
+ Diamond,
17
+ Eye,
18
+ EyeOff,
19
+ Footprints,
20
+ Layers,
21
+ Layers2,
22
+ Palette,
23
+ PenLine,
24
+ SlidersHorizontal,
25
+ Sparkles,
26
+ Square,
27
+ SwatchBook,
28
+ } from 'lucide-react'
29
+ import { cn } from '../../lib/utils'
30
+ import { ActionButton } from '../ui/action-menu/action-button'
31
+ import {
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuItem,
35
+ DropdownMenuSeparator,
36
+ DropdownMenuSub,
37
+ DropdownMenuSubContent,
38
+ DropdownMenuSubTrigger,
39
+ DropdownMenuTrigger,
40
+ } from '../ui/primitives/dropdown-menu'
41
+ import { TooltipProvider } from '../ui/primitives/tooltip'
42
+
43
+ const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
44
+ stacked: 'Stacked',
45
+ exploded: 'Exploded',
46
+ solo: 'Solo',
47
+ }
48
+
49
+ const wallModeConfig = {
50
+ up: {
51
+ icon: (props: any) => (
52
+ <img alt="Full height" height={28} src="/icons/room.webp" width={28} {...props} />
53
+ ),
54
+ label: 'Full height',
55
+ },
56
+ cutaway: {
57
+ icon: (props: any) => (
58
+ <img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
59
+ ),
60
+ label: 'Cutaway',
61
+ },
62
+ down: {
63
+ icon: (props: any) => (
64
+ <img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
65
+ ),
66
+ label: 'Low',
67
+ },
68
+ }
69
+
70
+ const SHADING_OPTIONS = [
71
+ { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
72
+ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
73
+ ] as const
74
+
75
+ const EDGE_OPTIONS = [
76
+ { id: 'off', name: 'Off', detail: 'No edge lines' },
77
+ { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
78
+ { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
79
+ ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
80
+
81
+ // Keep the dropdown open when flipping an in-place toggle row.
82
+ const keepOpen = (event: Event, fn: () => void) => {
83
+ event.preventDefault()
84
+ fn()
85
+ }
86
+
87
+ // Scans + guides folded into one control. A baked GLB carries none of its own,
88
+ // but the GLB viewer re-adds them from scene data when the privacy flags allow,
89
+ // so the toggle shows for whichever exist.
90
+ function VisibilityMenu({
91
+ canShowScans,
92
+ canShowGuides,
93
+ }: {
94
+ canShowScans: boolean
95
+ canShowGuides: boolean
96
+ }) {
97
+ const showScans = useViewer((s) => s.showScans)
98
+ const showGuides = useViewer((s) => s.showGuides)
99
+ return (
100
+ <DropdownMenu>
101
+ <DropdownMenuTrigger asChild>
102
+ <ActionButton
103
+ className="hover:bg-white/5 hover:text-foreground"
104
+ label="Visibility"
105
+ size="icon"
106
+ tooltipSide="top"
107
+ variant="ghost"
108
+ >
109
+ <Eye className="h-5 w-5" />
110
+ </ActionButton>
111
+ </DropdownMenuTrigger>
112
+ <DropdownMenuContent align="center" className="min-w-44" side="top">
113
+ {canShowScans && (
114
+ <DropdownMenuItem
115
+ onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowScans(!showScans))}
116
+ >
117
+ <img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.webp" />
118
+ <span>Scans</span>
119
+ {showScans ? (
120
+ <Eye className="ml-auto h-4 w-4 text-foreground" />
121
+ ) : (
122
+ <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
123
+ )}
124
+ </DropdownMenuItem>
125
+ )}
126
+ {canShowGuides && (
127
+ <DropdownMenuItem
128
+ onSelect={(e) => keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))}
129
+ >
130
+ <img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.webp" />
131
+ <span>Guides</span>
132
+ {showGuides ? (
133
+ <Eye className="ml-auto h-4 w-4 text-foreground" />
134
+ ) : (
135
+ <EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
136
+ )}
137
+ </DropdownMenuItem>
138
+ )}
139
+ </DropdownMenuContent>
140
+ </DropdownMenu>
141
+ )
142
+ }
143
+
144
+ // One "Display" button gathering shadows, camera projection, colors, render
145
+ // mode, scene theme and edges.
146
+ function DisplayMenu() {
147
+ const cameraMode = useViewer((s) => s.cameraMode)
148
+ const shading = useViewer((s) => s.shading)
149
+ const textures = useViewer((s) => s.textures)
150
+ const shadows = useViewer((s) => s.shadows)
151
+ const sceneTheme = useViewer((s) => s.sceneTheme)
152
+ const edges = useViewer((s) => s.edges)
153
+ const activeShading = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
154
+ const activeTheme = getSceneTheme(sceneTheme)
155
+ const activeEdges = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
156
+ return (
157
+ <DropdownMenu>
158
+ <DropdownMenuTrigger asChild>
159
+ <ActionButton
160
+ className="hover:bg-white/5 hover:text-foreground"
161
+ label="Display settings"
162
+ size="icon"
163
+ tooltipSide="top"
164
+ variant="ghost"
165
+ >
166
+ <SlidersHorizontal className="h-5 w-5" />
167
+ </ActionButton>
168
+ </DropdownMenuTrigger>
169
+ <DropdownMenuContent align="center" className="min-w-56" side="top">
170
+ <DropdownMenuItem
171
+ onSelect={(e) => keepOpen(e, () => useViewer.getState().setShadows(!shadows))}
172
+ >
173
+ <Contrast className="h-4 w-4" />
174
+ <span>Shadows</span>
175
+ <span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
176
+ </DropdownMenuItem>
177
+ <DropdownMenuItem
178
+ onSelect={(e) =>
179
+ keepOpen(e, () =>
180
+ useViewer
181
+ .getState()
182
+ .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
183
+ )
184
+ }
185
+ >
186
+ <Camera className="h-4 w-4" />
187
+ <span>Camera</span>
188
+ <span className="ml-auto text-muted-foreground text-xs">
189
+ {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
190
+ </span>
191
+ </DropdownMenuItem>
192
+ <DropdownMenuItem
193
+ onSelect={(e) => keepOpen(e, () => useViewer.getState().setTextures(!textures))}
194
+ >
195
+ {textures ? <Palette className="h-4 w-4" /> : <Square className="h-4 w-4" />}
196
+ <span>Colors</span>
197
+ <span className="ml-auto text-muted-foreground text-xs">
198
+ {textures ? 'Colored' : 'Monochrome'}
199
+ </span>
200
+ </DropdownMenuItem>
201
+
202
+ <DropdownMenuSeparator />
203
+
204
+ <DropdownMenuSub>
205
+ <DropdownMenuSubTrigger>
206
+ <activeShading.icon className="h-4 w-4" />
207
+ <span>Render</span>
208
+ <span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
209
+ </DropdownMenuSubTrigger>
210
+ <DropdownMenuSubContent className="min-w-56">
211
+ {SHADING_OPTIONS.map((option) => {
212
+ const OptionIcon = option.icon
213
+ return (
214
+ <DropdownMenuItem
215
+ key={option.id}
216
+ onSelect={() => useViewer.getState().setShading(option.id)}
217
+ >
218
+ <OptionIcon className="h-4 w-4" />
219
+ <div className="flex flex-col">
220
+ <span className="text-foreground">{option.name}</span>
221
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
222
+ </div>
223
+ {shading === option.id ? (
224
+ <Check className="ml-auto h-4 w-4 text-foreground" />
225
+ ) : null}
226
+ </DropdownMenuItem>
227
+ )
228
+ })}
229
+ </DropdownMenuSubContent>
230
+ </DropdownMenuSub>
231
+
232
+ <DropdownMenuSub>
233
+ <DropdownMenuSubTrigger>
234
+ <SwatchBook className="h-4 w-4" />
235
+ <span>Theme</span>
236
+ <span className="ml-auto truncate text-muted-foreground text-xs">
237
+ {activeTheme.name}
238
+ </span>
239
+ </DropdownMenuSubTrigger>
240
+ <DropdownMenuSubContent className="min-w-48">
241
+ {SCENE_THEMES.map((t) => {
242
+ const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
243
+ (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role],
244
+ )
245
+ return (
246
+ <DropdownMenuItem
247
+ className="gap-2"
248
+ key={t.id}
249
+ onSelect={() => useViewer.getState().setSceneTheme(t.id)}
250
+ >
251
+ <span
252
+ className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
253
+ style={{ backgroundColor: t.background }}
254
+ >
255
+ {swatches.map((color, index) => (
256
+ <span key={`${t.id}-${index}`} style={{ backgroundColor: color }} />
257
+ ))}
258
+ </span>
259
+ <span>{t.name}</span>
260
+ {sceneTheme === t.id ? <Check className="ml-auto h-4 w-4" /> : null}
261
+ </DropdownMenuItem>
262
+ )
263
+ })}
264
+ </DropdownMenuSubContent>
265
+ </DropdownMenuSub>
266
+
267
+ <DropdownMenuSub>
268
+ <DropdownMenuSubTrigger>
269
+ <PenLine className="h-4 w-4" />
270
+ <span>Edges</span>
271
+ <span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
272
+ </DropdownMenuSubTrigger>
273
+ <DropdownMenuSubContent className="min-w-56">
274
+ {EDGE_OPTIONS.map((option) => (
275
+ <DropdownMenuItem
276
+ key={option.id}
277
+ onSelect={() => useViewer.getState().setEdges(option.id)}
278
+ >
279
+ <div className="flex flex-col">
280
+ <span className="text-foreground">{option.name}</span>
281
+ <span className="text-muted-foreground text-xs">{option.detail}</span>
282
+ </div>
283
+ {edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
284
+ </DropdownMenuItem>
285
+ ))}
286
+ </DropdownMenuSubContent>
287
+ </DropdownMenuSub>
288
+ </DropdownMenuContent>
289
+ </DropdownMenu>
290
+ )
291
+ }
292
+
293
+ export type ViewerControlsBarProps = {
294
+ canShowScans?: boolean
295
+ canShowGuides?: boolean
296
+ /** A baked GLB is the active artifact: hide controls it can't honor (wall
297
+ * modes aren't baked into the GLB). */
298
+ glbActive?: boolean
299
+ /** In GLB mode, whether scans/guides were re-added from scene data — so the
300
+ * visibility control surfaces the matching toggle even though the artifact
301
+ * itself carries none. */
302
+ glbHasScans?: boolean
303
+ glbHasGuides?: boolean
304
+ walkthroughActive?: boolean
305
+ onWalkthroughToggle: () => void
306
+ className?: string
307
+ }
308
+
309
+ export const ViewerControlsBar = ({
310
+ canShowScans = true,
311
+ canShowGuides = true,
312
+ glbActive = false,
313
+ glbHasScans = false,
314
+ glbHasGuides = false,
315
+ walkthroughActive = false,
316
+ onWalkthroughToggle,
317
+ className,
318
+ }: ViewerControlsBarProps) => {
319
+ const levelMode = useViewer((s) => s.levelMode)
320
+ const wallMode = useViewer((s) => s.wallMode)
321
+ // Sessions may carry a stale mode outside the cycle (e.g. the retired
322
+ // 'translucent'); render and cycle it as cutaway instead of crashing.
323
+ const safeWallMode = (
324
+ wallMode in wallModeConfig ? wallMode : 'cutaway'
325
+ ) as keyof typeof wallModeConfig
326
+ const WallModeIcon = wallModeConfig[safeWallMode].icon
327
+
328
+ return (
329
+ <div
330
+ className={cn(
331
+ 'dark absolute bottom-4 left-1/2 z-20 -translate-x-1/2 text-foreground sm:bottom-6',
332
+ className,
333
+ )}
334
+ >
335
+ <TooltipProvider delayDuration={0}>
336
+ <div className="corner-smooth pointer-events-auto flex h-12 max-w-[calc(100vw-1rem)] flex-row items-center justify-center gap-0.5 overflow-hidden rounded-2xl border border-border/40 bg-background/95 p-1 shadow-elevation-4 backdrop-blur-xl transition-colors duration-200 ease-out sm:h-14 sm:gap-1.5 sm:p-1.5">
337
+ {((canShowScans && (!glbActive || glbHasScans)) ||
338
+ (canShowGuides && (!glbActive || glbHasGuides))) && (
339
+ <>
340
+ <VisibilityMenu
341
+ canShowGuides={canShowGuides && (!glbActive || glbHasGuides)}
342
+ canShowScans={canShowScans && (!glbActive || glbHasScans)}
343
+ />
344
+ <div className="mx-1 h-5 w-px bg-border/40" />
345
+ </>
346
+ )}
347
+
348
+ {/* Level mode */}
349
+ <ActionButton
350
+ className={
351
+ levelMode === 'stacked'
352
+ ? 'hover:bg-white/5 hover:text-amber-400'
353
+ : 'bg-amber-500/20 text-amber-400'
354
+ }
355
+ label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
356
+ onClick={() => {
357
+ if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
358
+ const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
359
+ const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
360
+ useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
361
+ }}
362
+ size="icon"
363
+ tooltipSide="top"
364
+ variant="ghost"
365
+ >
366
+ {levelMode === 'solo' && <Diamond className="h-6 w-6" />}
367
+ {levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
368
+ {(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
369
+ </ActionButton>
370
+
371
+ {/* Wall mode — parametric only; baked GLB walls are fixed-height. */}
372
+ {!glbActive && (
373
+ <ActionButton
374
+ className={
375
+ safeWallMode === 'cutaway'
376
+ ? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
377
+ : 'bg-white/10'
378
+ }
379
+ label={`Walls: ${wallModeConfig[safeWallMode].label}`}
380
+ onClick={() => {
381
+ const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
382
+ const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length
383
+ useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
384
+ }}
385
+ size="icon"
386
+ tooltipSide="top"
387
+ variant="ghost"
388
+ >
389
+ <WallModeIcon className="h-[28px] w-[28px]" />
390
+ </ActionButton>
391
+ )}
392
+
393
+ <div className="mx-1 h-5 w-px bg-border/40" />
394
+
395
+ <DisplayMenu />
396
+
397
+ <div className="mx-1 h-5 w-px bg-border/40" />
398
+
399
+ {/* Walkthrough */}
400
+ <ActionButton
401
+ className={
402
+ walkthroughActive
403
+ ? 'bg-emerald-500/20 text-emerald-400'
404
+ : 'hover:bg-white/5 hover:text-emerald-400'
405
+ }
406
+ label={`Walkthrough: ${walkthroughActive ? 'On' : 'Off'}`}
407
+ onClick={onWalkthroughToggle}
408
+ size="icon"
409
+ tooltipSide="top"
410
+ variant="ghost"
411
+ >
412
+ <Footprints className="h-6 w-6" />
413
+ </ActionButton>
414
+
415
+ <div className="mx-1 h-5 w-px bg-border/40" />
416
+
417
+ {/* Camera actions */}
418
+ <ActionButton
419
+ className="group hidden hover:bg-white/5 sm:inline-flex"
420
+ label="Orbit left"
421
+ onClick={() => emitter.emit('camera-controls:orbit-ccw')}
422
+ size="icon"
423
+ tooltipSide="top"
424
+ variant="ghost"
425
+ >
426
+ <img
427
+ alt="Orbit left"
428
+ className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
429
+ src="/icons/rotate.webp"
430
+ />
431
+ </ActionButton>
432
+
433
+ <ActionButton
434
+ className="group hidden hover:bg-white/5 sm:inline-flex"
435
+ label="Orbit right"
436
+ onClick={() => emitter.emit('camera-controls:orbit-cw')}
437
+ size="icon"
438
+ tooltipSide="top"
439
+ variant="ghost"
440
+ >
441
+ <img
442
+ alt="Orbit right"
443
+ className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
444
+ src="/icons/rotate.webp"
445
+ />
446
+ </ActionButton>
447
+
448
+ <ActionButton
449
+ className="group hover:bg-white/5"
450
+ label="Top view"
451
+ onClick={() => emitter.emit('camera-controls:top-view')}
452
+ size="icon"
453
+ tooltipSide="top"
454
+ variant="ghost"
455
+ >
456
+ <img
457
+ alt="Top view"
458
+ className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
459
+ src="/icons/topview.webp"
460
+ />
461
+ </ActionButton>
462
+ </div>
463
+ </TooltipProvider>
464
+ </div>
465
+ )
466
+ }