@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.
- 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 +265 -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
|
@@ -2,44 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
getCatalogMaterialById,
|
|
5
|
+
getDynamicLibraryMaterials,
|
|
5
6
|
getLibraryMaterialIdFromRef,
|
|
7
|
+
getLibraryMaterialsVersion,
|
|
6
8
|
getMaterialsForCategory,
|
|
7
9
|
MATERIAL_CATEGORIES,
|
|
10
|
+
type MaterialCatalogItem,
|
|
11
|
+
type MaterialSource,
|
|
8
12
|
type MaterialTarget,
|
|
13
|
+
subscribeLibraryMaterials,
|
|
9
14
|
toLibraryMaterialRef,
|
|
10
15
|
} from '@pascal-app/core'
|
|
11
|
-
import {
|
|
16
|
+
import { Plus } from 'lucide-react'
|
|
17
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
12
18
|
import { triggerSFX } from '../../../lib/sfx-bus'
|
|
13
19
|
|
|
14
|
-
type
|
|
20
|
+
export type MaterialSourceFilter = 'all' | MaterialSource
|
|
21
|
+
|
|
22
|
+
export type MaterialPickerProps = {
|
|
15
23
|
selectedMaterialPreset?: string
|
|
16
24
|
onSelectMaterialPreset?: (materialPreset: string) => void
|
|
17
25
|
disabled?: boolean
|
|
18
26
|
nodeType?: MaterialTarget
|
|
19
27
|
hideSideControl?: boolean
|
|
28
|
+
onCreateMaterialRequest?: () => void
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
|
|
32
|
+
{ id: 'all', label: 'All' },
|
|
33
|
+
{ id: 'pascal', label: 'Pascal' },
|
|
34
|
+
{ id: 'mine', label: 'Mine' },
|
|
35
|
+
{ id: 'workspace', label: 'Workspace' },
|
|
36
|
+
{ id: 'community', label: 'Community' },
|
|
37
|
+
]
|
|
38
|
+
|
|
22
39
|
function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
|
|
23
40
|
return category.charAt(0).toUpperCase() + category.slice(1)
|
|
24
41
|
}
|
|
25
42
|
|
|
43
|
+
function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
|
|
44
|
+
if (filter === 'all') return items
|
|
45
|
+
return items.filter((item) => (item.source ?? 'pascal') === filter)
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
|
-
* Catalog material picker: a fixed row of category tabs
|
|
28
|
-
* of swatches.
|
|
29
|
-
* (the host's `+` action)
|
|
49
|
+
* Catalog material picker: a fixed row of category tabs and a source filter row
|
|
50
|
+
* over a scrollable grid of swatches. Scene-material creation lives in the
|
|
51
|
+
* scene-material section (the host's `+` action); `onCreateMaterialRequest` is
|
|
52
|
+
* the host's entry point for authoring a new *library* material.
|
|
30
53
|
*/
|
|
31
54
|
export function MaterialPicker({
|
|
32
55
|
selectedMaterialPreset,
|
|
33
56
|
onSelectMaterialPreset,
|
|
34
57
|
disabled = false,
|
|
58
|
+
onCreateMaterialRequest,
|
|
35
59
|
}: MaterialPickerProps) {
|
|
36
60
|
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
|
37
61
|
MATERIAL_CATEGORIES[0],
|
|
38
62
|
)
|
|
63
|
+
const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('all')
|
|
64
|
+
// Version counter so host registrations/unregistrations re-render the picker.
|
|
65
|
+
const libraryVersion = useSyncExternalStore(
|
|
66
|
+
subscribeLibraryMaterials,
|
|
67
|
+
getLibraryMaterialsVersion,
|
|
68
|
+
getLibraryMaterialsVersion,
|
|
69
|
+
)
|
|
70
|
+
const hasWorkspaceMaterials = useMemo(
|
|
71
|
+
() => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
|
|
72
|
+
[libraryVersion],
|
|
73
|
+
)
|
|
74
|
+
const visibleSourceFilters = SOURCE_FILTERS.filter(
|
|
75
|
+
(filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
|
|
76
|
+
)
|
|
39
77
|
const availableCategories = MATERIAL_CATEGORIES.filter(
|
|
40
78
|
(category) => getMaterialsForCategory(category).length > 0,
|
|
41
79
|
)
|
|
42
|
-
const catalogItems = getMaterialsForCategory(selectedCategory)
|
|
80
|
+
const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
|
|
43
81
|
|
|
44
82
|
// Keep the visible category in sync with the externally-selected catalog
|
|
45
83
|
// material (a `scene:` ref matches no catalog entry, so the tab stays put).
|
|
@@ -72,7 +110,7 @@ export function MaterialPicker({
|
|
|
72
110
|
setSelectedCategory(category)
|
|
73
111
|
// Auto-select the first material in the category so the brush is
|
|
74
112
|
// immediately ready (and the swatch shows as selected).
|
|
75
|
-
const first = getMaterialsForCategory(category)[0]
|
|
113
|
+
const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
|
|
76
114
|
if (first) handleCatalogSelect(first.id)
|
|
77
115
|
}}
|
|
78
116
|
type="button"
|
|
@@ -81,11 +119,52 @@ export function MaterialPicker({
|
|
|
81
119
|
</button>
|
|
82
120
|
))}
|
|
83
121
|
</div>
|
|
122
|
+
{/* Fixed source filter tabs — underline style, matching the catalog
|
|
123
|
+
browse surfaces (Items / Rooms / Build / Search) rather than the
|
|
124
|
+
pill-button category row above. */}
|
|
125
|
+
<div className="flex shrink-0 items-center gap-4 px-1">
|
|
126
|
+
{visibleSourceFilters.map((filter) => (
|
|
127
|
+
<button
|
|
128
|
+
className={`-mb-px border-b-2 px-0.5 py-1.5 font-medium text-xs transition-colors ${
|
|
129
|
+
sourceFilter === filter.id
|
|
130
|
+
? 'border-foreground text-foreground'
|
|
131
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
132
|
+
}`}
|
|
133
|
+
key={filter.id}
|
|
134
|
+
onClick={() => {
|
|
135
|
+
triggerSFX('sfx:menu-click')
|
|
136
|
+
setSourceFilter(filter.id)
|
|
137
|
+
}}
|
|
138
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
139
|
+
type="button"
|
|
140
|
+
>
|
|
141
|
+
{filter.label}
|
|
142
|
+
</button>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
84
145
|
{/* The only scrolling region. */}
|
|
85
146
|
<div
|
|
86
147
|
className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1"
|
|
87
148
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
|
|
88
149
|
>
|
|
150
|
+
{onCreateMaterialRequest ? (
|
|
151
|
+
<button
|
|
152
|
+
className="group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent"
|
|
153
|
+
onClick={() => {
|
|
154
|
+
triggerSFX('sfx:menu-click')
|
|
155
|
+
onCreateMaterialRequest()
|
|
156
|
+
}}
|
|
157
|
+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
158
|
+
type="button"
|
|
159
|
+
>
|
|
160
|
+
<div className="flex aspect-square w-full items-center justify-center rounded-lg border border-border/45 border-dashed">
|
|
161
|
+
<Plus className="size-5 text-muted-foreground group-hover:text-foreground" />
|
|
162
|
+
</div>
|
|
163
|
+
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
|
|
164
|
+
New material
|
|
165
|
+
</span>
|
|
166
|
+
</button>
|
|
167
|
+
) : null}
|
|
89
168
|
{catalogItems.map((item) => {
|
|
90
169
|
const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
|
|
91
170
|
return (
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
RAISE_METRES_PER_STROKE,
|
|
6
|
+
type SiteNode,
|
|
7
|
+
type TerrainVerb,
|
|
8
|
+
useScene,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import { Mountain, Pipette } from 'lucide-react'
|
|
11
|
+
import { brushRadiusRange, flattenSite, resetSiteTerrain } from '../../../lib/terrain-sculpt'
|
|
12
|
+
import useEditor from '../../../store/use-editor'
|
|
13
|
+
import { Button } from '../primitives/button'
|
|
14
|
+
import { SegmentedControl } from './segmented-control'
|
|
15
|
+
import { SliderControl } from './slider-control'
|
|
16
|
+
|
|
17
|
+
const VERB_OPTIONS: Array<{ value: TerrainVerb; iconSrc: string; hint: string }> = [
|
|
18
|
+
{ value: 'raise', iconSrc: '/icons/terrain-raise.webp', hint: 'Raise' },
|
|
19
|
+
{ value: 'lower', iconSrc: '/icons/terrain-lower.webp', hint: 'Lower' },
|
|
20
|
+
{ value: 'flatten', iconSrc: '/icons/terrain-flatten.webp', hint: 'Flatten' },
|
|
21
|
+
{ value: 'smooth', iconSrc: '/icons/terrain-smooth.webp', hint: 'Smooth' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const VERB_HINTS: Record<TerrainVerb, string> = {
|
|
25
|
+
raise: `Drag to raise the ground. One pass moves it up to ${RAISE_METRES_PER_STROKE} m — release and drag again to go further.`,
|
|
26
|
+
lower: `Drag to lower the ground. One pass moves it down to ${RAISE_METRES_PER_STROKE} m.`,
|
|
27
|
+
flatten: 'Drag to level the ground toward the target height. It never overshoots.',
|
|
28
|
+
smooth: 'Drag to soften slopes and remove ridges. Flat ground stays flat.',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sculpt controls for terrain mode — verb, brush, and the two lot-wide actions.
|
|
33
|
+
*
|
|
34
|
+
* A panel rather than a floating HUD because the brush settings are the sort of
|
|
35
|
+
* thing a user adjusts between strokes and then leaves alone, and because
|
|
36
|
+
* sculpting already owns the whole viewport pointer: putting controls over the
|
|
37
|
+
* canvas would put them over the surface being sculpted.
|
|
38
|
+
*
|
|
39
|
+
* Embedders mount this wherever their sculpt controls belong (the community
|
|
40
|
+
* editor puts it in the Build sidebar while sculpt mode is active), exactly like
|
|
41
|
+
* `MaterialPaintPanel`.
|
|
42
|
+
*/
|
|
43
|
+
export function TerrainSculptPanel() {
|
|
44
|
+
const verb = useEditor((state) => state.terrainVerb)
|
|
45
|
+
const setTerrainVerb = useEditor((state) => state.setTerrainVerb)
|
|
46
|
+
const brush = useEditor((state) => state.terrainBrush)
|
|
47
|
+
const setTerrainBrush = useEditor((state) => state.setTerrainBrush)
|
|
48
|
+
const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
|
|
49
|
+
const setTerrainFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget)
|
|
50
|
+
const sampling = useEditor((state) => state.terrainSampling)
|
|
51
|
+
const setTerrainSampling = useEditor((state) => state.setTerrainSampling)
|
|
52
|
+
|
|
53
|
+
const nodes = useScene((state) => state.nodes)
|
|
54
|
+
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
|
55
|
+
const siteId = rootNodeIds[0]
|
|
56
|
+
const siteNode = siteId ? nodes[siteId as AnyNodeId] : undefined
|
|
57
|
+
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
|
|
58
|
+
const [minRadius, maxRadius] = brushRadiusRange(site)
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col gap-3">
|
|
62
|
+
<div className="flex flex-col gap-1.5">
|
|
63
|
+
<SegmentedControl
|
|
64
|
+
className="h-14"
|
|
65
|
+
onChange={(next) => setTerrainVerb(next)}
|
|
66
|
+
options={VERB_OPTIONS.map(({ value, iconSrc, hint }) => ({
|
|
67
|
+
value,
|
|
68
|
+
label: (
|
|
69
|
+
<span className="flex flex-col items-center gap-0.5">
|
|
70
|
+
<img
|
|
71
|
+
alt=""
|
|
72
|
+
aria-hidden
|
|
73
|
+
className="size-7 object-contain"
|
|
74
|
+
draggable={false}
|
|
75
|
+
height={28}
|
|
76
|
+
src={iconSrc}
|
|
77
|
+
width={28}
|
|
78
|
+
/>
|
|
79
|
+
<span className="text-[9px] leading-none">{hint}</span>
|
|
80
|
+
</span>
|
|
81
|
+
),
|
|
82
|
+
}))}
|
|
83
|
+
value={verb}
|
|
84
|
+
/>
|
|
85
|
+
<p className="px-0.5 text-muted-foreground text-xs">{VERB_HINTS[verb]}</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div className="flex flex-col gap-1.5">
|
|
89
|
+
{/*
|
|
90
|
+
Range from `brushRadiusRange`, shared with the `[`/`]` keys. The low end
|
|
91
|
+
is not a preference: it tracks the field's sample spacing, and a brush
|
|
92
|
+
under it lands between samples and paints nothing at all.
|
|
93
|
+
*/}
|
|
94
|
+
<SliderControl
|
|
95
|
+
label="Size"
|
|
96
|
+
max={maxRadius}
|
|
97
|
+
min={minRadius}
|
|
98
|
+
onChange={(radius) => setTerrainBrush({ radius })}
|
|
99
|
+
precision={1}
|
|
100
|
+
step={0.5}
|
|
101
|
+
unit="m"
|
|
102
|
+
value={brush.radius}
|
|
103
|
+
/>
|
|
104
|
+
<SliderControl
|
|
105
|
+
label="Strength"
|
|
106
|
+
max={1}
|
|
107
|
+
min={0.05}
|
|
108
|
+
onChange={(strength) => setTerrainBrush({ strength })}
|
|
109
|
+
precision={2}
|
|
110
|
+
step={0.05}
|
|
111
|
+
value={brush.strength}
|
|
112
|
+
/>
|
|
113
|
+
<SliderControl
|
|
114
|
+
label="Softness"
|
|
115
|
+
max={1}
|
|
116
|
+
min={0}
|
|
117
|
+
onChange={(falloff) => setTerrainBrush({ falloff })}
|
|
118
|
+
precision={2}
|
|
119
|
+
step={0.05}
|
|
120
|
+
value={brush.falloff}
|
|
121
|
+
/>
|
|
122
|
+
<SegmentedControl
|
|
123
|
+
onChange={(shape) => setTerrainBrush({ shape })}
|
|
124
|
+
options={[
|
|
125
|
+
{ value: 'round', label: 'Round' },
|
|
126
|
+
{ value: 'square', label: 'Square' },
|
|
127
|
+
]}
|
|
128
|
+
value={brush.shape}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{verb === 'flatten' && (
|
|
133
|
+
<div className="flex flex-col gap-1.5 border-border/60 border-t pt-3">
|
|
134
|
+
<div className="flex items-center gap-2">
|
|
135
|
+
<div className="min-w-0 flex-1">
|
|
136
|
+
<SliderControl
|
|
137
|
+
label="Target"
|
|
138
|
+
max={50}
|
|
139
|
+
min={-50}
|
|
140
|
+
onChange={setTerrainFlattenTarget}
|
|
141
|
+
precision={2}
|
|
142
|
+
step={0.1}
|
|
143
|
+
unit="m"
|
|
144
|
+
value={flattenTarget ?? 0}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<Button
|
|
148
|
+
aria-label="Pick target height from the ground"
|
|
149
|
+
aria-pressed={sampling}
|
|
150
|
+
onClick={() => setTerrainSampling(!sampling)}
|
|
151
|
+
size="icon-sm"
|
|
152
|
+
type="button"
|
|
153
|
+
variant={sampling ? 'default' : 'outline'}
|
|
154
|
+
>
|
|
155
|
+
<Pipette />
|
|
156
|
+
</Button>
|
|
157
|
+
</div>
|
|
158
|
+
<p className="px-0.5 text-muted-foreground text-xs">
|
|
159
|
+
{sampling
|
|
160
|
+
? 'Click the ground to pick its height as the target.'
|
|
161
|
+
: flattenTarget === null
|
|
162
|
+
? 'No target yet — the first click samples the ground under it.'
|
|
163
|
+
: 'Every flatten stroke levels toward this height.'}
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<div className="flex items-center gap-2 border-border/60 border-t pt-3">
|
|
169
|
+
<Button
|
|
170
|
+
className="flex-1"
|
|
171
|
+
disabled={!site}
|
|
172
|
+
onClick={() => site && flattenSite(site, flattenTarget ?? 0)}
|
|
173
|
+
size="sm"
|
|
174
|
+
type="button"
|
|
175
|
+
variant="outline"
|
|
176
|
+
>
|
|
177
|
+
<Mountain />
|
|
178
|
+
Level lot
|
|
179
|
+
</Button>
|
|
180
|
+
<Button
|
|
181
|
+
className="flex-1"
|
|
182
|
+
disabled={!site?.terrain}
|
|
183
|
+
onClick={() => site && resetSiteTerrain(site)}
|
|
184
|
+
size="sm"
|
|
185
|
+
type="button"
|
|
186
|
+
variant="outline"
|
|
187
|
+
>
|
|
188
|
+
Clear terrain
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
type AnyNode,
|
|
23
23
|
type AnyNodeId,
|
|
24
24
|
type BuildingNode,
|
|
25
|
+
DEFAULT_LEVEL_HEIGHT,
|
|
26
|
+
getStoredLevelHeight,
|
|
25
27
|
LevelNode,
|
|
26
28
|
useScene,
|
|
27
29
|
} from '@pascal-app/core'
|
|
@@ -34,22 +36,19 @@ import {
|
|
|
34
36
|
useEffect,
|
|
35
37
|
useRef,
|
|
36
38
|
useState,
|
|
37
|
-
useSyncExternalStore,
|
|
38
39
|
} from 'react'
|
|
39
40
|
import { useShallow } from 'zustand/react/shallow'
|
|
41
|
+
import { pasteSelectionAndPickUp } from '../editor/group-actions'
|
|
40
42
|
import {
|
|
41
43
|
buildLevelDuplicateCreateOps,
|
|
42
44
|
type LevelDuplicatePreset,
|
|
43
45
|
} from '../../lib/level-duplication'
|
|
44
46
|
import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
|
|
45
47
|
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
|
46
|
-
import {
|
|
47
|
-
getEditorClipboardSnapshot,
|
|
48
|
-
pasteEditorClipboardToLevel,
|
|
49
|
-
subscribeEditorClipboard,
|
|
50
|
-
} from '../../lib/scene-clipboard'
|
|
51
|
-
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
48
|
+
import { useLinearDisplay } from '../../lib/use-linear-display'
|
|
52
49
|
import { cn } from '../../lib/utils'
|
|
50
|
+
import { ActionButton } from './controls/action-button'
|
|
51
|
+
import { SliderControl } from './controls/slider-control'
|
|
53
52
|
import { LevelDuplicateDialog } from './level-duplicate-dialog'
|
|
54
53
|
import {
|
|
55
54
|
Dialog,
|
|
@@ -145,6 +144,26 @@ function LevelRow({
|
|
|
145
144
|
}) {
|
|
146
145
|
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
147
146
|
const [isEditing, setIsEditing] = useState(false)
|
|
147
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
148
|
+
const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
|
|
149
|
+
|
|
150
|
+
const storeyHeight = getStoredLevelHeight(level)
|
|
151
|
+
// toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
|
|
152
|
+
const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
|
|
153
|
+
|
|
154
|
+
// Clean preset values per display system; imperial stores exact meters
|
|
155
|
+
// for whole-foot storey heights.
|
|
156
|
+
const heightPresets = isImperial
|
|
157
|
+
? [
|
|
158
|
+
{ label: '8 ft', height: 2.4384 },
|
|
159
|
+
{ label: '9 ft', height: 2.7432 },
|
|
160
|
+
{ label: '10 ft', height: 3.048 },
|
|
161
|
+
]
|
|
162
|
+
: [
|
|
163
|
+
{ label: '2.5 m', height: 2.5 },
|
|
164
|
+
{ label: '3.0 m', height: 3.0 },
|
|
165
|
+
{ label: '3.5 m', height: 3.5 },
|
|
166
|
+
]
|
|
148
167
|
|
|
149
168
|
return (
|
|
150
169
|
<div className="group/level">
|
|
@@ -195,6 +214,48 @@ function LevelRow({
|
|
|
195
214
|
<span className="truncate">{getLevelDisplayName(level)}</span>
|
|
196
215
|
</button>
|
|
197
216
|
|
|
217
|
+
{/* Storey height badge — opens the height popover */}
|
|
218
|
+
<Popover>
|
|
219
|
+
<PopoverTrigger asChild>
|
|
220
|
+
<button
|
|
221
|
+
className="mr-0.5 shrink-0 whitespace-nowrap rounded px-1 py-0.5 font-mono text-[10px] text-muted-foreground/50 tabular-nums transition-colors hover:bg-white/5 hover:text-foreground"
|
|
222
|
+
onClick={(e) => e.stopPropagation()}
|
|
223
|
+
title="Level height"
|
|
224
|
+
type="button"
|
|
225
|
+
>
|
|
226
|
+
{storeyHeightLabel}
|
|
227
|
+
</button>
|
|
228
|
+
</PopoverTrigger>
|
|
229
|
+
<PopoverContent
|
|
230
|
+
align="start"
|
|
231
|
+
className="w-56 p-2"
|
|
232
|
+
onClick={(e) => e.stopPropagation()}
|
|
233
|
+
side="right"
|
|
234
|
+
sideOffset={8}
|
|
235
|
+
>
|
|
236
|
+
<SliderControl
|
|
237
|
+
label="Level height"
|
|
238
|
+
max={6}
|
|
239
|
+
min={1}
|
|
240
|
+
onChange={(v) => updateNode(level.id, { height: v })}
|
|
241
|
+
precision={3}
|
|
242
|
+
step={0.1}
|
|
243
|
+
unit="m"
|
|
244
|
+
value={Math.round(storeyHeight * 1000) / 1000}
|
|
245
|
+
/>
|
|
246
|
+
<div className="mt-1.5 grid grid-cols-3 gap-1.5">
|
|
247
|
+
{heightPresets.map((preset) => (
|
|
248
|
+
<ActionButton
|
|
249
|
+
className="h-7 px-2"
|
|
250
|
+
key={preset.label}
|
|
251
|
+
label={preset.label}
|
|
252
|
+
onClick={() => updateNode(level.id, { height: preset.height })}
|
|
253
|
+
/>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
</PopoverContent>
|
|
257
|
+
</Popover>
|
|
258
|
+
|
|
198
259
|
{/* Vertical three-dot menu — inside the pill */}
|
|
199
260
|
<Popover>
|
|
200
261
|
<PopoverTrigger asChild>
|
|
@@ -332,11 +393,6 @@ export function FloatingLevelSelector() {
|
|
|
332
393
|
|
|
333
394
|
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
|
334
395
|
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
|
|
335
|
-
const clipboardSnapshot = useSyncExternalStore(
|
|
336
|
-
subscribeEditorClipboard,
|
|
337
|
-
getEditorClipboardSnapshot,
|
|
338
|
-
getEditorClipboardSnapshot,
|
|
339
|
-
)
|
|
340
396
|
const sensors = useSensors(
|
|
341
397
|
useSensor(PointerSensor, {
|
|
342
398
|
activationConstraint: { distance: 4 },
|
|
@@ -371,6 +427,7 @@ export function FloatingLevelSelector() {
|
|
|
371
427
|
const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
|
|
372
428
|
const newLevel = LevelNode.parse({
|
|
373
429
|
level: maxLevel + 1,
|
|
430
|
+
height: DEFAULT_LEVEL_HEIGHT,
|
|
374
431
|
children: [],
|
|
375
432
|
parentId: resolvedBuildingId,
|
|
376
433
|
})
|
|
@@ -383,6 +440,7 @@ export function FloatingLevelSelector() {
|
|
|
383
440
|
const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
|
|
384
441
|
const newLevel = LevelNode.parse({
|
|
385
442
|
level: minLevel - 1,
|
|
443
|
+
height: DEFAULT_LEVEL_HEIGHT,
|
|
386
444
|
children: [],
|
|
387
445
|
parentId: resolvedBuildingId,
|
|
388
446
|
})
|
|
@@ -409,6 +467,7 @@ export function FloatingLevelSelector() {
|
|
|
409
467
|
|
|
410
468
|
const newLevel = LevelNode.parse({
|
|
411
469
|
level: newLevelNumber,
|
|
470
|
+
height: DEFAULT_LEVEL_HEIGHT,
|
|
412
471
|
children: [],
|
|
413
472
|
parentId: resolvedBuildingId,
|
|
414
473
|
})
|
|
@@ -452,10 +511,7 @@ export function FloatingLevelSelector() {
|
|
|
452
511
|
)
|
|
453
512
|
|
|
454
513
|
const handlePasteToLevel = useCallback((level: LevelNode) => {
|
|
455
|
-
|
|
456
|
-
if (result?.pastedIds.length) {
|
|
457
|
-
sfxEmitter.emit('sfx:item-place')
|
|
458
|
-
}
|
|
514
|
+
void pasteSelectionAndPickUp(level.id)
|
|
459
515
|
}, [])
|
|
460
516
|
|
|
461
517
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
@@ -557,9 +613,7 @@ export function FloatingLevelSelector() {
|
|
|
557
613
|
isSelected={isSelected}
|
|
558
614
|
level={level}
|
|
559
615
|
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
|
|
560
|
-
onPaste={
|
|
561
|
-
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
|
|
562
|
-
}
|
|
616
|
+
onPaste={() => handlePasteToLevel(level)}
|
|
563
617
|
onRequestDelete={() => setDeletingLevel(level)}
|
|
564
618
|
onSelect={() =>
|
|
565
619
|
setSelection(
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
nodeRegistry,
|
|
7
|
+
type TerrainVerb,
|
|
7
8
|
useScene,
|
|
8
9
|
} from '@pascal-app/core'
|
|
9
10
|
import { useViewer } from '@pascal-app/viewer'
|
|
@@ -53,6 +54,35 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
|
|
|
53
54
|
]
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Sculpt mode's HUD. The verb is named rather than described because it is the one
|
|
58
|
+
// piece of state a user loses track of between strokes. This stays even though the
|
|
59
|
+
// brush ring now carries the verb in colour (`brushRingColor`): the ring says
|
|
60
|
+
// *which* verb only to someone who already knows the mapping, and the HUD is what
|
|
61
|
+
// teaches it.
|
|
62
|
+
function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualShortcutHint[] {
|
|
63
|
+
if (sampling) {
|
|
64
|
+
return [
|
|
65
|
+
{ keys: ['Click'], label: 'Pick target height' },
|
|
66
|
+
{ keys: ['Esc'], label: 'Cancel picking' },
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
const action =
|
|
70
|
+
verb === 'raise'
|
|
71
|
+
? 'Raise ground'
|
|
72
|
+
: verb === 'lower'
|
|
73
|
+
? 'Lower ground'
|
|
74
|
+
: verb === 'flatten'
|
|
75
|
+
? 'Level ground'
|
|
76
|
+
: 'Smooth ground'
|
|
77
|
+
return [
|
|
78
|
+
{ keys: ['Drag'], label: action },
|
|
79
|
+
// Nested, so the two render as alternatives ("[ / ]") rather than a chord —
|
|
80
|
+
// a flat `['[', ']']` joins with "+" and would read as "press both".
|
|
81
|
+
{ keys: [['[', ']']], label: 'Brush size' },
|
|
82
|
+
{ keys: ['Esc'], label: 'Cancel stroke' },
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
type ActiveModifierKeys = {
|
|
57
87
|
command: boolean
|
|
58
88
|
shift: boolean
|
|
@@ -95,6 +125,9 @@ function useActiveModifierKeys(): ActiveModifierKeys {
|
|
|
95
125
|
export function HelperManager() {
|
|
96
126
|
const mode = useEditor((s) => s.mode)
|
|
97
127
|
const tool = useEditor((s) => s.tool)
|
|
128
|
+
const terrainVerb = useEditor((s) => s.terrainVerb)
|
|
129
|
+
const terrainSampling = useEditor((s) => s.terrainSampling)
|
|
130
|
+
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
|
98
131
|
const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
|
|
99
132
|
const workspaceMode = useEditor((s) => s.workspaceMode)
|
|
100
133
|
const scope = useInteractionScope((s) => s.scope)
|
|
@@ -148,6 +181,10 @@ export function HelperManager() {
|
|
|
148
181
|
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
|
149
182
|
if (isMobile) return null
|
|
150
183
|
|
|
184
|
+
// First-person walkthrough has its own HUD; editor shortcut hints (e.g. the
|
|
185
|
+
// Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking.
|
|
186
|
+
if (isFirstPersonMode) return null
|
|
187
|
+
|
|
151
188
|
// The studio workspace (compose panel / gallery) has no scene selection or
|
|
152
189
|
// tools — editor shortcut hints would only mislead there.
|
|
153
190
|
if (workspaceMode === 'studio') return null
|
|
@@ -209,6 +246,15 @@ export function HelperManager() {
|
|
|
209
246
|
return <ContextualHelperPanel hints={[]} showPaintScope />
|
|
210
247
|
}
|
|
211
248
|
|
|
249
|
+
// Sculpt mode. The HUD is what makes a sustained brush mode legible: it names
|
|
250
|
+
// the active verb (the same word the panel's segmented control shows, so the
|
|
251
|
+
// two never disagree), and it advertises the two keys whose behaviour is
|
|
252
|
+
// mode-specific — bracket resize, and an Esc that abandons the stroke rather
|
|
253
|
+
// than exiting the mode.
|
|
254
|
+
if (mode === 'terrain-sculpt') {
|
|
255
|
+
return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
|
|
256
|
+
}
|
|
257
|
+
|
|
212
258
|
// Idle select only — an active scope (handle-drag, box-select, …) must not show
|
|
213
259
|
// the idle selection hints.
|
|
214
260
|
if (mode === 'select' && scope.kind === 'idle') {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Copy, Trash2 } from 'lucide-react'
|
|
6
|
+
import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
|
|
7
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
8
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
9
|
+
import { formatSelectionBreakdown } from './selection-breakdown'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Docked right-side panel for a MULTI-selection — the compact sibling of the
|
|
13
|
+
* single-node inspector, rendered by `PanelManager` when more than one node
|
|
14
|
+
* is selected. Same collapsed-by-default `PanelWrapper` shell (header always
|
|
15
|
+
* visible; the shared desktop collapse state carries across single ↔ multi
|
|
16
|
+
* swaps). Actions mirror the floating group pill: Duplicate clones the
|
|
17
|
+
* selection and picks the clones up, Delete removes the whole selection
|
|
18
|
+
* (including its bulk-delete confirm). Unlike the pill, the docked panel
|
|
19
|
+
* stays visible during interactions. `footer` is the host-injected slot
|
|
20
|
+
* (e.g. community's "Save to my catalog").
|
|
21
|
+
*/
|
|
22
|
+
export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
|
|
23
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
24
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
25
|
+
// String selector — recomputed on scene ticks, but the === compare keeps
|
|
26
|
+
// unrelated mutations from re-rendering the panel.
|
|
27
|
+
const breakdown = useScene((s) =>
|
|
28
|
+
formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<PanelWrapper
|
|
33
|
+
footer={footer}
|
|
34
|
+
icon="/icons/select.webp"
|
|
35
|
+
onClose={() => setSelection({ selectedIds: [] })}
|
|
36
|
+
title={`${selectedIds.length} selected`}
|
|
37
|
+
width={320}
|
|
38
|
+
>
|
|
39
|
+
{breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
|
|
40
|
+
<div className="border-border/50 border-t p-3">
|
|
41
|
+
<ActionGroup>
|
|
42
|
+
<ActionButton
|
|
43
|
+
icon={<Copy className="h-4 w-4" />}
|
|
44
|
+
label="Duplicate"
|
|
45
|
+
onClick={() => duplicateSelectionAndPickUp()}
|
|
46
|
+
/>
|
|
47
|
+
<ActionButton
|
|
48
|
+
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
|
49
|
+
icon={<Trash2 className="h-4 w-4" />}
|
|
50
|
+
label="Delete"
|
|
51
|
+
onClick={() => deleteSelection()}
|
|
52
|
+
/>
|
|
53
|
+
</ActionGroup>
|
|
54
|
+
</div>
|
|
55
|
+
</PanelWrapper>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -28,6 +28,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
|
28
28
|
import useEditor from '../../../store/use-editor'
|
|
29
29
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
|
30
30
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
|
31
|
+
import { MultiSelectionPanel } from './multi-selection-panel'
|
|
31
32
|
import { getNodeDisplay } from './node-display'
|
|
32
33
|
import { resetDesktopInspectorCollapsed } from './panel-wrapper'
|
|
33
34
|
import { ParametricInspector } from './parametric-inspector'
|
|
@@ -168,7 +169,13 @@ function MobilePanelLayer({
|
|
|
168
169
|
)
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
export function PanelManager({
|
|
172
|
+
export function PanelManager({
|
|
173
|
+
inspectorFooter,
|
|
174
|
+
multiSelectionFooter,
|
|
175
|
+
}: {
|
|
176
|
+
inspectorFooter?: React.ReactNode
|
|
177
|
+
multiSelectionFooter?: React.ReactNode
|
|
178
|
+
}) {
|
|
172
179
|
const isMobile = useIsMobile()
|
|
173
180
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
174
181
|
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
|
@@ -237,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
|
|
|
237
244
|
)
|
|
238
245
|
}
|
|
239
246
|
|
|
247
|
+
// Multi-selection: compact docked panel (desktop only — the mobile branch
|
|
248
|
+
// above keeps today's behavior and renders nothing for multi-selections).
|
|
249
|
+
if (selectedIds.length > 1) {
|
|
250
|
+
return <MultiSelectionPanel footer={multiSelectionFooter} />
|
|
251
|
+
}
|
|
252
|
+
|
|
240
253
|
return panelForType(selectedNodeType, inspectorFooter)
|
|
241
254
|
}
|