@pascal-app/editor 0.4.0 → 0.6.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 +8 -7
- package/src/components/editor/editor-layout-v2.tsx +9 -0
- package/src/components/editor/floating-action-menu.tsx +341 -48
- package/src/components/editor/floating-building-action-menu.tsx +70 -0
- package/src/components/editor/floorplan-panel.tsx +1350 -722
- package/src/components/editor/index.tsx +221 -167
- package/src/components/editor/node-action-menu.tsx +40 -11
- package/src/components/editor/selection-manager.tsx +238 -10
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +422 -79
- package/src/components/editor/wall-measurement-label.tsx +120 -32
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
- package/src/components/systems/roof/roof-edit-system.tsx +5 -5
- package/src/components/systems/stair/stair-edit-system.tsx +27 -5
- package/src/components/tools/building/move-building-tool.tsx +157 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/door/door-math.ts +1 -1
- package/src/components/tools/door/door-tool.tsx +31 -7
- package/src/components/tools/door/move-door-tool.tsx +27 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +137 -0
- package/src/components/tools/fence/fence-tool.tsx +190 -0
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +231 -0
- package/src/components/tools/item/item-tool.tsx +3 -3
- package/src/components/tools/item/move-tool.tsx +16 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +17 -9
- package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
- package/src/components/tools/roof/move-roof-tool.tsx +90 -26
- package/src/components/tools/roof/roof-tool.tsx +6 -6
- package/src/components/tools/select/box-select-tool.tsx +2 -2
- package/src/components/tools/shared/polygon-editor.tsx +98 -8
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/slab/slab-tool.tsx +4 -4
- package/src/components/tools/stair/stair-defaults.ts +10 -0
- package/src/components/tools/stair/stair-tool.tsx +39 -8
- package/src/components/tools/tool-manager.tsx +54 -14
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +331 -9
- package/src/components/tools/wall/wall-tool.tsx +19 -29
- package/src/components/tools/window/move-window-tool.tsx +27 -8
- package/src/components/tools/window/window-math.ts +1 -1
- package/src/components/tools/window/window-tool.tsx +31 -7
- package/src/components/tools/zone/zone-tool.tsx +7 -7
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- package/src/components/ui/action-menu/structure-tools.tsx +1 -0
- package/src/components/ui/command-palette/editor-commands.tsx +9 -4
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +127 -94
- package/src/components/ui/controls/slider-control.tsx +28 -14
- package/src/components/ui/helpers/building-helper.tsx +32 -0
- package/src/components/ui/helpers/helper-manager.tsx +2 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
- package/src/components/ui/panels/ceiling-panel.tsx +61 -17
- package/src/components/ui/panels/door-panel.tsx +5 -5
- package/src/components/ui/panels/fence-panel.tsx +269 -0
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +32 -27
- package/src/components/ui/panels/reference-panel.tsx +5 -4
- package/src/components/ui/panels/roof-panel.tsx +91 -22
- package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
- package/src/components/ui/panels/slab-panel.tsx +63 -15
- package/src/components/ui/panels/stair-panel.tsx +377 -50
- package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
- package/src/components/ui/panels/wall-panel.tsx +159 -11
- package/src/components/ui/panels/window-panel.tsx +5 -7
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +26 -19
- package/src/hooks/use-auto-save.ts +3 -6
- package/src/hooks/use-contextual-tools.ts +25 -16
- package/src/hooks/use-grid-events.ts +13 -1
- package/src/hooks/use-keyboard.ts +7 -2
- package/src/index.tsx +2 -1
- package/src/lib/history.ts +20 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/store/use-editor.tsx +125 -10
|
@@ -90,12 +90,17 @@ export function ControlModes() {
|
|
|
90
90
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
|
91
91
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
// Only subscribe to the primitive `level` number — when walls are added to
|
|
94
|
+
// this level the object ref changes but this number doesn't, so Object.is
|
|
95
|
+
// dedupes and we avoid a re-render.
|
|
96
|
+
const levelIndex = useScene((state) => {
|
|
97
|
+
if (!levelId) return null
|
|
98
|
+
const node = state.nodes[levelId]
|
|
99
|
+
return node?.type === 'level' ? (node as LevelNode).level : null
|
|
100
|
+
})
|
|
96
101
|
|
|
97
102
|
const isSiteEditing = phase === 'site'
|
|
98
|
-
const isGroundFloor =
|
|
103
|
+
const isGroundFloor = levelIndex === 0
|
|
99
104
|
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
|
100
105
|
|
|
101
106
|
const structureLayer = useEditor((state) => state.structureLayer)
|
|
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
|
|
|
28
28
|
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
|
29
29
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
|
30
30
|
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
|
31
|
+
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
|
31
32
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
|
32
33
|
]
|
|
33
34
|
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from 'lucide-react'
|
|
36
36
|
import { useEffect } from 'react'
|
|
37
37
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
38
|
+
import { runRedo, runUndo } from '../../../lib/history'
|
|
38
39
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
39
40
|
import type { StructureTool } from '../../../store/use-editor'
|
|
40
41
|
import useEditor from '../../../store/use-editor'
|
|
@@ -44,8 +45,12 @@ export function EditorCommands() {
|
|
|
44
45
|
const register = useCommandRegistry((s) => s.register)
|
|
45
46
|
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
48
|
+
const setPhase = useEditor((s) => s.setPhase)
|
|
49
|
+
const setMode = useEditor((s) => s.setMode)
|
|
50
|
+
const setTool = useEditor((s) => s.setTool)
|
|
51
|
+
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
|
52
|
+
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
53
|
+
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
49
54
|
|
|
50
55
|
const exportScene = useViewer((s) => s.exportScene)
|
|
51
56
|
|
|
@@ -309,7 +314,7 @@ export function EditorCommands() {
|
|
|
309
314
|
group: 'History',
|
|
310
315
|
icon: <Undo2 className="h-4 w-4" />,
|
|
311
316
|
keywords: ['undo', 'revert', 'back'],
|
|
312
|
-
execute: () => run(() =>
|
|
317
|
+
execute: () => run(() => runUndo()),
|
|
313
318
|
},
|
|
314
319
|
{
|
|
315
320
|
id: 'editor.history.redo',
|
|
@@ -317,7 +322,7 @@ export function EditorCommands() {
|
|
|
317
322
|
group: 'History',
|
|
318
323
|
icon: <Redo2 className="h-4 w-4" />,
|
|
319
324
|
keywords: ['redo', 'forward', 'repeat'],
|
|
320
|
-
execute: () => run(() =>
|
|
325
|
+
execute: () => run(() => runRedo()),
|
|
321
326
|
},
|
|
322
327
|
|
|
323
328
|
// ── Export & Share ───────────────────────────────────────────────────
|
|
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
|
|
219
219
|
const views = usePaletteViewRegistry((s) => s.views)
|
|
220
220
|
|
|
221
221
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
|
222
|
-
const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
|
|
223
222
|
|
|
224
223
|
const wallMode = useViewer((s) => s.wallMode)
|
|
225
224
|
const setWallMode = useViewer((s) => s.setWallMode)
|
|
@@ -1,73 +1,79 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
getMaterialsForTarget,
|
|
5
|
+
toLibraryMaterialRef,
|
|
6
|
+
type MaterialSchema,
|
|
7
|
+
type MaterialTarget,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
31
10
|
|
|
32
11
|
type MaterialPickerProps = {
|
|
12
|
+
nodeType?: MaterialTarget
|
|
33
13
|
value?: MaterialSchema
|
|
34
|
-
|
|
14
|
+
selectedMaterialPreset?: string
|
|
15
|
+
onChange?: (material: MaterialSchema) => void
|
|
16
|
+
onSelectMaterialPreset?: (materialPreset: string) => void
|
|
17
|
+
hideSideControl?: boolean
|
|
18
|
+
disabled?: boolean
|
|
35
19
|
}
|
|
36
20
|
|
|
37
|
-
export function MaterialPicker({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
21
|
+
export function MaterialPicker({
|
|
22
|
+
nodeType,
|
|
23
|
+
value,
|
|
24
|
+
selectedMaterialPreset,
|
|
25
|
+
onChange,
|
|
26
|
+
onSelectMaterialPreset,
|
|
27
|
+
hideSideControl = false,
|
|
28
|
+
disabled = false,
|
|
29
|
+
}: MaterialPickerProps) {
|
|
30
|
+
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
|
31
|
+
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
|
35
|
+
}, [selectedMaterialPreset, value?.properties])
|
|
44
36
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
37
|
+
const currentProps = value?.properties || {
|
|
38
|
+
color: '#ffffff',
|
|
39
|
+
roughness: 0.5,
|
|
40
|
+
metalness: 0,
|
|
41
|
+
opacity: 1,
|
|
42
|
+
transparent: false,
|
|
43
|
+
side: 'front' as const,
|
|
44
|
+
}
|
|
45
|
+
const selectedCatalogId =
|
|
46
|
+
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
|
47
|
+
|
|
48
|
+
const handleCatalogSelect = (materialId: string) => {
|
|
49
|
+
if (disabled) return
|
|
50
|
+
setShowCustom(false)
|
|
51
|
+
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleCustomOpen = () => {
|
|
55
|
+
if (disabled) return
|
|
56
|
+
setShowCustom(true)
|
|
57
|
+
onChange?.({
|
|
58
|
+
preset: 'custom',
|
|
59
|
+
properties: {
|
|
60
|
+
color: value?.properties?.color || '#ffffff',
|
|
61
|
+
roughness: value?.properties?.roughness ?? 0.5,
|
|
62
|
+
metalness: value?.properties?.metalness ?? 0,
|
|
63
|
+
opacity: value?.properties?.opacity ?? 1,
|
|
64
|
+
transparent: value?.properties?.transparent ?? false,
|
|
65
|
+
side: value?.properties?.side ?? 'front',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
const handlePropertyChange = (
|
|
66
71
|
prop: keyof typeof currentProps,
|
|
67
72
|
val: (typeof currentProps)[keyof typeof currentProps],
|
|
68
73
|
) => {
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
if (disabled) return
|
|
75
|
+
onChange?.({
|
|
76
|
+
preset: 'custom',
|
|
71
77
|
properties: {
|
|
72
78
|
...currentProps,
|
|
73
79
|
[prop]: val,
|
|
@@ -76,32 +82,57 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
return (
|
|
79
|
-
<div className=
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
className=
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
85
|
+
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
|
86
|
+
{(catalogItems.length > 0 || onChange) && (
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
{catalogItems.length > 0 ? (
|
|
89
|
+
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
|
|
90
|
+
) : null}
|
|
91
|
+
<div className="flex flex-wrap gap-1.5">
|
|
92
|
+
{catalogItems.map((item) => (
|
|
93
|
+
<button
|
|
94
|
+
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
|
|
95
|
+
selectedCatalogId === toLibraryMaterialRef(item.id)
|
|
96
|
+
? 'border-blue-500 ring-2 ring-blue-500/30'
|
|
97
|
+
: 'border-gray-300 hover:border-gray-400'
|
|
98
|
+
}`}
|
|
99
|
+
key={item.id}
|
|
100
|
+
onClick={() => handleCatalogSelect(item.id)}
|
|
101
|
+
title={item.label}
|
|
102
|
+
type="button"
|
|
103
|
+
>
|
|
104
|
+
{item.previewThumbnailUrl ? (
|
|
105
|
+
<img
|
|
106
|
+
alt={item.label}
|
|
107
|
+
className="h-full w-full object-cover"
|
|
108
|
+
src={item.previewThumbnailUrl}
|
|
109
|
+
/>
|
|
110
|
+
) : item.previewColor ? (
|
|
111
|
+
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
|
112
|
+
) : (
|
|
113
|
+
<div className="h-full w-full bg-gray-100" />
|
|
114
|
+
)}
|
|
115
|
+
</button>
|
|
116
|
+
))}
|
|
117
|
+
{onChange ? (
|
|
118
|
+
<button
|
|
119
|
+
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
|
|
120
|
+
showCustom
|
|
121
|
+
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
|
|
122
|
+
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
|
|
123
|
+
}`}
|
|
124
|
+
onClick={handleCustomOpen}
|
|
125
|
+
title="Custom"
|
|
126
|
+
type="button"
|
|
127
|
+
>
|
|
128
|
+
Custom
|
|
129
|
+
</button>
|
|
130
|
+
) : null}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
103
134
|
|
|
104
|
-
{showCustom && (
|
|
135
|
+
{showCustom && onChange && (
|
|
105
136
|
<div className="space-y-2 pt-2">
|
|
106
137
|
<div className="flex items-center gap-2">
|
|
107
138
|
<label className="w-16 text-gray-500 text-xs">Color</label>
|
|
@@ -173,20 +204,22 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|
|
173
204
|
</span>
|
|
174
205
|
</div>
|
|
175
206
|
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
207
|
+
{!hideSideControl && (
|
|
208
|
+
<div className="flex items-center gap-2">
|
|
209
|
+
<label className="w-16 text-gray-500 text-xs">Side</label>
|
|
210
|
+
<select
|
|
211
|
+
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
|
212
|
+
onChange={(e) =>
|
|
213
|
+
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
|
|
214
|
+
}
|
|
215
|
+
value={currentProps.side}
|
|
216
|
+
>
|
|
217
|
+
<option value="front">Front</option>
|
|
218
|
+
<option value="back">Back</option>
|
|
219
|
+
<option value="double">Double</option>
|
|
220
|
+
</select>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
190
223
|
</div>
|
|
191
224
|
)}
|
|
192
225
|
</div>
|
|
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
|
|
|
21
21
|
return Math.max(0, Math.ceil(-Math.log10(s)))
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function getAdjustedStep(
|
|
25
|
+
baseStep: number,
|
|
26
|
+
modifiers: {
|
|
27
|
+
shiftKey?: boolean
|
|
28
|
+
metaKey?: boolean
|
|
29
|
+
ctrlKey?: boolean
|
|
30
|
+
altKey?: boolean
|
|
31
|
+
},
|
|
32
|
+
): number {
|
|
33
|
+
if (modifiers.shiftKey) return baseStep * 10
|
|
34
|
+
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
|
|
35
|
+
return baseStep
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
export function SliderControl({
|
|
25
39
|
label,
|
|
26
40
|
value,
|
|
@@ -58,16 +72,14 @@ export function SliderControl({
|
|
|
58
72
|
if (isEditing) return
|
|
59
73
|
e.preventDefault()
|
|
60
74
|
const direction = e.deltaY < 0 ? 1 : -1
|
|
61
|
-
|
|
62
|
-
if (e.shiftKey) s = step * 10
|
|
63
|
-
else if (e.altKey) s = step * 0.1
|
|
75
|
+
const s = getAdjustedStep(step, e)
|
|
64
76
|
const newValue = clamp(valueRef.current + direction * s)
|
|
65
77
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
66
78
|
if (final !== valueRef.current) onChange(final)
|
|
67
79
|
}
|
|
68
80
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
|
69
81
|
return () => el.removeEventListener('wheel', handleWheel)
|
|
70
|
-
}, [isEditing, step, clamp, onChange
|
|
82
|
+
}, [isEditing, step, clamp, onChange])
|
|
71
83
|
|
|
72
84
|
// Arrow key support while hovered
|
|
73
85
|
useEffect(() => {
|
|
@@ -78,9 +90,7 @@ export function SliderControl({
|
|
|
78
90
|
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
|
|
79
91
|
if (direction !== 0) {
|
|
80
92
|
e.preventDefault()
|
|
81
|
-
|
|
82
|
-
if (e.shiftKey) s = step * 10
|
|
83
|
-
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
|
93
|
+
const s = getAdjustedStep(step, e)
|
|
84
94
|
const newValue = clamp(valueRef.current + direction * s)
|
|
85
95
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
86
96
|
if (final !== valueRef.current) onChange(final)
|
|
@@ -88,7 +98,7 @@ export function SliderControl({
|
|
|
88
98
|
}
|
|
89
99
|
window.addEventListener('keydown', handleKeyDown)
|
|
90
100
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
91
|
-
}, [isHovered, isEditing, step, clamp, onChange
|
|
101
|
+
}, [isHovered, isEditing, step, clamp, onChange])
|
|
92
102
|
|
|
93
103
|
const handleLabelPointerDown = useCallback(
|
|
94
104
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
@@ -107,16 +117,14 @@ export function SliderControl({
|
|
|
107
117
|
if (!dragRef.current) return
|
|
108
118
|
const { startX, startValue } = dragRef.current
|
|
109
119
|
const dx = e.clientX - startX
|
|
110
|
-
|
|
111
|
-
if (e.shiftKey) s = step * 10
|
|
112
|
-
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
|
120
|
+
const s = getAdjustedStep(step, e)
|
|
113
121
|
// 4 px per step at default sensitivity
|
|
114
122
|
const newValue = clamp(
|
|
115
123
|
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
|
116
124
|
)
|
|
117
125
|
onChange(newValue)
|
|
118
126
|
},
|
|
119
|
-
[step,
|
|
127
|
+
[step, clamp, onChange],
|
|
120
128
|
)
|
|
121
129
|
|
|
122
130
|
const handleLabelPointerUp = useCallback(
|
|
@@ -163,12 +171,18 @@ export function SliderControl({
|
|
|
163
171
|
setIsEditing(false)
|
|
164
172
|
} else if (e.key === 'ArrowUp') {
|
|
165
173
|
e.preventDefault()
|
|
166
|
-
const
|
|
174
|
+
const adjustedStep = getAdjustedStep(step, e)
|
|
175
|
+
const newV = clamp(
|
|
176
|
+
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
|
177
|
+
)
|
|
167
178
|
onChange(newV)
|
|
168
179
|
setInputValue(newV.toFixed(precision))
|
|
169
180
|
} else if (e.key === 'ArrowDown') {
|
|
170
181
|
e.preventDefault()
|
|
171
|
-
const
|
|
182
|
+
const adjustedStep = getAdjustedStep(step, e)
|
|
183
|
+
const newV = clamp(
|
|
184
|
+
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
|
185
|
+
)
|
|
172
186
|
onChange(newV)
|
|
173
187
|
setInputValue(newV.toFixed(precision))
|
|
174
188
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ShortcutToken } from '../primitives/shortcut-token'
|
|
2
|
+
|
|
3
|
+
interface BuildingHelperProps {
|
|
4
|
+
showRotate?: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function BuildingHelper({ showRotate }: BuildingHelperProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
|
10
|
+
<div className="flex items-center gap-2 text-sm">
|
|
11
|
+
<ShortcutToken value="Left click" />
|
|
12
|
+
<span className="text-muted-foreground">Place building</span>
|
|
13
|
+
</div>
|
|
14
|
+
{showRotate && (
|
|
15
|
+
<>
|
|
16
|
+
<div className="flex items-center gap-2 text-sm">
|
|
17
|
+
<ShortcutToken value="R" />
|
|
18
|
+
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="flex items-center gap-2 text-sm">
|
|
21
|
+
<ShortcutToken value="T" />
|
|
22
|
+
<span className="text-muted-foreground">Rotate clockwise</span>
|
|
23
|
+
</div>
|
|
24
|
+
</>
|
|
25
|
+
)}
|
|
26
|
+
<div className="flex items-center gap-2 text-sm">
|
|
27
|
+
<ShortcutToken value="Esc" />
|
|
28
|
+
<span className="text-muted-foreground">Cancel</span>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import useEditor from '../../../store/use-editor'
|
|
4
|
+
import { BuildingHelper } from './building-helper'
|
|
4
5
|
import { CeilingHelper } from './ceiling-helper'
|
|
5
6
|
import { ItemHelper } from './item-helper'
|
|
6
7
|
import { RoofHelper } from './roof-helper'
|
|
@@ -12,6 +13,7 @@ export function HelperManager() {
|
|
|
12
13
|
const movingNode = useEditor((state) => state.movingNode)
|
|
13
14
|
|
|
14
15
|
if (movingNode) {
|
|
16
|
+
if (movingNode.type === 'building') return <BuildingHelper showRotate />
|
|
15
17
|
return <ItemHelper showEsc />
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -1578,3 +1578,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|
|
1578
1578
|
},
|
|
1579
1579
|
},
|
|
1580
1580
|
]
|
|
1581
|
+
|
|
1582
|
+
export function getDefaultCatalogItem(category: string | null | undefined): AssetInput | null {
|
|
1583
|
+
if (!category) return null
|
|
1584
|
+
return CATALOG_ITEMS.find((item) => item.category === category) ?? null
|
|
1585
|
+
}
|
|
@@ -2,27 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
-
import { Edit, Plus, Trash2 } from 'lucide-react'
|
|
5
|
+
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
|
6
6
|
import { useCallback, useEffect } from 'react'
|
|
7
|
+
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
7
8
|
import useEditor from '../../../store/use-editor'
|
|
8
|
-
import { ActionButton } from '../controls/action-button'
|
|
9
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
9
10
|
import { MaterialPicker } from '../controls/material-picker'
|
|
10
11
|
import { PanelSection } from '../controls/panel-section'
|
|
11
12
|
import { SliderControl } from '../controls/slider-control'
|
|
12
13
|
import { PanelWrapper } from './panel-wrapper'
|
|
13
14
|
|
|
14
15
|
export function CeilingPanel() {
|
|
15
|
-
const
|
|
16
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
16
17
|
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
-
const nodes = useScene((s) => s.nodes)
|
|
18
18
|
const updateNode = useScene((s) => s.updateNode)
|
|
19
19
|
const editingHole = useEditor((s) => s.editingHole)
|
|
20
20
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
21
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
: undefined
|
|
23
|
+
const node = useScene((s) =>
|
|
24
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
|
|
25
|
+
)
|
|
26
26
|
|
|
27
27
|
const handleUpdate = useCallback(
|
|
28
28
|
(updates: Partial<CeilingNode>) => {
|
|
@@ -34,7 +34,14 @@ export function CeilingPanel() {
|
|
|
34
34
|
|
|
35
35
|
const handleMaterialChange = useCallback(
|
|
36
36
|
(material: MaterialSchema) => {
|
|
37
|
-
handleUpdate({ material })
|
|
37
|
+
handleUpdate({ material, materialPreset: undefined })
|
|
38
|
+
},
|
|
39
|
+
[handleUpdate],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const handleMaterialPresetChange = useCallback(
|
|
43
|
+
(materialPreset: string) => {
|
|
44
|
+
handleUpdate({ materialPreset, material: undefined })
|
|
38
45
|
},
|
|
39
46
|
[handleUpdate],
|
|
40
47
|
)
|
|
@@ -77,7 +84,13 @@ export function CeilingPanel() {
|
|
|
77
84
|
[cx - holeSize, cz + holeSize],
|
|
78
85
|
]
|
|
79
86
|
const currentHoles = node?.holes || []
|
|
80
|
-
|
|
87
|
+
const currentMetadata = currentHoles.map(
|
|
88
|
+
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
|
|
89
|
+
)
|
|
90
|
+
handleUpdate({
|
|
91
|
+
holes: [...currentHoles, newHole],
|
|
92
|
+
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
|
93
|
+
})
|
|
81
94
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
82
95
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
|
83
96
|
|
|
@@ -93,16 +106,28 @@ export function CeilingPanel() {
|
|
|
93
106
|
(index: number) => {
|
|
94
107
|
if (!selectedId) return
|
|
95
108
|
const currentHoles = node?.holes || []
|
|
109
|
+
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
|
96
110
|
const newHoles = currentHoles.filter((_, i) => i !== index)
|
|
97
|
-
|
|
111
|
+
const currentMetadata = currentHoles.map(
|
|
112
|
+
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
|
113
|
+
)
|
|
114
|
+
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
|
115
|
+
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
|
98
116
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
|
99
117
|
setEditingHole(null)
|
|
100
118
|
}
|
|
101
119
|
},
|
|
102
|
-
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
|
120
|
+
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
|
103
121
|
)
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
const handleMove = useCallback(() => {
|
|
124
|
+
if (!node) return
|
|
125
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
126
|
+
setMovingNode(node)
|
|
127
|
+
setSelection({ selectedIds: [] })
|
|
128
|
+
}, [node, setMovingNode, setSelection])
|
|
129
|
+
|
|
130
|
+
if (!(node && node.type === 'ceiling' && selectedId)) return null
|
|
106
131
|
|
|
107
132
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
|
108
133
|
if (polygon.length < 3) return 0
|
|
@@ -110,8 +135,11 @@ export function CeilingPanel() {
|
|
|
110
135
|
const n = polygon.length
|
|
111
136
|
for (let i = 0; i < n; i++) {
|
|
112
137
|
const j = (i + 1) % n
|
|
113
|
-
|
|
114
|
-
|
|
138
|
+
const current = polygon[i]
|
|
139
|
+
const next = polygon[j]
|
|
140
|
+
if (!(current && next)) continue
|
|
141
|
+
area += current[0] * next[1]
|
|
142
|
+
area -= next[0] * current[1]
|
|
115
143
|
}
|
|
116
144
|
return Math.abs(area) / 2
|
|
117
145
|
}
|
|
@@ -158,6 +186,8 @@ export function CeilingPanel() {
|
|
|
158
186
|
const holeArea = calculateArea(hole)
|
|
159
187
|
const isEditing =
|
|
160
188
|
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
|
189
|
+
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
|
190
|
+
const isAutoHole = source === 'stair'
|
|
161
191
|
return (
|
|
162
192
|
<div
|
|
163
193
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
|
@@ -174,7 +204,8 @@ export function CeilingPanel() {
|
|
|
174
204
|
Hole {index + 1} {isEditing && '(Editing)'}
|
|
175
205
|
</p>
|
|
176
206
|
<p className="text-[10px] text-muted-foreground">
|
|
177
|
-
{holeArea.toFixed(2)} m² · {hole.length} pts
|
|
207
|
+
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
|
208
|
+
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
|
178
209
|
</p>
|
|
179
210
|
</div>
|
|
180
211
|
<div className="flex items-center gap-1">
|
|
@@ -184,6 +215,10 @@ export function CeilingPanel() {
|
|
|
184
215
|
label="Done"
|
|
185
216
|
onClick={() => setEditingHole(null)}
|
|
186
217
|
/>
|
|
218
|
+
) : isAutoHole ? (
|
|
219
|
+
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
|
220
|
+
Auto
|
|
221
|
+
</div>
|
|
187
222
|
) : (
|
|
188
223
|
<>
|
|
189
224
|
<button
|
|
@@ -223,8 +258,17 @@ export function CeilingPanel() {
|
|
|
223
258
|
</PanelSection>
|
|
224
259
|
|
|
225
260
|
<PanelSection title="Material">
|
|
226
|
-
<MaterialPicker
|
|
261
|
+
<MaterialPicker
|
|
262
|
+
nodeType="ceiling"
|
|
263
|
+
onChange={handleMaterialChange}
|
|
264
|
+
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
265
|
+
selectedMaterialPreset={node.materialPreset}
|
|
266
|
+
value={node.material}
|
|
267
|
+
/>
|
|
227
268
|
</PanelSection>
|
|
269
|
+
<ActionGroup>
|
|
270
|
+
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
271
|
+
</ActionGroup>
|
|
228
272
|
</PanelWrapper>
|
|
229
273
|
)
|
|
230
274
|
}
|