@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,600 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ DoorNode,
7
+ emitter,
8
+ type MaterialSchema,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
13
+ import { useCallback } from 'react'
14
+ import { usePresetsAdapter } from '../../../contexts/presets-context'
15
+ import { sfxEmitter } from '../../../lib/sfx-bus'
16
+ import useEditor from '../../../store/use-editor'
17
+ import { ActionButton, ActionGroup } from '../controls/action-button'
18
+ import { MaterialPicker } from '../controls/material-picker'
19
+ import { MetricControl } from '../controls/metric-control'
20
+ import { PanelSection } from '../controls/panel-section'
21
+ import { SegmentedControl } from '../controls/segmented-control'
22
+ import { SliderControl } from '../controls/slider-control'
23
+ import { ToggleControl } from '../controls/toggle-control'
24
+ import { PanelWrapper } from './panel-wrapper'
25
+ import { PresetsPopover } from './presets/presets-popover'
26
+
27
+ export function DoorPanel() {
28
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
29
+ const setSelection = useViewer((s) => s.setSelection)
30
+ const nodes = useScene((s) => s.nodes)
31
+ const updateNode = useScene((s) => s.updateNode)
32
+ const deleteNode = useScene((s) => s.deleteNode)
33
+ const setMovingNode = useEditor((s) => s.setMovingNode)
34
+
35
+ const adapter = usePresetsAdapter()
36
+
37
+ const selectedId = selectedIds[0]
38
+ const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
39
+
40
+ const handleUpdate = useCallback(
41
+ (updates: Partial<DoorNode>) => {
42
+ if (!selectedId) return
43
+ updateNode(selectedId as AnyNode['id'], updates)
44
+ useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
45
+ },
46
+ [selectedId, updateNode],
47
+ )
48
+
49
+ const handleMaterialChange = useCallback(
50
+ (material: MaterialSchema) => {
51
+ handleUpdate({ material })
52
+ },
53
+ [handleUpdate],
54
+ )
55
+
56
+ const handleClose = useCallback(() => {
57
+ setSelection({ selectedIds: [] })
58
+ }, [setSelection])
59
+
60
+ const handleFlip = useCallback(() => {
61
+ if (!node) return
62
+ handleUpdate({
63
+ side: node.side === 'front' ? 'back' : 'front',
64
+ rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
65
+ })
66
+ }, [node, handleUpdate])
67
+
68
+ const handleMove = useCallback(() => {
69
+ if (!node) return
70
+ sfxEmitter.emit('sfx:item-pick')
71
+ setMovingNode(node)
72
+ setSelection({ selectedIds: [] })
73
+ }, [node, setMovingNode, setSelection])
74
+
75
+ const handleDelete = useCallback(() => {
76
+ if (!(selectedId && node)) return
77
+ sfxEmitter.emit('sfx:item-delete')
78
+ deleteNode(selectedId as AnyNode['id'])
79
+ if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
80
+ setSelection({ selectedIds: [] })
81
+ }, [selectedId, node, deleteNode, setSelection])
82
+
83
+ const handleDuplicate = useCallback(() => {
84
+ if (!node?.parentId) return
85
+ sfxEmitter.emit('sfx:item-pick')
86
+ useScene.temporal.getState().pause()
87
+ const cloned = structuredClone(node) as any
88
+ delete cloned.id
89
+ cloned.metadata = { ...cloned.metadata, isNew: true }
90
+ const duplicate = DoorNode.parse(cloned)
91
+ useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
92
+ setMovingNode(duplicate)
93
+ setSelection({ selectedIds: [] })
94
+ }, [node, setMovingNode, setSelection])
95
+
96
+ const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
97
+ const numSegs = node?.segments.length
98
+ const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
99
+ const normH = node?.segments.map((s) => s.heightRatio / totalH)
100
+ const clamped = Math.max(0.05, Math.min(0.95, newVal))
101
+ const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
102
+ const delta = clamped - normH[segIdx]!
103
+ const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
104
+ const newRatios = normH.map((v, i) => {
105
+ if (i === segIdx) return clamped
106
+ if (i === neighborIdx) return neighborVal
107
+ return v
108
+ })
109
+ const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
110
+ handleUpdate({ segments: updated })
111
+ }
112
+
113
+ const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
114
+ const seg = node?.segments[segIdx]
115
+ if (!seg) return
116
+ const normRatios = (() => {
117
+ const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
118
+ return seg.columnRatios.map((r) => r / sum)
119
+ })()
120
+ const numCols = normRatios.length
121
+ const clamped = Math.max(0.05, Math.min(0.95, newVal))
122
+ const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
123
+ const delta = clamped - normRatios[colIdx]!
124
+ const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
125
+ const newRatios = normRatios.map((v, i) => {
126
+ if (i === colIdx) return clamped
127
+ if (i === neighborIdx) return neighborVal
128
+ return v
129
+ })
130
+ const updated = node?.segments.map((s, idx) =>
131
+ idx === segIdx ? { ...s, columnRatios: newRatios } : s,
132
+ )
133
+ handleUpdate({ segments: updated })
134
+ }
135
+
136
+ const getDoorPresetData = useCallback(() => {
137
+ if (!node) return null
138
+ return {
139
+ width: node.width,
140
+ height: node.height,
141
+ frameThickness: node.frameThickness,
142
+ frameDepth: node.frameDepth,
143
+ contentPadding: node.contentPadding,
144
+ hingesSide: node.hingesSide,
145
+ swingDirection: node.swingDirection,
146
+ threshold: node.threshold,
147
+ thresholdHeight: node.thresholdHeight,
148
+ handle: node.handle,
149
+ handleHeight: node.handleHeight,
150
+ handleSide: node.handleSide,
151
+ doorCloser: node.doorCloser,
152
+ panicBar: node.panicBar,
153
+ panicBarHeight: node.panicBarHeight,
154
+ segments: node.segments,
155
+ }
156
+ }, [node])
157
+
158
+ const handleSavePreset = useCallback(
159
+ async (name: string) => {
160
+ const data = getDoorPresetData()
161
+ if (!(data && selectedId)) return
162
+ const presetId = await adapter.savePreset('door', name, data)
163
+ if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
164
+ },
165
+ [getDoorPresetData, selectedId, adapter],
166
+ )
167
+
168
+ const handleOverwritePreset = useCallback(
169
+ async (id: string) => {
170
+ const data = getDoorPresetData()
171
+ if (!(data && selectedId)) return
172
+ await adapter.overwritePreset('door', id, data)
173
+ emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
174
+ },
175
+ [getDoorPresetData, selectedId, adapter],
176
+ )
177
+
178
+ const handleApplyPreset = useCallback(
179
+ (data: Record<string, unknown>) => {
180
+ handleUpdate(data as Partial<DoorNode>)
181
+ },
182
+ [handleUpdate],
183
+ )
184
+
185
+ if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
186
+
187
+ const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
188
+ const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
189
+
190
+ return (
191
+ <PanelWrapper
192
+ icon="/icons/door.png"
193
+ onClose={handleClose}
194
+ title={node.name || 'Door'}
195
+ width={320}
196
+ >
197
+ {/* Presets strip */}
198
+ <div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
199
+ <PresetsPopover
200
+ isAuthenticated={adapter.isAuthenticated}
201
+ onApply={handleApplyPreset}
202
+ onDelete={(id) => adapter.deletePreset(id)}
203
+ onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
204
+ onOverwrite={handleOverwritePreset}
205
+ onRename={(id, name) => adapter.renamePreset(id, name)}
206
+ onSave={handleSavePreset}
207
+ onToggleCommunity={adapter.togglePresetCommunity}
208
+ tabs={adapter.tabs}
209
+ type="door"
210
+ >
211
+ <button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
212
+ <BookMarked className="h-3.5 w-3.5 shrink-0" />
213
+ <span>Presets</span>
214
+ </button>
215
+ </PresetsPopover>
216
+ </div>
217
+
218
+ <PanelSection title="Position">
219
+ <SliderControl
220
+ label={
221
+ <>
222
+ X<sub className="ml-[1px] text-[11px] opacity-70">wall</sub>
223
+ </>
224
+ }
225
+ max={10}
226
+ min={-10}
227
+ onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
228
+ precision={2}
229
+ step={0.1}
230
+ unit="m"
231
+ value={Math.round(node.position[0] * 100) / 100}
232
+ />
233
+ <div className="px-1 pt-2 pb-1">
234
+ <ActionButton
235
+ className="w-full"
236
+ icon={<FlipHorizontal2 className="h-4 w-4" />}
237
+ label="Flip Side"
238
+ onClick={handleFlip}
239
+ />
240
+ </div>
241
+ </PanelSection>
242
+
243
+ <PanelSection title="Dimensions">
244
+ <SliderControl
245
+ label="Width"
246
+ max={3}
247
+ min={0.5}
248
+ onChange={(v) => handleUpdate({ width: v })}
249
+ precision={2}
250
+ step={0.05}
251
+ unit="m"
252
+ value={Math.round(node.width * 100) / 100}
253
+ />
254
+ <SliderControl
255
+ label="Height"
256
+ max={4}
257
+ min={1.0}
258
+ onChange={(v) =>
259
+ handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
260
+ }
261
+ precision={2}
262
+ step={0.05}
263
+ unit="m"
264
+ value={Math.round(node.height * 100) / 100}
265
+ />
266
+ </PanelSection>
267
+
268
+ <PanelSection title="Frame">
269
+ <SliderControl
270
+ label="Thickness"
271
+ max={0.2}
272
+ min={0.01}
273
+ onChange={(v) => handleUpdate({ frameThickness: v })}
274
+ precision={3}
275
+ step={0.01}
276
+ unit="m"
277
+ value={Math.round(node.frameThickness * 1000) / 1000}
278
+ />
279
+ <SliderControl
280
+ label="Depth"
281
+ max={0.3}
282
+ min={0.01}
283
+ onChange={(v) => handleUpdate({ frameDepth: v })}
284
+ precision={3}
285
+ step={0.01}
286
+ unit="m"
287
+ value={Math.round(node.frameDepth * 1000) / 1000}
288
+ />
289
+ </PanelSection>
290
+
291
+ <PanelSection title="Content Padding">
292
+ <SliderControl
293
+ label="Horizontal"
294
+ max={0.2}
295
+ min={0}
296
+ onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
297
+ precision={3}
298
+ step={0.005}
299
+ unit="m"
300
+ value={Math.round(node.contentPadding[0] * 1000) / 1000}
301
+ />
302
+ <SliderControl
303
+ label="Vertical"
304
+ max={0.2}
305
+ min={0}
306
+ onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
307
+ precision={3}
308
+ step={0.005}
309
+ unit="m"
310
+ value={Math.round(node.contentPadding[1] * 1000) / 1000}
311
+ />
312
+ </PanelSection>
313
+
314
+ <PanelSection title="Swing">
315
+ <div className="flex flex-col gap-2 px-1 pb-1">
316
+ <div className="space-y-1">
317
+ <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
318
+ Hinges Side
319
+ </span>
320
+ <SegmentedControl
321
+ onChange={(v) => handleUpdate({ hingesSide: v })}
322
+ options={[
323
+ { label: 'Left', value: 'left' },
324
+ { label: 'Right', value: 'right' },
325
+ ]}
326
+ value={node.hingesSide}
327
+ />
328
+ </div>
329
+ <div className="space-y-1">
330
+ <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
331
+ Direction
332
+ </span>
333
+ <SegmentedControl
334
+ onChange={(v) => handleUpdate({ swingDirection: v })}
335
+ options={[
336
+ { label: 'Inward', value: 'inward' },
337
+ { label: 'Outward', value: 'outward' },
338
+ ]}
339
+ value={node.swingDirection}
340
+ />
341
+ </div>
342
+ </div>
343
+ </PanelSection>
344
+
345
+ <PanelSection title="Threshold">
346
+ <ToggleControl
347
+ checked={node.threshold}
348
+ label="Enable Threshold"
349
+ onChange={(checked) => handleUpdate({ threshold: checked })}
350
+ />
351
+ {node.threshold && (
352
+ <div className="mt-1 flex flex-col gap-1">
353
+ <SliderControl
354
+ label="Height"
355
+ max={0.1}
356
+ min={0.005}
357
+ onChange={(v) => handleUpdate({ thresholdHeight: v })}
358
+ precision={3}
359
+ step={0.005}
360
+ unit="m"
361
+ value={Math.round(node.thresholdHeight * 1000) / 1000}
362
+ />
363
+ </div>
364
+ )}
365
+ </PanelSection>
366
+
367
+ <PanelSection title="Handle">
368
+ <ToggleControl
369
+ checked={node.handle}
370
+ label="Enable Handle"
371
+ onChange={(checked) => handleUpdate({ handle: checked })}
372
+ />
373
+ {node.handle && (
374
+ <div className="mt-1 flex flex-col gap-1">
375
+ <SliderControl
376
+ label="Height"
377
+ max={node.height - 0.1}
378
+ min={0.5}
379
+ onChange={(v) => handleUpdate({ handleHeight: v })}
380
+ precision={2}
381
+ step={0.05}
382
+ unit="m"
383
+ value={Math.round(node.handleHeight * 100) / 100}
384
+ />
385
+ <div className="space-y-1">
386
+ <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
387
+ Handle Side
388
+ </span>
389
+ <SegmentedControl
390
+ onChange={(v) => handleUpdate({ handleSide: v })}
391
+ options={[
392
+ { label: 'Left', value: 'left' },
393
+ { label: 'Right', value: 'right' },
394
+ ]}
395
+ value={node.handleSide}
396
+ />
397
+ </div>
398
+ </div>
399
+ )}
400
+ </PanelSection>
401
+
402
+ <PanelSection title="Hardware">
403
+ <ToggleControl
404
+ checked={node.doorCloser}
405
+ label="Door Closer"
406
+ onChange={(checked) => handleUpdate({ doorCloser: checked })}
407
+ />
408
+ <ToggleControl
409
+ checked={node.panicBar}
410
+ label="Panic Bar"
411
+ onChange={(checked) => handleUpdate({ panicBar: checked })}
412
+ />
413
+ {node.panicBar && (
414
+ <div className="mt-1 flex flex-col gap-1">
415
+ <SliderControl
416
+ label="Bar Height"
417
+ max={node.height - 0.1}
418
+ min={0.5}
419
+ onChange={(v) => handleUpdate({ panicBarHeight: v })}
420
+ precision={2}
421
+ step={0.05}
422
+ unit="m"
423
+ value={Math.round(node.panicBarHeight * 100) / 100}
424
+ />
425
+ </div>
426
+ )}
427
+ </PanelSection>
428
+
429
+ <PanelSection title="Segments">
430
+ {node.segments.map((seg, i) => {
431
+ const numCols = seg.columnRatios.length
432
+ const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
433
+ const normCols = seg.columnRatios.map((r) => r / colSum)
434
+ return (
435
+ <div className="mb-2 flex flex-col gap-1" key={i}>
436
+ <div className="flex items-center justify-between pb-1">
437
+ <span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
438
+ </div>
439
+
440
+ <SegmentedControl
441
+ onChange={(t) => {
442
+ const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))
443
+ handleUpdate({ segments: updated })
444
+ }}
445
+ options={[
446
+ { label: 'Panel', value: 'panel' },
447
+ { label: 'Glass', value: 'glass' },
448
+ { label: 'Empty', value: 'empty' },
449
+ ]}
450
+ value={seg.type}
451
+ />
452
+
453
+ <SliderControl
454
+ label="Height"
455
+ max={95}
456
+ min={5}
457
+ onChange={(v) => setSegmentHeightRatio(i, v / 100)}
458
+ precision={1}
459
+ step={1}
460
+ unit="%"
461
+ value={Math.round(normHeights[i]! * 100 * 10) / 10}
462
+ />
463
+
464
+ <SliderControl
465
+ label="Columns"
466
+ max={8}
467
+ min={1}
468
+ onChange={(v) => {
469
+ const n = Math.max(1, Math.min(8, Math.round(v)))
470
+ const updated = node.segments.map((s, idx) =>
471
+ idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
472
+ )
473
+ handleUpdate({ segments: updated })
474
+ }}
475
+ precision={0}
476
+ step={1}
477
+ value={numCols}
478
+ />
479
+
480
+ {numCols > 1 && (
481
+ <div className="mt-1 border-border/50 border-t pt-1">
482
+ {normCols.map((ratio, ci) => (
483
+ <SliderControl
484
+ key={`c-${ci}`}
485
+ label={`C${ci + 1}`}
486
+ max={95}
487
+ min={5}
488
+ onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
489
+ precision={1}
490
+ step={1}
491
+ unit="%"
492
+ value={Math.round(ratio * 100 * 10) / 10}
493
+ />
494
+ ))}
495
+ <SliderControl
496
+ label="Divider"
497
+ max={0.1}
498
+ min={0.005}
499
+ onChange={(v) => {
500
+ const updated = node.segments.map((s, idx) =>
501
+ idx === i ? { ...s, dividerThickness: v } : s,
502
+ )
503
+ handleUpdate({ segments: updated })
504
+ }}
505
+ precision={3}
506
+ step={0.005}
507
+ unit="m"
508
+ value={Math.round(seg.dividerThickness * 1000) / 1000}
509
+ />
510
+ </div>
511
+ )}
512
+
513
+ {seg.type === 'panel' && (
514
+ <div className="mt-1 border-border/50 border-t pt-1">
515
+ <SliderControl
516
+ label="Inset"
517
+ max={0.1}
518
+ min={0}
519
+ onChange={(v) => {
520
+ const updated = node.segments.map((s, idx) =>
521
+ idx === i ? { ...s, panelInset: v } : s,
522
+ )
523
+ handleUpdate({ segments: updated })
524
+ }}
525
+ precision={3}
526
+ step={0.005}
527
+ unit="m"
528
+ value={Math.round(seg.panelInset * 1000) / 1000}
529
+ />
530
+ <SliderControl
531
+ label="Depth"
532
+ max={0.1}
533
+ min={0}
534
+ onChange={(v) => {
535
+ const updated = node.segments.map((s, idx) =>
536
+ idx === i ? { ...s, panelDepth: v } : s,
537
+ )
538
+ handleUpdate({ segments: updated })
539
+ }}
540
+ precision={3}
541
+ step={0.005}
542
+ unit="m"
543
+ value={Math.round(seg.panelDepth * 1000) / 1000}
544
+ />
545
+ </div>
546
+ )}
547
+ </div>
548
+ )
549
+ })}
550
+
551
+ <div className="flex gap-1.5 px-1 pt-1">
552
+ <ActionButton
553
+ label="+ Add Segment"
554
+ onClick={() => {
555
+ const updated = [
556
+ ...node.segments,
557
+ {
558
+ type: 'panel' as const,
559
+ heightRatio: 1,
560
+ columnRatios: [1],
561
+ dividerThickness: 0.03,
562
+ panelDepth: 0.01,
563
+ panelInset: 0.04,
564
+ },
565
+ ]
566
+ handleUpdate({ segments: updated })
567
+ }}
568
+ />
569
+ {node.segments.length > 1 && (
570
+ <ActionButton
571
+ className="text-white/60 hover:text-white"
572
+ label="- Remove"
573
+ onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
574
+ />
575
+ )}
576
+ </div>
577
+ </PanelSection>
578
+
579
+ <PanelSection title="Actions">
580
+ <ActionGroup>
581
+ <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
582
+ <ActionButton
583
+ icon={<Copy className="h-3.5 w-3.5" />}
584
+ label="Duplicate"
585
+ onClick={handleDuplicate}
586
+ />
587
+ <ActionButton
588
+ className="hover:bg-red-500/20"
589
+ icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
590
+ label="Delete"
591
+ onClick={handleDelete}
592
+ />
593
+ </ActionGroup>
594
+ </PanelSection>
595
+ <PanelSection title="Material">
596
+ <MaterialPicker onChange={handleMaterialChange} value={node.material} />
597
+ </PanelSection>
598
+ </PanelWrapper>
599
+ )
600
+ }