@pascal-app/editor 0.9.2 → 1.0.0-beta.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.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- 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
|
|
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 {
|
|
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">
|
|
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
|
|
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
|
+
}
|