@pascal-app/editor 0.5.1 → 0.7.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 +12 -7
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +29 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +281 -83
- package/src/components/editor/floating-building-action-menu.tsx +4 -3
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +10442 -3275
- package/src/components/editor/index.tsx +270 -20
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/selection-manager.tsx +766 -12
- package/src/components/editor/site-edge-labels.tsx +9 -3
- package/src/components/editor/thumbnail-generator.tsx +350 -157
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +377 -58
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- 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-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +19 -0
- package/src/components/tools/door/move-door-tool.tsx +38 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
- package/src/components/tools/fence/fence-drafting.ts +27 -8
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
- package/src/components/tools/fence/move-fence-tool.tsx +102 -27
- package/src/components/tools/item/move-tool.tsx +19 -1
- package/src/components/tools/item/placement-math.ts +44 -7
- package/src/components/tools/item/placement-strategies.ts +111 -33
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
- package/src/components/tools/roof/move-roof-tool.tsx +111 -43
- package/src/components/tools/shared/polygon-editor.tsx +244 -29
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/move-slab-tool.tsx +182 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/stair/stair-tool.tsx +11 -3
- package/src/components/tools/tool-manager.tsx +30 -3
- package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
- package/src/components/tools/wall/move-wall-tool.tsx +356 -0
- package/src/components/tools/wall/wall-drafting.ts +348 -17
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +28 -0
- package/src/components/tools/window/window-tool.tsx +17 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +37 -5
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +27 -5
- package/src/components/ui/command-palette/index.tsx +0 -1
- package/src/components/ui/controls/material-picker.tsx +189 -169
- package/src/components/ui/controls/slider-control.tsx +88 -26
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +47 -27
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +986 -294
- package/src/components/ui/panels/fence-panel.tsx +55 -12
- package/src/components/ui/panels/item-panel.tsx +5 -5
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +241 -30
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +243 -9
- package/src/components/ui/panels/roof-panel.tsx +30 -62
- package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
- package/src/components/ui/panels/slab-panel.tsx +46 -24
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +117 -69
- package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
- package/src/components/ui/panels/wall-panel.tsx +71 -17
- package/src/components/ui/panels/window-panel.tsx +665 -146
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- 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 +138 -56
- 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 +9 -5
- 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/spawn-tree-node.tsx +82 -0
- 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 +12 -6
- 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 +15 -8
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +96 -2
- package/src/components/viewer-overlay.tsx +25 -19
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-contextual-tools.ts +14 -13
- package/src/hooks/use-keyboard.ts +67 -9
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +2 -1
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/history.ts +20 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/sfx-player.ts +96 -13
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +279 -15
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { AssetInput } from '@pascal-app/core'
|
|
4
4
|
import { resolveCdnUrl } from '@pascal-app/viewer'
|
|
5
5
|
import Image from 'next/image'
|
|
6
|
-
import { useEffect
|
|
6
|
+
import { useEffect } from 'react'
|
|
7
7
|
import {
|
|
8
8
|
Tooltip,
|
|
9
9
|
TooltipContent,
|
|
@@ -13,54 +13,19 @@ import { cn } from './../../../lib/utils'
|
|
|
13
13
|
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
|
14
14
|
import { CATALOG_ITEMS } from './catalog-items'
|
|
15
15
|
|
|
16
|
-
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
|
17
|
-
|
|
18
16
|
export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|
19
17
|
const selectedItem = useEditor((state) => state.selectedItem)
|
|
20
18
|
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
|
21
|
-
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
|
22
|
-
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
|
23
19
|
|
|
24
20
|
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
|
|
25
21
|
|
|
26
|
-
//
|
|
27
|
-
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
|
28
|
-
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
|
29
|
-
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
|
30
|
-
const hasFilters = allTags.length > 1
|
|
31
|
-
|
|
32
|
-
// Count items for a placement tag given the current functional filter
|
|
33
|
-
const placementCount = (tag: string | null) =>
|
|
34
|
-
categoryItems.filter((item) => {
|
|
35
|
-
const tags = item.tags ?? []
|
|
36
|
-
if (tag !== null && !tags.includes(tag)) return false
|
|
37
|
-
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
38
|
-
return true
|
|
39
|
-
}).length
|
|
40
|
-
|
|
41
|
-
// Count items for a functional tag given the current placement filter
|
|
42
|
-
const functionalCount = (tag: string) =>
|
|
43
|
-
categoryItems.filter((item) => {
|
|
44
|
-
const tags = item.tags ?? []
|
|
45
|
-
if (!tags.includes(tag)) return false
|
|
46
|
-
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
47
|
-
return true
|
|
48
|
-
}).length
|
|
49
|
-
|
|
50
|
-
const filteredItems = categoryItems.filter((item) => {
|
|
51
|
-
const tags = item.tags ?? []
|
|
52
|
-
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
53
|
-
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
54
|
-
return true
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
// Auto-select first item if current selection is not in the filtered list
|
|
22
|
+
// Auto-select first item if current selection is not in this category
|
|
58
23
|
useEffect(() => {
|
|
59
|
-
const isCurrentItemInCategory =
|
|
60
|
-
if (!isCurrentItemInCategory &&
|
|
61
|
-
setSelectedItem(
|
|
24
|
+
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
|
|
25
|
+
if (!isCurrentItemInCategory && categoryItems.length > 0) {
|
|
26
|
+
setSelectedItem(categoryItems[0] as AssetInput)
|
|
62
27
|
}
|
|
63
|
-
}, [
|
|
28
|
+
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
|
64
29
|
|
|
65
30
|
// Get attachment icon based on attachTo type
|
|
66
31
|
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
|
@@ -74,146 +39,48 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|
|
74
39
|
}
|
|
75
40
|
|
|
76
41
|
return (
|
|
77
|
-
<div className="flex
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
<
|
|
42
|
+
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
|
43
|
+
{categoryItems.map((item, index) => {
|
|
44
|
+
const isSelected = selectedItem?.src === item?.src
|
|
45
|
+
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
|
46
|
+
return (
|
|
47
|
+
<Tooltip key={index}>
|
|
48
|
+
<TooltipTrigger asChild>
|
|
84
49
|
<button
|
|
85
50
|
className={cn(
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
? 'bg-blue-500 text-white'
|
|
89
|
-
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
51
|
+
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
|
|
52
|
+
isSelected && 'ring-2 ring-primary-foreground',
|
|
90
53
|
)}
|
|
91
|
-
onClick={() =>
|
|
54
|
+
onClick={() => setSelectedItem(item)}
|
|
92
55
|
type="button"
|
|
93
56
|
>
|
|
94
|
-
|
|
57
|
+
<Image
|
|
58
|
+
alt={item.name}
|
|
59
|
+
className="rounded-lg object-cover"
|
|
60
|
+
fill
|
|
61
|
+
loading="eager"
|
|
62
|
+
sizes="56px"
|
|
63
|
+
src={resolveCdnUrl(item.thumbnail) || ''}
|
|
64
|
+
/>
|
|
65
|
+
{attachmentIcon && (
|
|
66
|
+
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
|
67
|
+
<Image
|
|
68
|
+
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
|
69
|
+
className="h-4 w-4"
|
|
70
|
+
height={16}
|
|
71
|
+
src={attachmentIcon}
|
|
72
|
+
width={16}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
95
76
|
</button>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
|
104
|
-
isActive
|
|
105
|
-
? 'bg-blue-500 text-white'
|
|
106
|
-
: isEmpty
|
|
107
|
-
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
108
|
-
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
109
|
-
)}
|
|
110
|
-
disabled={isEmpty}
|
|
111
|
-
key={tag}
|
|
112
|
-
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
|
113
|
-
type="button"
|
|
114
|
-
>
|
|
115
|
-
{tag}
|
|
116
|
-
<span
|
|
117
|
-
className={cn(
|
|
118
|
-
'text-[10px]',
|
|
119
|
-
isActive ? 'text-blue-200' : isEmpty ? 'text-zinc-600' : 'text-blue-500/70',
|
|
120
|
-
)}
|
|
121
|
-
>
|
|
122
|
-
{count}
|
|
123
|
-
</span>
|
|
124
|
-
</button>
|
|
125
|
-
)
|
|
126
|
-
})}
|
|
127
|
-
</div>
|
|
128
|
-
)}
|
|
129
|
-
|
|
130
|
-
{/* Functional row */}
|
|
131
|
-
{functionalTags.length > 0 && (
|
|
132
|
-
<div className="flex flex-wrap gap-1">
|
|
133
|
-
{functionalTags.map((tag) => {
|
|
134
|
-
const count = functionalCount(tag)
|
|
135
|
-
const isActive = activeFunctionalTag === tag
|
|
136
|
-
const isEmpty = count === 0 && !isActive
|
|
137
|
-
return (
|
|
138
|
-
<button
|
|
139
|
-
className={cn(
|
|
140
|
-
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
|
141
|
-
isActive
|
|
142
|
-
? 'bg-violet-500 text-white'
|
|
143
|
-
: isEmpty
|
|
144
|
-
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
145
|
-
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
146
|
-
)}
|
|
147
|
-
disabled={isEmpty}
|
|
148
|
-
key={tag}
|
|
149
|
-
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
|
150
|
-
type="button"
|
|
151
|
-
>
|
|
152
|
-
{tag}
|
|
153
|
-
<span
|
|
154
|
-
className={cn(
|
|
155
|
-
'text-[10px]',
|
|
156
|
-
isActive
|
|
157
|
-
? 'text-violet-200'
|
|
158
|
-
: isEmpty
|
|
159
|
-
? 'text-zinc-600'
|
|
160
|
-
: 'text-zinc-500/70',
|
|
161
|
-
)}
|
|
162
|
-
>
|
|
163
|
-
{count}
|
|
164
|
-
</span>
|
|
165
|
-
</button>
|
|
166
|
-
)
|
|
167
|
-
})}
|
|
168
|
-
</div>
|
|
169
|
-
)}
|
|
170
|
-
</div>
|
|
171
|
-
)}
|
|
172
|
-
|
|
173
|
-
{/* Items */}
|
|
174
|
-
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
|
175
|
-
{filteredItems.map((item, index) => {
|
|
176
|
-
const isSelected = selectedItem?.src === item?.src
|
|
177
|
-
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
|
178
|
-
return (
|
|
179
|
-
<Tooltip key={index}>
|
|
180
|
-
<TooltipTrigger asChild>
|
|
181
|
-
<button
|
|
182
|
-
className={cn(
|
|
183
|
-
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
|
|
184
|
-
isSelected && 'ring-2 ring-primary-foreground',
|
|
185
|
-
)}
|
|
186
|
-
onClick={() => setSelectedItem(item)}
|
|
187
|
-
type="button"
|
|
188
|
-
>
|
|
189
|
-
<Image
|
|
190
|
-
alt={item.name}
|
|
191
|
-
className="rounded-lg object-cover"
|
|
192
|
-
fill
|
|
193
|
-
loading="eager"
|
|
194
|
-
sizes="56px"
|
|
195
|
-
src={resolveCdnUrl(item.thumbnail) || ''}
|
|
196
|
-
/>
|
|
197
|
-
{attachmentIcon && (
|
|
198
|
-
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
|
199
|
-
<Image
|
|
200
|
-
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
|
201
|
-
className="h-4 w-4"
|
|
202
|
-
height={16}
|
|
203
|
-
src={attachmentIcon}
|
|
204
|
-
width={16}
|
|
205
|
-
/>
|
|
206
|
-
</div>
|
|
207
|
-
)}
|
|
208
|
-
</button>
|
|
209
|
-
</TooltipTrigger>
|
|
210
|
-
<TooltipContent className="text-xs" side="top">
|
|
211
|
-
{item.name}
|
|
212
|
-
</TooltipContent>
|
|
213
|
-
</Tooltip>
|
|
214
|
-
)
|
|
215
|
-
})}
|
|
216
|
-
</div>
|
|
77
|
+
</TooltipTrigger>
|
|
78
|
+
<TooltipContent className="text-xs" side="top">
|
|
79
|
+
{item.name}
|
|
80
|
+
</TooltipContent>
|
|
81
|
+
</Tooltip>
|
|
82
|
+
)
|
|
83
|
+
})}
|
|
217
84
|
</div>
|
|
218
85
|
)
|
|
219
86
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { LevelNode } from '@pascal-app/core'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from './primitives/dialog'
|
|
15
|
+
|
|
16
|
+
const DUPLICATE_PRESETS: Array<{
|
|
17
|
+
id: LevelDuplicatePreset
|
|
18
|
+
label: string
|
|
19
|
+
description: string
|
|
20
|
+
}> = [
|
|
21
|
+
{
|
|
22
|
+
id: 'everything',
|
|
23
|
+
label: 'Everything',
|
|
24
|
+
description: 'Structure, materials, furniture, and references.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'structure',
|
|
28
|
+
label: 'Structure only',
|
|
29
|
+
description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'structure-materials',
|
|
33
|
+
label: 'Structure + materials',
|
|
34
|
+
description: 'Structure with the current material and finish assignments.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'structure-furniture',
|
|
38
|
+
label: 'Structure + furniture',
|
|
39
|
+
description: 'Structure, finishes, and placed items, without guide references.',
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function getLevelLabel(level: LevelNode | null) {
|
|
44
|
+
if (!level) return 'this level'
|
|
45
|
+
return level.name || `Level ${level.level}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function LevelDuplicateDialog({
|
|
49
|
+
open,
|
|
50
|
+
level,
|
|
51
|
+
onConfirm,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
}: {
|
|
54
|
+
open: boolean
|
|
55
|
+
level: LevelNode | null
|
|
56
|
+
onConfirm: (preset: LevelDuplicatePreset) => void
|
|
57
|
+
onOpenChange: (open: boolean) => void
|
|
58
|
+
}) {
|
|
59
|
+
const [preset, setPreset] = useState<LevelDuplicatePreset>('everything')
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (open) {
|
|
63
|
+
setPreset('everything')
|
|
64
|
+
}
|
|
65
|
+
}, [open])
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
69
|
+
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
|
70
|
+
<DialogHeader>
|
|
71
|
+
<DialogTitle>Duplicate Level</DialogTitle>
|
|
72
|
+
<DialogDescription>
|
|
73
|
+
Choose what to copy from {getLevelLabel(level)}.
|
|
74
|
+
</DialogDescription>
|
|
75
|
+
</DialogHeader>
|
|
76
|
+
|
|
77
|
+
<div className="grid gap-2">
|
|
78
|
+
{DUPLICATE_PRESETS.map((option) => (
|
|
79
|
+
<button
|
|
80
|
+
className={cn(
|
|
81
|
+
'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
|
|
82
|
+
preset === option.id
|
|
83
|
+
? 'border-primary bg-primary/10 text-foreground'
|
|
84
|
+
: 'border-border bg-background hover:bg-accent/40',
|
|
85
|
+
)}
|
|
86
|
+
key={option.id}
|
|
87
|
+
onClick={() => setPreset(option.id)}
|
|
88
|
+
type="button"
|
|
89
|
+
>
|
|
90
|
+
<div className="font-medium text-sm">{option.label}</div>
|
|
91
|
+
<div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<DialogFooter>
|
|
97
|
+
<button
|
|
98
|
+
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
|
|
99
|
+
onClick={() => onOpenChange(false)}
|
|
100
|
+
type="button"
|
|
101
|
+
>
|
|
102
|
+
Cancel
|
|
103
|
+
</button>
|
|
104
|
+
<button
|
|
105
|
+
className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
|
|
106
|
+
onClick={() => onConfirm(preset)}
|
|
107
|
+
type="button"
|
|
108
|
+
>
|
|
109
|
+
Duplicate
|
|
110
|
+
</button>
|
|
111
|
+
</DialogFooter>
|
|
112
|
+
</DialogContent>
|
|
113
|
+
</Dialog>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type AnyNode, type CeilingNode,
|
|
3
|
+
import { type AnyNode, type CeilingNode, 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 { MaterialPicker } from '../controls/material-picker'
|
|
9
|
+
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
10
10
|
import { PanelSection } from '../controls/panel-section'
|
|
11
11
|
import { SliderControl } from '../controls/slider-control'
|
|
12
12
|
import { PanelWrapper } from './panel-wrapper'
|
|
13
13
|
|
|
14
14
|
export function CeilingPanel() {
|
|
15
|
-
const
|
|
15
|
+
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
|
16
16
|
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
-
const nodes = useScene((s) => s.nodes)
|
|
18
17
|
const updateNode = useScene((s) => s.updateNode)
|
|
19
18
|
const editingHole = useEditor((s) => s.editingHole)
|
|
20
19
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
20
|
+
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
: undefined
|
|
22
|
+
const node = useScene((s) =>
|
|
23
|
+
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
|
|
24
|
+
)
|
|
26
25
|
|
|
27
26
|
const handleUpdate = useCallback(
|
|
28
27
|
(updates: Partial<CeilingNode>) => {
|
|
@@ -32,13 +31,6 @@ export function CeilingPanel() {
|
|
|
32
31
|
[selectedId, updateNode],
|
|
33
32
|
)
|
|
34
33
|
|
|
35
|
-
const handleMaterialChange = useCallback(
|
|
36
|
-
(material: MaterialSchema) => {
|
|
37
|
-
handleUpdate({ material })
|
|
38
|
-
},
|
|
39
|
-
[handleUpdate],
|
|
40
|
-
)
|
|
41
|
-
|
|
42
34
|
const handleClose = useCallback(() => {
|
|
43
35
|
setSelection({ selectedIds: [] })
|
|
44
36
|
setEditingHole(null)
|
|
@@ -77,7 +69,13 @@ export function CeilingPanel() {
|
|
|
77
69
|
[cx - holeSize, cz + holeSize],
|
|
78
70
|
]
|
|
79
71
|
const currentHoles = node?.holes || []
|
|
80
|
-
|
|
72
|
+
const currentMetadata = currentHoles.map(
|
|
73
|
+
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
|
|
74
|
+
)
|
|
75
|
+
handleUpdate({
|
|
76
|
+
holes: [...currentHoles, newHole],
|
|
77
|
+
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
|
78
|
+
})
|
|
81
79
|
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
82
80
|
}, [node, selectedId, handleUpdate, setEditingHole])
|
|
83
81
|
|
|
@@ -93,16 +91,28 @@ export function CeilingPanel() {
|
|
|
93
91
|
(index: number) => {
|
|
94
92
|
if (!selectedId) return
|
|
95
93
|
const currentHoles = node?.holes || []
|
|
94
|
+
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
|
96
95
|
const newHoles = currentHoles.filter((_, i) => i !== index)
|
|
97
|
-
|
|
96
|
+
const currentMetadata = currentHoles.map(
|
|
97
|
+
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
|
98
|
+
)
|
|
99
|
+
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
|
100
|
+
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
|
98
101
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
|
99
102
|
setEditingHole(null)
|
|
100
103
|
}
|
|
101
104
|
},
|
|
102
|
-
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
|
105
|
+
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
|
103
106
|
)
|
|
104
107
|
|
|
105
|
-
|
|
108
|
+
const handleMove = useCallback(() => {
|
|
109
|
+
if (!node) return
|
|
110
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
111
|
+
setMovingNode(node)
|
|
112
|
+
setSelection({ selectedIds: [] })
|
|
113
|
+
}, [node, setMovingNode, setSelection])
|
|
114
|
+
|
|
115
|
+
if (!(node && node.type === 'ceiling' && selectedId)) return null
|
|
106
116
|
|
|
107
117
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
|
108
118
|
if (polygon.length < 3) return 0
|
|
@@ -110,8 +120,11 @@ export function CeilingPanel() {
|
|
|
110
120
|
const n = polygon.length
|
|
111
121
|
for (let i = 0; i < n; i++) {
|
|
112
122
|
const j = (i + 1) % n
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
const current = polygon[i]
|
|
124
|
+
const next = polygon[j]
|
|
125
|
+
if (!(current && next)) continue
|
|
126
|
+
area += current[0] * next[1]
|
|
127
|
+
area -= next[0] * current[1]
|
|
115
128
|
}
|
|
116
129
|
return Math.abs(area) / 2
|
|
117
130
|
}
|
|
@@ -158,6 +171,8 @@ export function CeilingPanel() {
|
|
|
158
171
|
const holeArea = calculateArea(hole)
|
|
159
172
|
const isEditing =
|
|
160
173
|
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
|
174
|
+
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
|
175
|
+
const isAutoHole = source === 'stair'
|
|
161
176
|
return (
|
|
162
177
|
<div
|
|
163
178
|
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
|
@@ -174,7 +189,8 @@ export function CeilingPanel() {
|
|
|
174
189
|
Hole {index + 1} {isEditing && '(Editing)'}
|
|
175
190
|
</p>
|
|
176
191
|
<p className="text-[10px] text-muted-foreground">
|
|
177
|
-
{holeArea.toFixed(2)} m² · {hole.length} pts
|
|
192
|
+
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
|
193
|
+
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
|
178
194
|
</p>
|
|
179
195
|
</div>
|
|
180
196
|
<div className="flex items-center gap-1">
|
|
@@ -184,6 +200,10 @@ export function CeilingPanel() {
|
|
|
184
200
|
label="Done"
|
|
185
201
|
onClick={() => setEditingHole(null)}
|
|
186
202
|
/>
|
|
203
|
+
) : isAutoHole ? (
|
|
204
|
+
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
|
205
|
+
Auto
|
|
206
|
+
</div>
|
|
187
207
|
) : (
|
|
188
208
|
<>
|
|
189
209
|
<button
|
|
@@ -222,9 +242,9 @@ export function CeilingPanel() {
|
|
|
222
242
|
</div>
|
|
223
243
|
</PanelSection>
|
|
224
244
|
|
|
225
|
-
<
|
|
226
|
-
<
|
|
227
|
-
</
|
|
245
|
+
<ActionGroup>
|
|
246
|
+
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
247
|
+
</ActionGroup>
|
|
228
248
|
</PanelWrapper>
|
|
229
249
|
)
|
|
230
250
|
}
|