@pascal-app/editor 0.5.1 → 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 +255 -34
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-panel.tsx +1323 -713
- package/src/components/editor/index.tsx +2 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +200 -8
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +319 -157
- 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/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-tool.tsx +12 -0
- package/src/components/tools/door/move-door-tool.tsx +10 -0
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +19 -7
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
- package/src/components/tools/fence/move-fence-tool.tsx +8 -0
- package/src/components/tools/item/move-tool.tsx +9 -0
- package/src/components/tools/item/placement-math.ts +14 -6
- package/src/components/tools/item/placement-strategies.ts +2 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +42 -10
- package/src/components/tools/roof/move-roof-tool.tsx +89 -28
- 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/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +12 -0
- 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/window/move-window-tool.tsx +10 -0
- package/src/components/tools/window/window-tool.tsx +12 -0
- package/src/components/ui/action-menu/control-modes.tsx +9 -4
- 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/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 +97 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/panel-manager.tsx +31 -29
- 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 +173 -19
- 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 +7 -3
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +29 -32
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +7 -3
- package/src/components/ui/viewer-toolbar.tsx +55 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +3 -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 +118 -10
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -25,17 +25,17 @@ import { PanelWrapper } from './panel-wrapper'
|
|
|
25
25
|
import { PresetsPopover } from './presets/presets-popover'
|
|
26
26
|
|
|
27
27
|
export function DoorPanel() {
|
|
28
|
-
const
|
|
28
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
29
29
|
const setSelection = useViewer((s) => s.setSelection)
|
|
30
|
-
const nodes = useScene((s) => s.nodes)
|
|
31
30
|
const updateNode = useScene((s) => s.updateNode)
|
|
32
31
|
const deleteNode = useScene((s) => s.deleteNode)
|
|
33
32
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
34
33
|
|
|
35
34
|
const adapter = usePresetsAdapter()
|
|
36
35
|
|
|
37
|
-
const
|
|
38
|
-
|
|
36
|
+
const node = useScene((s) =>
|
|
37
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
|
|
38
|
+
)
|
|
39
39
|
|
|
40
40
|
const handleUpdate = useCallback(
|
|
41
41
|
(updates: Partial<DoorNode>) => {
|
|
@@ -182,7 +182,7 @@ export function DoorPanel() {
|
|
|
182
182
|
[handleUpdate],
|
|
183
183
|
)
|
|
184
184
|
|
|
185
|
-
if (!node
|
|
185
|
+
if (!(node && node.type === 'door' && selectedId)) return null
|
|
186
186
|
|
|
187
187
|
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
|
188
188
|
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|