@pascal-app/editor 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/components/editor/custom-camera-controls.tsx +387 -0
- package/src/components/editor/editor-layout-v2.tsx +220 -0
- package/src/components/editor/export-manager.tsx +78 -0
- package/src/components/editor/first-person-controls.tsx +249 -0
- package/src/components/editor/floating-action-menu.tsx +231 -0
- package/src/components/editor/floorplan-panel.tsx +9609 -0
- package/src/components/editor/grid.tsx +161 -0
- package/src/components/editor/index.tsx +928 -0
- package/src/components/editor/node-action-menu.tsx +66 -0
- package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
- package/src/components/editor/selection-manager.tsx +897 -0
- package/src/components/editor/site-edge-labels.tsx +90 -0
- package/src/components/editor/thumbnail-generator.tsx +166 -0
- package/src/components/editor/wall-measurement-label.tsx +258 -0
- package/src/components/feedback-dialog.tsx +265 -0
- package/src/components/pascal-radio.tsx +280 -0
- package/src/components/preview-button.tsx +16 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
- package/src/components/systems/roof/roof-edit-system.tsx +69 -0
- package/src/components/systems/stair/stair-edit-system.tsx +69 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
- package/src/components/systems/zone/zone-system.tsx +87 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
- package/src/components/tools/door/door-math.ts +110 -0
- package/src/components/tools/door/door-tool.tsx +293 -0
- package/src/components/tools/door/move-door-tool.tsx +373 -0
- package/src/components/tools/item/item-tool.tsx +26 -0
- package/src/components/tools/item/move-tool.tsx +90 -0
- package/src/components/tools/item/placement-math.ts +85 -0
- package/src/components/tools/item/placement-strategies.ts +556 -0
- package/src/components/tools/item/placement-types.ts +117 -0
- package/src/components/tools/item/use-draft-node.ts +227 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
- package/src/components/tools/roof/move-roof-tool.tsx +288 -0
- package/src/components/tools/roof/roof-tool.tsx +318 -0
- package/src/components/tools/select/box-select-tool.tsx +626 -0
- package/src/components/tools/shared/cursor-sphere.tsx +119 -0
- package/src/components/tools/shared/polygon-editor.tsx +361 -0
- package/src/components/tools/site/site-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
- package/src/components/tools/slab/slab-tool.tsx +322 -0
- package/src/components/tools/stair/stair-defaults.ts +7 -0
- package/src/components/tools/stair/stair-tool.tsx +194 -0
- package/src/components/tools/tool-manager.tsx +120 -0
- package/src/components/tools/wall/wall-drafting.ts +140 -0
- package/src/components/tools/wall/wall-tool.tsx +210 -0
- package/src/components/tools/window/move-window-tool.tsx +410 -0
- package/src/components/tools/window/window-math.ts +117 -0
- package/src/components/tools/window/window-tool.tsx +303 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
- package/src/components/tools/zone/zone-tool.tsx +364 -0
- package/src/components/ui/action-menu/action-button.tsx +59 -0
- package/src/components/ui/action-menu/camera-actions.tsx +74 -0
- package/src/components/ui/action-menu/control-modes.tsx +240 -0
- package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
- package/src/components/ui/action-menu/index.tsx +152 -0
- package/src/components/ui/action-menu/structure-tools.tsx +100 -0
- package/src/components/ui/action-menu/view-toggles.tsx +397 -0
- package/src/components/ui/command-palette/editor-commands.tsx +396 -0
- package/src/components/ui/command-palette/index.tsx +730 -0
- package/src/components/ui/controls/action-button.tsx +33 -0
- package/src/components/ui/controls/material-picker.tsx +194 -0
- package/src/components/ui/controls/metric-control.tsx +262 -0
- package/src/components/ui/controls/panel-section.tsx +65 -0
- package/src/components/ui/controls/segmented-control.tsx +45 -0
- package/src/components/ui/controls/slider-control.tsx +245 -0
- package/src/components/ui/controls/toggle-control.tsx +38 -0
- package/src/components/ui/floating-level-selector.tsx +355 -0
- package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
- package/src/components/ui/helpers/helper-manager.tsx +33 -0
- package/src/components/ui/helpers/item-helper.tsx +40 -0
- package/src/components/ui/helpers/roof-helper.tsx +16 -0
- package/src/components/ui/helpers/slab-helper.tsx +20 -0
- package/src/components/ui/helpers/wall-helper.tsx +20 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
- package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
- package/src/components/ui/panels/ceiling-panel.tsx +230 -0
- package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
- package/src/components/ui/panels/door-panel.tsx +600 -0
- package/src/components/ui/panels/item-panel.tsx +306 -0
- package/src/components/ui/panels/panel-manager.tsx +59 -0
- package/src/components/ui/panels/panel-wrapper.tsx +80 -0
- package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
- package/src/components/ui/panels/reference-panel.tsx +177 -0
- package/src/components/ui/panels/roof-panel.tsx +262 -0
- package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
- package/src/components/ui/panels/slab-panel.tsx +228 -0
- package/src/components/ui/panels/stair-panel.tsx +304 -0
- package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
- package/src/components/ui/panels/wall-panel.tsx +123 -0
- package/src/components/ui/panels/window-panel.tsx +441 -0
- package/src/components/ui/primitives/button.tsx +69 -0
- package/src/components/ui/primitives/card.tsx +75 -0
- package/src/components/ui/primitives/color-dot.tsx +61 -0
- package/src/components/ui/primitives/context-menu.tsx +227 -0
- package/src/components/ui/primitives/dialog.tsx +129 -0
- package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
- package/src/components/ui/primitives/error-boundary.tsx +52 -0
- package/src/components/ui/primitives/input.tsx +21 -0
- package/src/components/ui/primitives/number-input.tsx +187 -0
- package/src/components/ui/primitives/opacity-control.tsx +79 -0
- package/src/components/ui/primitives/popover.tsx +42 -0
- package/src/components/ui/primitives/separator.tsx +28 -0
- package/src/components/ui/primitives/sheet.tsx +130 -0
- package/src/components/ui/primitives/shortcut-token.tsx +64 -0
- package/src/components/ui/primitives/sidebar.tsx +855 -0
- package/src/components/ui/primitives/skeleton.tsx +13 -0
- package/src/components/ui/primitives/slider.tsx +58 -0
- package/src/components/ui/primitives/switch.tsx +29 -0
- package/src/components/ui/primitives/tooltip.tsx +57 -0
- package/src/components/ui/scene-loader.tsx +40 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
- package/src/components/ui/sidebar/icon-rail.tsx +147 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
- package/src/components/ui/sidebar/tab-bar.tsx +39 -0
- package/src/components/ui/slider-demo.tsx +36 -0
- package/src/components/ui/slider.tsx +81 -0
- package/src/components/ui/viewer-toolbar.tsx +342 -0
- package/src/components/viewer-overlay.tsx +499 -0
- package/src/components/viewer-zone-system.tsx +48 -0
- package/src/contexts/presets-context.tsx +121 -0
- package/src/hooks/use-auto-save.ts +194 -0
- package/src/hooks/use-contextual-tools.ts +52 -0
- package/src/hooks/use-grid-events.ts +106 -0
- package/src/hooks/use-keyboard.ts +214 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-reduced-motion.ts +20 -0
- package/src/index.tsx +33 -0
- package/src/lib/constants.ts +3 -0
- package/src/lib/level-selection.ts +31 -0
- package/src/lib/scene.ts +394 -0
- package/src/lib/sfx/index.ts +2 -0
- package/src/lib/sfx-bus.ts +49 -0
- package/src/lib/sfx-player.ts +60 -0
- package/src/lib/utils.ts +43 -0
- package/src/store/use-audio.tsx +45 -0
- package/src/store/use-command-registry.ts +36 -0
- package/src/store/use-editor.tsx +522 -0
- package/src/store/use-palette-view-registry.ts +45 -0
- package/src/store/use-upload.ts +90 -0
- package/src/three-types.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AssetInput } from '@pascal-app/core'
|
|
4
|
+
import { resolveCdnUrl } from '@pascal-app/viewer'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import { useEffect, useState } from 'react'
|
|
7
|
+
import {
|
|
8
|
+
Tooltip,
|
|
9
|
+
TooltipContent,
|
|
10
|
+
TooltipTrigger,
|
|
11
|
+
} from './../../../components/ui/primitives/tooltip'
|
|
12
|
+
import { cn } from './../../../lib/utils'
|
|
13
|
+
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
|
14
|
+
import { CATALOG_ITEMS } from './catalog-items'
|
|
15
|
+
|
|
16
|
+
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
|
17
|
+
|
|
18
|
+
export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|
19
|
+
const selectedItem = useEditor((state) => state.selectedItem)
|
|
20
|
+
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
|
21
|
+
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
|
22
|
+
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
|
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
|
+
|
|
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
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)
|
|
60
|
+
if (!isCurrentItemInCategory && filteredItems.length > 0) {
|
|
61
|
+
setSelectedItem(filteredItems[0] as AssetInput)
|
|
62
|
+
}
|
|
63
|
+
}, [filteredItems, selectedItem?.src, setSelectedItem])
|
|
64
|
+
|
|
65
|
+
// Get attachment icon based on attachTo type
|
|
66
|
+
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
|
67
|
+
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
|
68
|
+
return '/icons/wall.png'
|
|
69
|
+
}
|
|
70
|
+
if (attachTo === 'ceiling') {
|
|
71
|
+
return '/icons/ceiling.png'
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col gap-2">
|
|
78
|
+
{/* Filter chips */}
|
|
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
|
+
)}
|
|
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>
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Edit, Plus, Trash2 } from 'lucide-react'
|
|
6
|
+
import { useCallback, useEffect } from 'react'
|
|
7
|
+
import useEditor from '../../../store/use-editor'
|
|
8
|
+
import { ActionButton } from '../controls/action-button'
|
|
9
|
+
import { MaterialPicker } from '../controls/material-picker'
|
|
10
|
+
import { PanelSection } from '../controls/panel-section'
|
|
11
|
+
import { SliderControl } from '../controls/slider-control'
|
|
12
|
+
import { PanelWrapper } from './panel-wrapper'
|
|
13
|
+
|
|
14
|
+
export function CeilingPanel() {
|
|
15
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
16
|
+
const setSelection = useViewer((s) => s.setSelection)
|
|
17
|
+
const nodes = useScene((s) => s.nodes)
|
|
18
|
+
const updateNode = useScene((s) => s.updateNode)
|
|
19
|
+
const editingHole = useEditor((s) => s.editingHole)
|
|
20
|
+
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
21
|
+
|
|
22
|
+
const selectedId = selectedIds[0]
|
|
23
|
+
const node = selectedId
|
|
24
|
+
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
|
25
|
+
: undefined
|
|
26
|
+
|
|
27
|
+
const handleUpdate = useCallback(
|
|
28
|
+
(updates: Partial<CeilingNode>) => {
|
|
29
|
+
if (!selectedId) return
|
|
30
|
+
updateNode(selectedId as AnyNode['id'], updates)
|
|
31
|
+
},
|
|
32
|
+
[selectedId, updateNode],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const handleMaterialChange = useCallback(
|
|
36
|
+
(material: MaterialSchema) => {
|
|
37
|
+
handleUpdate({ material })
|
|
38
|
+
},
|
|
39
|
+
[handleUpdate],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const handleClose = useCallback(() => {
|
|
43
|
+
setSelection({ selectedIds: [] })
|
|
44
|
+
setEditingHole(null)
|
|
45
|
+
}, [setSelection, setEditingHole])
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!node) {
|
|
49
|
+
setEditingHole(null)
|
|
50
|
+
}
|
|
51
|
+
}, [node, setEditingHole])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
return () => {
|
|
55
|
+
setEditingHole(null)
|
|
56
|
+
}
|
|
57
|
+
}, [setEditingHole])
|
|
58
|
+
|
|
59
|
+
const handleAddHole = useCallback(() => {
|
|
60
|
+
if (!(node && selectedId)) return
|
|
61
|
+
|
|
62
|
+
const polygon = node.polygon
|
|
63
|
+
let cx = 0
|
|
64
|
+
let cz = 0
|
|
65
|
+
for (const [x, z] of polygon) {
|
|
66
|
+
cx += x
|
|
67
|
+
cz += z
|
|
68
|
+
}
|
|
69
|
+
cx /= polygon.length
|
|
70
|
+
cz /= polygon.length
|
|
71
|
+
|
|
72
|
+
const holeSize = 0.5
|
|
73
|
+
const newHole: Array<[number, number]> = [
|
|
74
|
+
[cx - holeSize, cz - holeSize],
|
|
75
|
+
[cx + holeSize, cz - holeSize],
|
|
76
|
+
[cx + holeSize, cz + holeSize],
|
|
77
|
+
[cx - holeSize, cz + holeSize],
|
|
78
|
+
]
|
|
79
|
+
const currentHoles = node?.holes || []
|
|
80
|
+
handleUpdate({ holes: [...currentHoles, newHole] })
|
|
81
|
+
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
|
82
|
+
}, [node, selectedId, handleUpdate, setEditingHole])
|
|
83
|
+
|
|
84
|
+
const handleEditHole = useCallback(
|
|
85
|
+
(index: number) => {
|
|
86
|
+
if (!selectedId) return
|
|
87
|
+
setEditingHole({ nodeId: selectedId, holeIndex: index })
|
|
88
|
+
},
|
|
89
|
+
[selectedId, setEditingHole],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const handleDeleteHole = useCallback(
|
|
93
|
+
(index: number) => {
|
|
94
|
+
if (!selectedId) return
|
|
95
|
+
const currentHoles = node?.holes || []
|
|
96
|
+
const newHoles = currentHoles.filter((_, i) => i !== index)
|
|
97
|
+
handleUpdate({ holes: newHoles })
|
|
98
|
+
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
|
99
|
+
setEditingHole(null)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
|
106
|
+
|
|
107
|
+
const calculateArea = (polygon: Array<[number, number]>): number => {
|
|
108
|
+
if (polygon.length < 3) return 0
|
|
109
|
+
let area = 0
|
|
110
|
+
const n = polygon.length
|
|
111
|
+
for (let i = 0; i < n; i++) {
|
|
112
|
+
const j = (i + 1) % n
|
|
113
|
+
area += polygon[i]?.[0] * polygon[j]?.[1]
|
|
114
|
+
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
|
115
|
+
}
|
|
116
|
+
return Math.abs(area) / 2
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const area = calculateArea(node.polygon)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<PanelWrapper
|
|
123
|
+
icon="/icons/ceiling.png"
|
|
124
|
+
onClose={handleClose}
|
|
125
|
+
title={node.name || 'Ceiling'}
|
|
126
|
+
width={320}
|
|
127
|
+
>
|
|
128
|
+
<PanelSection title="Height">
|
|
129
|
+
<SliderControl
|
|
130
|
+
label="Height"
|
|
131
|
+
max={6}
|
|
132
|
+
min={0}
|
|
133
|
+
onChange={(v) => handleUpdate({ height: v })}
|
|
134
|
+
precision={3}
|
|
135
|
+
step={0.01}
|
|
136
|
+
unit="m"
|
|
137
|
+
value={Math.round(node.height * 1000) / 1000}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
|
|
141
|
+
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
|
|
142
|
+
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
|
|
143
|
+
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
|
|
144
|
+
</div>
|
|
145
|
+
</PanelSection>
|
|
146
|
+
|
|
147
|
+
<PanelSection title="Info">
|
|
148
|
+
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
|
149
|
+
<span>Area</span>
|
|
150
|
+
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
|
|
151
|
+
</div>
|
|
152
|
+
</PanelSection>
|
|
153
|
+
|
|
154
|
+
<PanelSection title="Holes">
|
|
155
|
+
{node.holes && node.holes.length > 0 ? (
|
|
156
|
+
<div className="flex flex-col gap-1 pb-2">
|
|
157
|
+
{node.holes.map((hole, index) => {
|
|
158
|
+
const holeArea = calculateArea(hole)
|
|
159
|
+
const isEditing =
|
|
160
|
+
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
|
164
|
+
isEditing
|
|
165
|
+
? 'border-primary/50 bg-primary/10'
|
|
166
|
+
: 'border-transparent hover:bg-accent/30'
|
|
167
|
+
}`}
|
|
168
|
+
key={index}
|
|
169
|
+
>
|
|
170
|
+
<div className="min-w-0 flex-1">
|
|
171
|
+
<p
|
|
172
|
+
className={`font-medium text-xs ${isEditing ? 'text-primary' : 'text-white'}`}
|
|
173
|
+
>
|
|
174
|
+
Hole {index + 1} {isEditing && '(Editing)'}
|
|
175
|
+
</p>
|
|
176
|
+
<p className="text-[10px] text-muted-foreground">
|
|
177
|
+
{holeArea.toFixed(2)} m² · {hole.length} pts
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex items-center gap-1">
|
|
181
|
+
{isEditing ? (
|
|
182
|
+
<ActionButton
|
|
183
|
+
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
184
|
+
label="Done"
|
|
185
|
+
onClick={() => setEditingHole(null)}
|
|
186
|
+
/>
|
|
187
|
+
) : (
|
|
188
|
+
<>
|
|
189
|
+
<button
|
|
190
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
|
|
191
|
+
onClick={() => handleEditHole(index)}
|
|
192
|
+
type="button"
|
|
193
|
+
>
|
|
194
|
+
<Edit className="h-3.5 w-3.5" />
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
|
|
198
|
+
onClick={() => handleDeleteHole(index)}
|
|
199
|
+
type="button"
|
|
200
|
+
>
|
|
201
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
202
|
+
</button>
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
) : (
|
|
211
|
+
<div className="px-2 py-3 text-center text-muted-foreground text-xs">No holes</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
<div className="px-1 pt-1 pb-1">
|
|
215
|
+
<ActionButton
|
|
216
|
+
className="w-full"
|
|
217
|
+
disabled={editingHole?.nodeId === selectedId}
|
|
218
|
+
icon={<Plus className="h-3.5 w-3.5" />}
|
|
219
|
+
label="Add Hole"
|
|
220
|
+
onClick={handleAddHole}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
</PanelSection>
|
|
224
|
+
|
|
225
|
+
<PanelSection title="Material">
|
|
226
|
+
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
227
|
+
</PanelSection>
|
|
228
|
+
</PanelWrapper>
|
|
229
|
+
)
|
|
230
|
+
}
|