@pascal-app/editor 0.6.0 → 0.8.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 +13 -9
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +74 -5
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +24 -3
- package/src/components/editor/first-person/build-collider-world.ts +363 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +860 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9861 -3297
- package/src/components/editor/index.tsx +295 -32
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
- package/src/components/editor/thumbnail-generator.tsx +56 -68
- 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 +267 -36
- 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 +124 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +202 -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 +10 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1 -1
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
- 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 +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +160 -4
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +139 -25
- package/src/components/tools/fence/move-fence-tool.tsx +111 -40
- package/src/components/tools/item/move-tool.tsx +7 -1
- package/src/components/tools/item/placement-math.ts +32 -5
- package/src/components/tools/item/placement-strategies.ts +110 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +1 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +558 -52
- package/src/components/tools/roof/move-roof-tool.tsx +29 -17
- package/src/components/tools/select/box-select-tool.tsx +12 -17
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -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/tool-manager.tsx +20 -5
- package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +131 -27
- package/src/components/tools/wall/move-wall-tool.tsx +6 -4
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +136 -4
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/tools/zone/zone-tool.tsx +20 -5
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +34 -1
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +98 -59
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +418 -41
- package/src/components/ui/command-palette/editor-commands.tsx +24 -5
- package/src/components/ui/command-palette/index.tsx +4 -255
- package/src/components/ui/controls/material-picker.tsx +154 -164
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +10 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +2563 -1239
- package/src/components/ui/item-catalog/item-catalog.tsx +96 -187
- package/src/components/ui/level-duplicate-dialog.tsx +113 -0
- package/src/components/ui/panels/ceiling-panel.tsx +3 -28
- package/src/components/ui/panels/column-panel.tsx +759 -0
- package/src/components/ui/panels/door-panel.tsx +989 -290
- package/src/components/ui/panels/fence-panel.tsx +2 -49
- 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 +163 -0
- package/src/components/ui/panels/panel-manager.tsx +208 -28
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +253 -5
- package/src/components/ui/panels/roof-panel.tsx +13 -64
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +161 -0
- package/src/components/ui/panels/stair-panel.tsx +20 -74
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +10 -8
- package/src/components/ui/panels/window-panel.tsx +668 -139
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -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 +2 -2
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +105 -22
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +76 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +11 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +10 -5
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +0 -0
- package/src/components/viewer-zone-system.tsx +0 -0
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-auto-save.ts +14 -0
- package/src/hooks/use-keyboard.ts +74 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/index.tsx +8 -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/level-duplication.test.ts +70 -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/scene.ts +0 -0
- package/src/lib/sfx-bus.ts +2 -0
- package/src/lib/sfx-player.ts +5 -5
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +186 -62
- package/tsconfig.json +2 -1
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/ui/viewer-toolbar.tsx +0 -395
|
@@ -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,207 +13,116 @@ 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export function ItemCatalog({
|
|
17
|
+
category,
|
|
18
|
+
items: itemsOverride,
|
|
19
|
+
activePlacementTag = null,
|
|
20
|
+
activeFunctionalTag = null,
|
|
21
|
+
search = '',
|
|
22
|
+
overrideItems,
|
|
23
|
+
leadingTile,
|
|
24
|
+
emptyState,
|
|
25
|
+
}: {
|
|
26
|
+
category: CatalogCategory
|
|
27
|
+
items?: AssetInput[]
|
|
28
|
+
activePlacementTag?: string | null
|
|
29
|
+
activeFunctionalTag?: string | null
|
|
30
|
+
search?: string
|
|
31
|
+
/** When set, bypasses all filtering and displays these items directly (used for server search results) */
|
|
32
|
+
overrideItems?: AssetInput[]
|
|
33
|
+
/** Rendered as the first grid cell, always visible when there are items. */
|
|
34
|
+
leadingTile?: React.ReactNode
|
|
35
|
+
/** Rendered when there are no items to show. Replaces the empty grid. */
|
|
36
|
+
emptyState?: React.ReactNode
|
|
37
|
+
}) {
|
|
19
38
|
const selectedItem = useEditor((state) => state.selectedItem)
|
|
20
39
|
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
|
|
25
|
-
|
|
26
|
-
// Collect tags available in this category
|
|
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
|
+
const setMode = useEditor((state) => state.setMode)
|
|
41
|
+
const setTool = useEditor((state) => state.setTool)
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
const sourceItems = itemsOverride ?? CATALOG_ITEMS
|
|
44
|
+
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
|
|
45
|
+
const filteredItems =
|
|
46
|
+
overrideItems ??
|
|
47
|
+
(() => {
|
|
48
|
+
const categoryItems = search
|
|
49
|
+
? sourceItems
|
|
50
|
+
: sourceItems.filter((item) => item.category === category)
|
|
51
|
+
return categoryItems.filter((item) => {
|
|
52
|
+
const tags = item.tags ?? []
|
|
53
|
+
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
54
|
+
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
55
|
+
if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
|
|
56
|
+
return true
|
|
57
|
+
})
|
|
58
|
+
})()
|
|
49
59
|
|
|
50
|
-
const
|
|
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
|
-
})
|
|
60
|
+
const categoryItems = filteredItems
|
|
56
61
|
|
|
57
62
|
// Auto-select first item if current selection is not in the filtered list
|
|
58
63
|
useEffect(() => {
|
|
59
|
-
const isCurrentItemInCategory =
|
|
60
|
-
if (!isCurrentItemInCategory &&
|
|
61
|
-
setSelectedItem(
|
|
64
|
+
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
|
|
65
|
+
if (!isCurrentItemInCategory && categoryItems.length > 0) {
|
|
66
|
+
setSelectedItem(categoryItems[0] as AssetInput)
|
|
62
67
|
}
|
|
63
|
-
}, [
|
|
68
|
+
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
|
64
69
|
|
|
65
|
-
// Get attachment icon based on attachTo type
|
|
66
70
|
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
|
67
|
-
if (attachTo === 'wall' || attachTo === 'wall-side')
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
if (attachTo === 'ceiling') {
|
|
71
|
-
return '/icons/ceiling.png'
|
|
72
|
-
}
|
|
71
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
|
|
72
|
+
if (attachTo === 'ceiling') return '/icons/ceiling.png'
|
|
73
73
|
return null
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{hasFilters && (
|
|
80
|
-
<div className="flex flex-col gap-1.5">
|
|
81
|
-
{/* Placement row */}
|
|
82
|
-
{placementTags.length > 0 && (
|
|
83
|
-
<div className="flex flex-wrap gap-1">
|
|
84
|
-
<button
|
|
85
|
-
className={cn(
|
|
86
|
-
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
|
87
|
-
activePlacementTag === null
|
|
88
|
-
? 'bg-blue-500 text-white'
|
|
89
|
-
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
90
|
-
)}
|
|
91
|
-
onClick={() => setActivePlacementTag(null)}
|
|
92
|
-
type="button"
|
|
93
|
-
>
|
|
94
|
-
All
|
|
95
|
-
</button>
|
|
96
|
-
{placementTags.map((tag) => {
|
|
97
|
-
const count = placementCount(tag)
|
|
98
|
-
const isActive = activePlacementTag === tag
|
|
99
|
-
const isEmpty = count === 0 && !isActive
|
|
100
|
-
return (
|
|
101
|
-
<button
|
|
102
|
-
className={cn(
|
|
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
|
-
)}
|
|
76
|
+
if (filteredItems.length === 0 && emptyState) {
|
|
77
|
+
return <>{emptyState}</>
|
|
78
|
+
}
|
|
172
79
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="grid gap-2"
|
|
83
|
+
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
|
|
84
|
+
>
|
|
85
|
+
{leadingTile}
|
|
86
|
+
{filteredItems.map((item, index) => {
|
|
87
|
+
const isSelected = selectedItem?.src === item?.src
|
|
88
|
+
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
className={cn(
|
|
92
|
+
'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
|
|
93
|
+
isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
|
|
94
|
+
)}
|
|
95
|
+
key={index}
|
|
96
|
+
onClick={() => {
|
|
97
|
+
setSelectedItem(item)
|
|
98
|
+
setTool('item')
|
|
99
|
+
setMode('build')
|
|
100
|
+
}}
|
|
101
|
+
type="button"
|
|
102
|
+
>
|
|
103
|
+
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
|
|
104
|
+
<img
|
|
105
|
+
alt={item.name}
|
|
106
|
+
className="h-full w-full object-cover"
|
|
107
|
+
loading="eager"
|
|
108
|
+
src={resolveCdnUrl(item.thumbnail) || ''}
|
|
109
|
+
/>
|
|
110
|
+
{attachmentIcon && (
|
|
111
|
+
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
|
|
112
|
+
<img
|
|
113
|
+
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
|
|
114
|
+
className="h-4 w-4"
|
|
115
|
+
src={attachmentIcon}
|
|
196
116
|
/>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
|
|
121
|
+
{item.name}
|
|
122
|
+
</span>
|
|
123
|
+
</button>
|
|
124
|
+
)
|
|
125
|
+
})}
|
|
217
126
|
</div>
|
|
218
127
|
)
|
|
219
128
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { LevelNode } from '@pascal-app/core'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
|
|
6
|
+
import { cn } from '../../lib/utils'
|
|
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>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
|
|
73
|
+
</DialogHeader>
|
|
74
|
+
|
|
75
|
+
<div className="grid gap-2">
|
|
76
|
+
{DUPLICATE_PRESETS.map((option) => (
|
|
77
|
+
<button
|
|
78
|
+
className={cn(
|
|
79
|
+
'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
|
|
80
|
+
preset === option.id
|
|
81
|
+
? 'border-primary bg-primary/10 text-foreground'
|
|
82
|
+
: 'border-border bg-background hover:bg-accent/40',
|
|
83
|
+
)}
|
|
84
|
+
key={option.id}
|
|
85
|
+
onClick={() => setPreset(option.id)}
|
|
86
|
+
type="button"
|
|
87
|
+
>
|
|
88
|
+
<div className="font-medium text-sm">{option.label}</div>
|
|
89
|
+
<div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<DialogFooter>
|
|
95
|
+
<button
|
|
96
|
+
className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
|
|
97
|
+
onClick={() => onOpenChange(false)}
|
|
98
|
+
type="button"
|
|
99
|
+
>
|
|
100
|
+
Cancel
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
|
|
104
|
+
onClick={() => onConfirm(preset)}
|
|
105
|
+
type="button"
|
|
106
|
+
>
|
|
107
|
+
Duplicate
|
|
108
|
+
</button>
|
|
109
|
+
</DialogFooter>
|
|
110
|
+
</DialogContent>
|
|
111
|
+
</Dialog>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
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
5
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
|
6
6
|
import { useCallback, useEffect } from 'react'
|
|
7
7
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
8
8
|
import useEditor from '../../../store/use-editor'
|
|
9
9
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
|
10
|
-
import { MaterialPicker } from '../controls/material-picker'
|
|
11
10
|
import { PanelSection } from '../controls/panel-section'
|
|
12
11
|
import { SliderControl } from '../controls/slider-control'
|
|
13
12
|
import { PanelWrapper } from './panel-wrapper'
|
|
@@ -32,20 +31,6 @@ export function CeilingPanel() {
|
|
|
32
31
|
[selectedId, updateNode],
|
|
33
32
|
)
|
|
34
33
|
|
|
35
|
-
const handleMaterialChange = useCallback(
|
|
36
|
-
(material: MaterialSchema) => {
|
|
37
|
-
handleUpdate({ material, materialPreset: undefined })
|
|
38
|
-
},
|
|
39
|
-
[handleUpdate],
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
const handleMaterialPresetChange = useCallback(
|
|
43
|
-
(materialPreset: string) => {
|
|
44
|
-
handleUpdate({ materialPreset, material: undefined })
|
|
45
|
-
},
|
|
46
|
-
[handleUpdate],
|
|
47
|
-
)
|
|
48
|
-
|
|
49
34
|
const handleClose = useCallback(() => {
|
|
50
35
|
setSelection({ selectedIds: [] })
|
|
51
36
|
setEditingHole(null)
|
|
@@ -135,9 +120,8 @@ export function CeilingPanel() {
|
|
|
135
120
|
const n = polygon.length
|
|
136
121
|
for (let i = 0; i < n; i++) {
|
|
137
122
|
const j = (i + 1) % n
|
|
138
|
-
const current = polygon[i]
|
|
139
|
-
const next = polygon[j]
|
|
140
|
-
if (!(current && next)) continue
|
|
123
|
+
const current = polygon[i]!
|
|
124
|
+
const next = polygon[j]!
|
|
141
125
|
area += current[0] * next[1]
|
|
142
126
|
area -= next[0] * current[1]
|
|
143
127
|
}
|
|
@@ -257,15 +241,6 @@ export function CeilingPanel() {
|
|
|
257
241
|
</div>
|
|
258
242
|
</PanelSection>
|
|
259
243
|
|
|
260
|
-
<PanelSection title="Material">
|
|
261
|
-
<MaterialPicker
|
|
262
|
-
nodeType="ceiling"
|
|
263
|
-
onChange={handleMaterialChange}
|
|
264
|
-
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
265
|
-
selectedMaterialPreset={node.materialPreset}
|
|
266
|
-
value={node.material}
|
|
267
|
-
/>
|
|
268
|
-
</PanelSection>
|
|
269
244
|
<ActionGroup>
|
|
270
245
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
271
246
|
</ActionGroup>
|