@pascal-app/editor 0.4.0
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 +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../lib/utils'
|
|
4
|
+
|
|
5
|
+
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
+
icon?: React.ReactNode
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
{...props}
|
|
14
|
+
className={cn(
|
|
15
|
+
'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
>
|
|
19
|
+
{icon}
|
|
20
|
+
<span>{label}</span>
|
|
21
|
+
</button>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ActionGroup({
|
|
26
|
+
children,
|
|
27
|
+
className,
|
|
28
|
+
}: {
|
|
29
|
+
children: React.ReactNode
|
|
30
|
+
className?: string
|
|
31
|
+
}) {
|
|
32
|
+
return <div className={cn('flex gap-1.5', className)}>{children}</div>
|
|
33
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
|
|
6
|
+
const PRESET_COLORS: Record<MaterialPreset, string> = {
|
|
7
|
+
white: '#ffffff',
|
|
8
|
+
brick: '#8b4513',
|
|
9
|
+
concrete: '#808080',
|
|
10
|
+
wood: '#deb887',
|
|
11
|
+
glass: '#87ceeb',
|
|
12
|
+
metal: '#c0c0c0',
|
|
13
|
+
plaster: '#f5f5dc',
|
|
14
|
+
tile: '#d3d3d3',
|
|
15
|
+
marble: '#fafafa',
|
|
16
|
+
custom: '#ffffff',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PRESET_LABELS: Record<MaterialPreset, string> = {
|
|
20
|
+
white: 'White',
|
|
21
|
+
brick: 'Brick',
|
|
22
|
+
concrete: 'Concrete',
|
|
23
|
+
wood: 'Wood',
|
|
24
|
+
glass: 'Glass',
|
|
25
|
+
metal: 'Metal',
|
|
26
|
+
plaster: 'Plaster',
|
|
27
|
+
tile: 'Tile',
|
|
28
|
+
marble: 'Marble',
|
|
29
|
+
custom: 'Custom',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type MaterialPickerProps = {
|
|
33
|
+
value?: MaterialSchema
|
|
34
|
+
onChange: (material: MaterialSchema) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|
38
|
+
const [showCustom, setShowCustom] = useState<boolean>(
|
|
39
|
+
value?.preset === 'custom' || !!value?.properties,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const currentPreset = value?.preset || 'white'
|
|
43
|
+
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
|
|
44
|
+
|
|
45
|
+
const handlePresetChange = (preset: MaterialPreset) => {
|
|
46
|
+
if (preset === 'custom') {
|
|
47
|
+
setShowCustom(true)
|
|
48
|
+
onChange({
|
|
49
|
+
preset: 'custom',
|
|
50
|
+
properties: {
|
|
51
|
+
color: value?.properties?.color || '#ffffff',
|
|
52
|
+
roughness: value?.properties?.roughness ?? 0.5,
|
|
53
|
+
metalness: value?.properties?.metalness ?? 0,
|
|
54
|
+
opacity: value?.properties?.opacity ?? 1,
|
|
55
|
+
transparent: value?.properties?.transparent ?? false,
|
|
56
|
+
side: value?.properties?.side ?? 'front',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
} else {
|
|
60
|
+
setShowCustom(false)
|
|
61
|
+
onChange({ preset })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const handlePropertyChange = (
|
|
66
|
+
prop: keyof typeof currentProps,
|
|
67
|
+
val: (typeof currentProps)[keyof typeof currentProps],
|
|
68
|
+
) => {
|
|
69
|
+
onChange({
|
|
70
|
+
preset: showCustom ? 'custom' : currentPreset,
|
|
71
|
+
properties: {
|
|
72
|
+
...currentProps,
|
|
73
|
+
[prop]: val,
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-3">
|
|
80
|
+
<div className="grid grid-cols-5 gap-1.5">
|
|
81
|
+
{(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (
|
|
82
|
+
<button
|
|
83
|
+
className={`h-8 w-8 rounded border-2 transition-all ${
|
|
84
|
+
currentPreset === preset
|
|
85
|
+
? 'border-blue-500 ring-2 ring-blue-500/30'
|
|
86
|
+
: 'border-gray-300 hover:border-gray-400'
|
|
87
|
+
}`}
|
|
88
|
+
key={preset}
|
|
89
|
+
onClick={() => handlePresetChange(preset)}
|
|
90
|
+
style={{
|
|
91
|
+
backgroundColor: PRESET_COLORS[preset],
|
|
92
|
+
backgroundImage:
|
|
93
|
+
preset === 'glass'
|
|
94
|
+
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
|
|
95
|
+
: undefined,
|
|
96
|
+
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
|
|
97
|
+
}}
|
|
98
|
+
title={PRESET_LABELS[preset]}
|
|
99
|
+
type="button"
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{showCustom && (
|
|
105
|
+
<div className="space-y-2 pt-2">
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<label className="w-16 text-gray-500 text-xs">Color</label>
|
|
108
|
+
<input
|
|
109
|
+
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
|
|
110
|
+
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
|
111
|
+
type="color"
|
|
112
|
+
value={currentProps.color}
|
|
113
|
+
/>
|
|
114
|
+
<input
|
|
115
|
+
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
|
116
|
+
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
|
117
|
+
type="text"
|
|
118
|
+
value={currentProps.color}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
<label className="w-16 text-gray-500 text-xs">Roughness</label>
|
|
124
|
+
<input
|
|
125
|
+
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
126
|
+
max={1}
|
|
127
|
+
min={0}
|
|
128
|
+
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
|
|
129
|
+
step={0.01}
|
|
130
|
+
type="range"
|
|
131
|
+
value={currentProps.roughness}
|
|
132
|
+
/>
|
|
133
|
+
<span className="w-8 text-right text-gray-400 text-xs">
|
|
134
|
+
{currentProps.roughness.toFixed(2)}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<label className="w-16 text-gray-500 text-xs">Metalness</label>
|
|
140
|
+
<input
|
|
141
|
+
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
142
|
+
max={1}
|
|
143
|
+
min={0}
|
|
144
|
+
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
|
|
145
|
+
step={0.01}
|
|
146
|
+
type="range"
|
|
147
|
+
value={currentProps.metalness}
|
|
148
|
+
/>
|
|
149
|
+
<span className="w-8 text-right text-gray-400 text-xs">
|
|
150
|
+
{currentProps.metalness.toFixed(2)}
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="flex items-center gap-2">
|
|
155
|
+
<label className="w-16 text-gray-500 text-xs">Opacity</label>
|
|
156
|
+
<input
|
|
157
|
+
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
158
|
+
max={1}
|
|
159
|
+
min={0}
|
|
160
|
+
onChange={(e) => {
|
|
161
|
+
const opacity = Number.parseFloat(e.target.value)
|
|
162
|
+
handlePropertyChange('opacity', opacity)
|
|
163
|
+
if (opacity < 1 && !currentProps.transparent) {
|
|
164
|
+
handlePropertyChange('transparent', true)
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
step={0.01}
|
|
168
|
+
type="range"
|
|
169
|
+
value={currentProps.opacity}
|
|
170
|
+
/>
|
|
171
|
+
<span className="w-8 text-right text-gray-400 text-xs">
|
|
172
|
+
{currentProps.opacity.toFixed(2)}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="flex items-center gap-2">
|
|
177
|
+
<label className="w-16 text-gray-500 text-xs">Side</label>
|
|
178
|
+
<select
|
|
179
|
+
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
|
180
|
+
onChange={(e) =>
|
|
181
|
+
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
|
|
182
|
+
}
|
|
183
|
+
value={currentProps.side}
|
|
184
|
+
>
|
|
185
|
+
<option value="front">Front</option>
|
|
186
|
+
<option value="back">Back</option>
|
|
187
|
+
<option value="double">Double</option>
|
|
188
|
+
</select>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
|
|
8
|
+
interface MetricControlProps {
|
|
9
|
+
label: React.ReactNode
|
|
10
|
+
value: number
|
|
11
|
+
onChange: (value: number) => void
|
|
12
|
+
min?: number
|
|
13
|
+
max?: number
|
|
14
|
+
precision?: number
|
|
15
|
+
step?: number
|
|
16
|
+
className?: string
|
|
17
|
+
unit?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function MetricControl({
|
|
21
|
+
label,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
min = Number.NEGATIVE_INFINITY,
|
|
25
|
+
max = Number.POSITIVE_INFINITY,
|
|
26
|
+
precision = 2,
|
|
27
|
+
step = 1,
|
|
28
|
+
className,
|
|
29
|
+
unit = '',
|
|
30
|
+
}: MetricControlProps) {
|
|
31
|
+
const viewerUnit = useViewer((state) => state.unit)
|
|
32
|
+
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
|
33
|
+
const multiplier = isImperial ? 3.280_84 : 1
|
|
34
|
+
const displayUnit = isImperial ? 'ft' : unit
|
|
35
|
+
|
|
36
|
+
const displayValue = value * multiplier
|
|
37
|
+
|
|
38
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
39
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
40
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
41
|
+
const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))
|
|
42
|
+
const startXRef = useRef(0)
|
|
43
|
+
const startValueRef = useRef(0)
|
|
44
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
|
|
46
|
+
const valueRef = useRef(value)
|
|
47
|
+
valueRef.current = value
|
|
48
|
+
|
|
49
|
+
const clamp = useCallback(
|
|
50
|
+
(val: number) => {
|
|
51
|
+
return Math.min(Math.max(val, min), max)
|
|
52
|
+
},
|
|
53
|
+
[min, max],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!isEditing) {
|
|
58
|
+
setInputValue(displayValue.toFixed(precision))
|
|
59
|
+
}
|
|
60
|
+
}, [displayValue, precision, isEditing])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const container = containerRef.current
|
|
64
|
+
if (!container) return
|
|
65
|
+
|
|
66
|
+
const handleWheel = (e: WheelEvent) => {
|
|
67
|
+
if (isEditing) return
|
|
68
|
+
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
|
|
71
|
+
const direction = e.deltaY < 0 ? 1 : -1
|
|
72
|
+
let scrollStep = step / multiplier
|
|
73
|
+
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
|
74
|
+
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
|
75
|
+
|
|
76
|
+
const newValue = clamp(valueRef.current + direction * scrollStep)
|
|
77
|
+
const finalValue = Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
|
78
|
+
|
|
79
|
+
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
|
80
|
+
onChange(finalValue)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
85
|
+
return () => container.removeEventListener('wheel', handleWheel)
|
|
86
|
+
}, [isEditing, step, clamp, onChange, precision, multiplier])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!isHovered || isEditing) return
|
|
90
|
+
|
|
91
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
92
|
+
let direction = 0
|
|
93
|
+
if (e.key === 'ArrowUp') direction = 1
|
|
94
|
+
else if (e.key === 'ArrowDown') direction = -1
|
|
95
|
+
|
|
96
|
+
if (direction !== 0) {
|
|
97
|
+
e.preventDefault()
|
|
98
|
+
let scrollStep = step / multiplier
|
|
99
|
+
if (e.shiftKey) scrollStep = (step * 10) / multiplier
|
|
100
|
+
else if (e.altKey) scrollStep = (step * 0.1) / multiplier
|
|
101
|
+
|
|
102
|
+
const newValue = clamp(valueRef.current + direction * scrollStep)
|
|
103
|
+
const finalValue =
|
|
104
|
+
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
|
105
|
+
|
|
106
|
+
if (Math.abs(finalValue - valueRef.current) > 1e-6) {
|
|
107
|
+
onChange(finalValue)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
113
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
114
|
+
}, [isHovered, isEditing, step, clamp, onChange, precision, multiplier])
|
|
115
|
+
|
|
116
|
+
const handlePointerDown = useCallback(
|
|
117
|
+
(e: React.PointerEvent) => {
|
|
118
|
+
if (isEditing) return
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
|
|
121
|
+
setIsDragging(true)
|
|
122
|
+
startXRef.current = e.clientX
|
|
123
|
+
startValueRef.current = value
|
|
124
|
+
useScene.temporal.getState().pause()
|
|
125
|
+
|
|
126
|
+
let finalValue = value
|
|
127
|
+
|
|
128
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
129
|
+
const deltaX = moveEvent.clientX - startXRef.current
|
|
130
|
+
|
|
131
|
+
let dragStep = step / multiplier
|
|
132
|
+
if (moveEvent.shiftKey) dragStep = (step * 10) / multiplier
|
|
133
|
+
else if (moveEvent.altKey) dragStep = (step * 0.1) / multiplier
|
|
134
|
+
|
|
135
|
+
const deltaValue = deltaX * dragStep
|
|
136
|
+
const newValue = clamp(startValueRef.current + deltaValue)
|
|
137
|
+
const newFinalValue =
|
|
138
|
+
Number.parseFloat((newValue * multiplier).toFixed(precision)) / multiplier
|
|
139
|
+
|
|
140
|
+
if (Math.abs(newFinalValue - finalValue) > 1e-6) {
|
|
141
|
+
finalValue = newFinalValue
|
|
142
|
+
onChange(finalValue)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const handlePointerUp = () => {
|
|
147
|
+
setIsDragging(false)
|
|
148
|
+
document.removeEventListener('pointermove', handlePointerMove)
|
|
149
|
+
document.removeEventListener('pointerup', handlePointerUp)
|
|
150
|
+
|
|
151
|
+
if (Math.abs(finalValue - startValueRef.current) > 1e-6) {
|
|
152
|
+
onChange(startValueRef.current)
|
|
153
|
+
useScene.temporal.getState().resume()
|
|
154
|
+
onChange(finalValue)
|
|
155
|
+
} else {
|
|
156
|
+
useScene.temporal.getState().resume()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
document.addEventListener('pointermove', handlePointerMove)
|
|
161
|
+
document.addEventListener('pointerup', handlePointerUp)
|
|
162
|
+
},
|
|
163
|
+
[isEditing, value, onChange, clamp, precision, step, multiplier],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const handleValueClick = useCallback(() => {
|
|
167
|
+
setIsEditing(true)
|
|
168
|
+
setInputValue((value * multiplier).toFixed(precision))
|
|
169
|
+
}, [value, multiplier, precision])
|
|
170
|
+
|
|
171
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
172
|
+
setInputValue(e.target.value)
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const submitValue = useCallback(() => {
|
|
176
|
+
const numValue = Number.parseFloat(inputValue)
|
|
177
|
+
if (Number.isNaN(numValue)) {
|
|
178
|
+
setInputValue((value * multiplier).toFixed(precision))
|
|
179
|
+
} else {
|
|
180
|
+
onChange(clamp(numValue / multiplier))
|
|
181
|
+
}
|
|
182
|
+
setIsEditing(false)
|
|
183
|
+
}, [inputValue, onChange, clamp, multiplier, value, precision])
|
|
184
|
+
|
|
185
|
+
const handleInputBlur = useCallback(() => {
|
|
186
|
+
submitValue()
|
|
187
|
+
}, [submitValue])
|
|
188
|
+
|
|
189
|
+
const handleInputKeyDown = useCallback(
|
|
190
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
191
|
+
if (e.key === 'Enter') {
|
|
192
|
+
submitValue()
|
|
193
|
+
} else if (e.key === 'Escape') {
|
|
194
|
+
setInputValue((value * multiplier).toFixed(precision))
|
|
195
|
+
setIsEditing(false)
|
|
196
|
+
} else if (e.key === 'ArrowUp') {
|
|
197
|
+
e.preventDefault()
|
|
198
|
+
const newV = clamp(value + step / multiplier)
|
|
199
|
+
onChange(newV)
|
|
200
|
+
setInputValue((newV * multiplier).toFixed(precision))
|
|
201
|
+
} else if (e.key === 'ArrowDown') {
|
|
202
|
+
e.preventDefault()
|
|
203
|
+
const newV = clamp(value - step / multiplier)
|
|
204
|
+
onChange(newV)
|
|
205
|
+
setInputValue((newV * multiplier).toFixed(precision))
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
[submitValue, value, multiplier, precision, step, clamp, onChange],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
className={cn(
|
|
214
|
+
'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',
|
|
215
|
+
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
|
216
|
+
className,
|
|
217
|
+
)}
|
|
218
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
219
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
220
|
+
ref={containerRef}
|
|
221
|
+
>
|
|
222
|
+
<div
|
|
223
|
+
className={cn(
|
|
224
|
+
'select-none truncate text-muted-foreground transition-colors',
|
|
225
|
+
isDragging
|
|
226
|
+
? 'cursor-ew-resize text-foreground'
|
|
227
|
+
: 'hover:cursor-ew-resize hover:text-foreground',
|
|
228
|
+
)}
|
|
229
|
+
onPointerDown={handlePointerDown}
|
|
230
|
+
>
|
|
231
|
+
{label}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="flex shrink-0 justify-end">
|
|
235
|
+
{isEditing ? (
|
|
236
|
+
<div className="flex items-center">
|
|
237
|
+
<input
|
|
238
|
+
autoFocus
|
|
239
|
+
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
|
240
|
+
onBlur={handleInputBlur}
|
|
241
|
+
onChange={handleInputChange}
|
|
242
|
+
onKeyDown={handleInputKeyDown}
|
|
243
|
+
type="text"
|
|
244
|
+
value={inputValue}
|
|
245
|
+
/>
|
|
246
|
+
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<div
|
|
250
|
+
className="flex w-full cursor-text items-center justify-end text-foreground transition-colors hover:text-primary"
|
|
251
|
+
onClick={handleValueClick}
|
|
252
|
+
>
|
|
253
|
+
<span className="font-mono tabular-nums tracking-tight">
|
|
254
|
+
{Number(displayValue.toFixed(precision)).toFixed(precision)}
|
|
255
|
+
</span>
|
|
256
|
+
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ChevronDown } from 'lucide-react'
|
|
4
|
+
import { AnimatePresence, motion } from 'motion/react'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
|
|
8
|
+
interface PanelSectionProps {
|
|
9
|
+
title: string
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
defaultExpanded?: boolean
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PanelSection({
|
|
16
|
+
title,
|
|
17
|
+
children,
|
|
18
|
+
defaultExpanded = true,
|
|
19
|
+
className,
|
|
20
|
+
}: PanelSectionProps) {
|
|
21
|
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<motion.div
|
|
25
|
+
className={cn('flex shrink-0 flex-col overflow-hidden border-border/50 border-b', className)}
|
|
26
|
+
layout
|
|
27
|
+
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
|
28
|
+
>
|
|
29
|
+
<motion.button
|
|
30
|
+
className={cn(
|
|
31
|
+
'group/section flex h-10 shrink-0 items-center justify-between px-3 transition-all duration-200',
|
|
32
|
+
isExpanded
|
|
33
|
+
? 'bg-accent/50 text-foreground'
|
|
34
|
+
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
|
35
|
+
)}
|
|
36
|
+
layout="position"
|
|
37
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
38
|
+
type="button"
|
|
39
|
+
>
|
|
40
|
+
<span className="truncate font-medium text-sm">{title}</span>
|
|
41
|
+
<ChevronDown
|
|
42
|
+
className={cn(
|
|
43
|
+
'h-4 w-4 transition-transform duration-200',
|
|
44
|
+
isExpanded ? 'rotate-180' : 'rotate-0',
|
|
45
|
+
isExpanded ? 'text-foreground' : 'opacity-0 group-hover/section:opacity-100',
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
</motion.button>
|
|
49
|
+
|
|
50
|
+
<AnimatePresence initial={false}>
|
|
51
|
+
{isExpanded && (
|
|
52
|
+
<motion.div
|
|
53
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
54
|
+
className="overflow-hidden"
|
|
55
|
+
exit={{ height: 0, opacity: 0 }}
|
|
56
|
+
initial={{ height: 0, opacity: 0 }}
|
|
57
|
+
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
|
58
|
+
>
|
|
59
|
+
<div className="flex flex-col gap-1.5 p-3 pt-2">{children}</div>
|
|
60
|
+
</motion.div>
|
|
61
|
+
)}
|
|
62
|
+
</AnimatePresence>
|
|
63
|
+
</motion.div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../lib/utils'
|
|
4
|
+
|
|
5
|
+
interface SegmentedControlProps<T extends string> {
|
|
6
|
+
value: T
|
|
7
|
+
onChange: (value: T) => void
|
|
8
|
+
options: { label: React.ReactNode; value: T }[]
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SegmentedControl<T extends string>({
|
|
13
|
+
value,
|
|
14
|
+
onChange,
|
|
15
|
+
options,
|
|
16
|
+
className,
|
|
17
|
+
}: SegmentedControlProps<T>) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{options.map((option) => {
|
|
26
|
+
const isSelected = value === option.value
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
className={cn(
|
|
30
|
+
'relative flex h-full flex-1 items-center justify-center rounded-md font-medium text-xs transition-all duration-200',
|
|
31
|
+
isSelected
|
|
32
|
+
? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
|
|
33
|
+
: 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
|
|
34
|
+
)}
|
|
35
|
+
key={option.value}
|
|
36
|
+
onClick={() => onChange(option.value)}
|
|
37
|
+
type="button"
|
|
38
|
+
>
|
|
39
|
+
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
|
|
40
|
+
</button>
|
|
41
|
+
)
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|