@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.
Files changed (165) hide show
  1. package/package.json +62 -0
  2. package/src/components/editor/custom-camera-controls.tsx +387 -0
  3. package/src/components/editor/editor-layout-v2.tsx +220 -0
  4. package/src/components/editor/export-manager.tsx +78 -0
  5. package/src/components/editor/first-person-controls.tsx +249 -0
  6. package/src/components/editor/floating-action-menu.tsx +231 -0
  7. package/src/components/editor/floorplan-panel.tsx +9609 -0
  8. package/src/components/editor/grid.tsx +161 -0
  9. package/src/components/editor/index.tsx +928 -0
  10. package/src/components/editor/node-action-menu.tsx +66 -0
  11. package/src/components/editor/preset-thumbnail-generator.tsx +125 -0
  12. package/src/components/editor/selection-manager.tsx +897 -0
  13. package/src/components/editor/site-edge-labels.tsx +90 -0
  14. package/src/components/editor/thumbnail-generator.tsx +166 -0
  15. package/src/components/editor/wall-measurement-label.tsx +258 -0
  16. package/src/components/feedback-dialog.tsx +265 -0
  17. package/src/components/pascal-radio.tsx +280 -0
  18. package/src/components/preview-button.tsx +16 -0
  19. package/src/components/systems/ceiling/ceiling-system.tsx +77 -0
  20. package/src/components/systems/roof/roof-edit-system.tsx +69 -0
  21. package/src/components/systems/stair/stair-edit-system.tsx +69 -0
  22. package/src/components/systems/zone/zone-label-editor-system.tsx +320 -0
  23. package/src/components/systems/zone/zone-system.tsx +87 -0
  24. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +42 -0
  25. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +47 -0
  26. package/src/components/tools/ceiling/ceiling-tool.tsx +465 -0
  27. package/src/components/tools/door/door-math.ts +110 -0
  28. package/src/components/tools/door/door-tool.tsx +293 -0
  29. package/src/components/tools/door/move-door-tool.tsx +373 -0
  30. package/src/components/tools/item/item-tool.tsx +26 -0
  31. package/src/components/tools/item/move-tool.tsx +90 -0
  32. package/src/components/tools/item/placement-math.ts +85 -0
  33. package/src/components/tools/item/placement-strategies.ts +556 -0
  34. package/src/components/tools/item/placement-types.ts +117 -0
  35. package/src/components/tools/item/use-draft-node.ts +227 -0
  36. package/src/components/tools/item/use-placement-coordinator.tsx +877 -0
  37. package/src/components/tools/roof/move-roof-tool.tsx +288 -0
  38. package/src/components/tools/roof/roof-tool.tsx +318 -0
  39. package/src/components/tools/select/box-select-tool.tsx +626 -0
  40. package/src/components/tools/shared/cursor-sphere.tsx +119 -0
  41. package/src/components/tools/shared/polygon-editor.tsx +361 -0
  42. package/src/components/tools/site/site-boundary-editor.tsx +42 -0
  43. package/src/components/tools/slab/slab-boundary-editor.tsx +42 -0
  44. package/src/components/tools/slab/slab-hole-editor.tsx +47 -0
  45. package/src/components/tools/slab/slab-tool.tsx +322 -0
  46. package/src/components/tools/stair/stair-defaults.ts +7 -0
  47. package/src/components/tools/stair/stair-tool.tsx +194 -0
  48. package/src/components/tools/tool-manager.tsx +120 -0
  49. package/src/components/tools/wall/wall-drafting.ts +140 -0
  50. package/src/components/tools/wall/wall-tool.tsx +210 -0
  51. package/src/components/tools/window/move-window-tool.tsx +410 -0
  52. package/src/components/tools/window/window-math.ts +117 -0
  53. package/src/components/tools/window/window-tool.tsx +303 -0
  54. package/src/components/tools/zone/zone-boundary-editor.tsx +39 -0
  55. package/src/components/tools/zone/zone-tool.tsx +364 -0
  56. package/src/components/ui/action-menu/action-button.tsx +59 -0
  57. package/src/components/ui/action-menu/camera-actions.tsx +74 -0
  58. package/src/components/ui/action-menu/control-modes.tsx +240 -0
  59. package/src/components/ui/action-menu/furnish-tools.tsx +102 -0
  60. package/src/components/ui/action-menu/index.tsx +152 -0
  61. package/src/components/ui/action-menu/structure-tools.tsx +100 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +397 -0
  63. package/src/components/ui/command-palette/editor-commands.tsx +396 -0
  64. package/src/components/ui/command-palette/index.tsx +730 -0
  65. package/src/components/ui/controls/action-button.tsx +33 -0
  66. package/src/components/ui/controls/material-picker.tsx +194 -0
  67. package/src/components/ui/controls/metric-control.tsx +262 -0
  68. package/src/components/ui/controls/panel-section.tsx +65 -0
  69. package/src/components/ui/controls/segmented-control.tsx +45 -0
  70. package/src/components/ui/controls/slider-control.tsx +245 -0
  71. package/src/components/ui/controls/toggle-control.tsx +38 -0
  72. package/src/components/ui/floating-level-selector.tsx +355 -0
  73. package/src/components/ui/helpers/ceiling-helper.tsx +20 -0
  74. package/src/components/ui/helpers/helper-manager.tsx +33 -0
  75. package/src/components/ui/helpers/item-helper.tsx +40 -0
  76. package/src/components/ui/helpers/roof-helper.tsx +16 -0
  77. package/src/components/ui/helpers/slab-helper.tsx +20 -0
  78. package/src/components/ui/helpers/wall-helper.tsx +20 -0
  79. package/src/components/ui/item-catalog/catalog-items.tsx +1580 -0
  80. package/src/components/ui/item-catalog/item-catalog.tsx +219 -0
  81. package/src/components/ui/panels/ceiling-panel.tsx +230 -0
  82. package/src/components/ui/panels/collections/collections-popover.tsx +356 -0
  83. package/src/components/ui/panels/door-panel.tsx +600 -0
  84. package/src/components/ui/panels/item-panel.tsx +306 -0
  85. package/src/components/ui/panels/panel-manager.tsx +59 -0
  86. package/src/components/ui/panels/panel-wrapper.tsx +80 -0
  87. package/src/components/ui/panels/presets/presets-popover.tsx +511 -0
  88. package/src/components/ui/panels/reference-panel.tsx +177 -0
  89. package/src/components/ui/panels/roof-panel.tsx +262 -0
  90. package/src/components/ui/panels/roof-segment-panel.tsx +326 -0
  91. package/src/components/ui/panels/slab-panel.tsx +228 -0
  92. package/src/components/ui/panels/stair-panel.tsx +304 -0
  93. package/src/components/ui/panels/stair-segment-panel.tsx +339 -0
  94. package/src/components/ui/panels/wall-panel.tsx +123 -0
  95. package/src/components/ui/panels/window-panel.tsx +441 -0
  96. package/src/components/ui/primitives/button.tsx +69 -0
  97. package/src/components/ui/primitives/card.tsx +75 -0
  98. package/src/components/ui/primitives/color-dot.tsx +61 -0
  99. package/src/components/ui/primitives/context-menu.tsx +227 -0
  100. package/src/components/ui/primitives/dialog.tsx +129 -0
  101. package/src/components/ui/primitives/dropdown-menu.tsx +228 -0
  102. package/src/components/ui/primitives/error-boundary.tsx +52 -0
  103. package/src/components/ui/primitives/input.tsx +21 -0
  104. package/src/components/ui/primitives/number-input.tsx +187 -0
  105. package/src/components/ui/primitives/opacity-control.tsx +79 -0
  106. package/src/components/ui/primitives/popover.tsx +42 -0
  107. package/src/components/ui/primitives/separator.tsx +28 -0
  108. package/src/components/ui/primitives/sheet.tsx +130 -0
  109. package/src/components/ui/primitives/shortcut-token.tsx +64 -0
  110. package/src/components/ui/primitives/sidebar.tsx +855 -0
  111. package/src/components/ui/primitives/skeleton.tsx +13 -0
  112. package/src/components/ui/primitives/slider.tsx +58 -0
  113. package/src/components/ui/primitives/switch.tsx +29 -0
  114. package/src/components/ui/primitives/tooltip.tsx +57 -0
  115. package/src/components/ui/scene-loader.tsx +40 -0
  116. package/src/components/ui/sidebar/app-sidebar.tsx +103 -0
  117. package/src/components/ui/sidebar/icon-rail.tsx +147 -0
  118. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +100 -0
  119. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +438 -0
  120. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +188 -0
  121. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +80 -0
  122. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +126 -0
  123. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +64 -0
  124. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1543 -0
  125. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +98 -0
  126. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +117 -0
  127. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +65 -0
  128. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +214 -0
  129. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +96 -0
  130. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +216 -0
  131. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +115 -0
  132. package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +342 -0
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +271 -0
  134. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +106 -0
  135. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +64 -0
  136. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +87 -0
  137. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +167 -0
  138. package/src/components/ui/sidebar/tab-bar.tsx +39 -0
  139. package/src/components/ui/slider-demo.tsx +36 -0
  140. package/src/components/ui/slider.tsx +81 -0
  141. package/src/components/ui/viewer-toolbar.tsx +342 -0
  142. package/src/components/viewer-overlay.tsx +499 -0
  143. package/src/components/viewer-zone-system.tsx +48 -0
  144. package/src/contexts/presets-context.tsx +121 -0
  145. package/src/hooks/use-auto-save.ts +194 -0
  146. package/src/hooks/use-contextual-tools.ts +52 -0
  147. package/src/hooks/use-grid-events.ts +106 -0
  148. package/src/hooks/use-keyboard.ts +214 -0
  149. package/src/hooks/use-mobile.ts +19 -0
  150. package/src/hooks/use-reduced-motion.ts +20 -0
  151. package/src/index.tsx +33 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/level-selection.ts +31 -0
  154. package/src/lib/scene.ts +394 -0
  155. package/src/lib/sfx/index.ts +2 -0
  156. package/src/lib/sfx-bus.ts +49 -0
  157. package/src/lib/sfx-player.ts +60 -0
  158. package/src/lib/utils.ts +43 -0
  159. package/src/store/use-audio.tsx +45 -0
  160. package/src/store/use-command-registry.ts +36 -0
  161. package/src/store/use-editor.tsx +522 -0
  162. package/src/store/use-palette-view-registry.ts +45 -0
  163. package/src/store/use-upload.ts +90 -0
  164. package/src/three-types.ts +3 -0
  165. 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
+ }