@pascal-app/editor 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +9 -5
  2. package/src/components/editor/bottom-sheet.tsx +149 -0
  3. package/src/components/editor/custom-camera-controls.tsx +75 -7
  4. package/src/components/editor/editor-layout-mobile.tsx +264 -0
  5. package/src/components/editor/editor-layout-v2.tsx +20 -0
  6. package/src/components/editor/first-person/build-collider-world.ts +365 -0
  7. package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
  8. package/src/components/editor/first-person-controls.tsx +496 -143
  9. package/src/components/editor/floating-action-menu.tsx +32 -55
  10. package/src/components/editor/floorplan-background-selection.ts +113 -0
  11. package/src/components/editor/floorplan-panel.tsx +9855 -3298
  12. package/src/components/editor/index.tsx +269 -21
  13. package/src/components/editor/selection-manager.tsx +575 -13
  14. package/src/components/editor/thumbnail-generator.tsx +38 -7
  15. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  16. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  17. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  18. package/src/components/editor/wall-measurement-label.tsx +267 -36
  19. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  20. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  21. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  22. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  23. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  24. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  25. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  26. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  27. package/src/components/editor-2d/svg-paths.ts +119 -0
  28. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  29. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  30. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  31. package/src/components/tools/column/column-tool.tsx +97 -0
  32. package/src/components/tools/column/move-column-tool.tsx +105 -0
  33. package/src/components/tools/door/door-tool.tsx +7 -0
  34. package/src/components/tools/door/move-door-tool.tsx +28 -8
  35. package/src/components/tools/fence/fence-drafting.ts +10 -3
  36. package/src/components/tools/fence/fence-tool.tsx +159 -3
  37. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
  38. package/src/components/tools/fence/move-fence-tool.tsx +101 -34
  39. package/src/components/tools/item/move-tool.tsx +10 -1
  40. package/src/components/tools/item/placement-math.ts +30 -1
  41. package/src/components/tools/item/placement-strategies.ts +109 -31
  42. package/src/components/tools/item/placement-types.ts +7 -0
  43. package/src/components/tools/item/use-draft-node.ts +2 -0
  44. package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
  45. package/src/components/tools/roof/move-roof-tool.tsx +22 -15
  46. package/src/components/tools/shared/polygon-editor.tsx +153 -28
  47. package/src/components/tools/shared/segment-angle.ts +156 -0
  48. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  49. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  50. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  51. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  52. package/src/components/tools/tool-manager.tsx +18 -3
  53. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
  54. package/src/components/tools/wall/wall-drafting.ts +18 -9
  55. package/src/components/tools/wall/wall-tool.tsx +134 -2
  56. package/src/components/tools/window/move-window-tool.tsx +18 -0
  57. package/src/components/tools/window/window-tool.tsx +5 -0
  58. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  59. package/src/components/ui/action-menu/control-modes.tsx +28 -1
  60. package/src/components/ui/action-menu/index.tsx +91 -1
  61. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  62. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  63. package/src/components/ui/command-palette/editor-commands.tsx +18 -1
  64. package/src/components/ui/controls/material-picker.tsx +152 -165
  65. package/src/components/ui/controls/slider-control.tsx +66 -18
  66. package/src/components/ui/floating-level-selector.tsx +286 -55
  67. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  68. package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
  69. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  70. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  71. package/src/components/ui/panels/ceiling-panel.tsx +1 -25
  72. package/src/components/ui/panels/column-panel.tsx +715 -0
  73. package/src/components/ui/panels/door-panel.tsx +981 -289
  74. package/src/components/ui/panels/fence-panel.tsx +3 -45
  75. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  76. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  77. package/src/components/ui/panels/node-display.ts +39 -0
  78. package/src/components/ui/panels/paint-panel.tsx +138 -0
  79. package/src/components/ui/panels/panel-manager.tsx +210 -1
  80. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  81. package/src/components/ui/panels/reference-panel.tsx +238 -5
  82. package/src/components/ui/panels/roof-panel.tsx +4 -105
  83. package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
  84. package/src/components/ui/panels/slab-panel.tsx +4 -30
  85. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  86. package/src/components/ui/panels/stair-panel.tsx +11 -117
  87. package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
  88. package/src/components/ui/panels/wall-panel.tsx +1 -95
  89. package/src/components/ui/panels/window-panel.tsx +660 -139
  90. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  91. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  92. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
  93. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  94. package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
  95. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
  96. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  97. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
  98. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
  99. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  100. package/src/components/ui/viewer-toolbar.tsx +42 -1
  101. package/src/hooks/use-auto-frame.ts +45 -0
  102. package/src/hooks/use-keyboard.ts +64 -7
  103. package/src/hooks/use-mobile.ts +12 -12
  104. package/src/lib/door-interaction.ts +88 -0
  105. package/src/lib/floorplan/geometry.ts +263 -0
  106. package/src/lib/floorplan/index.ts +38 -0
  107. package/src/lib/floorplan/items.ts +179 -0
  108. package/src/lib/floorplan/selection-tool.ts +231 -0
  109. package/src/lib/floorplan/stairs.ts +478 -0
  110. package/src/lib/floorplan/types.ts +57 -0
  111. package/src/lib/floorplan/walls.ts +23 -0
  112. package/src/lib/guide-events.ts +10 -0
  113. package/src/lib/level-duplication.test.ts +72 -0
  114. package/src/lib/level-duplication.ts +153 -0
  115. package/src/lib/local-guide-image.ts +42 -0
  116. package/src/lib/material-paint.ts +284 -0
  117. package/src/lib/roof-duplication.ts +214 -0
  118. package/src/lib/scene-bounds.test.ts +183 -0
  119. package/src/lib/scene-bounds.ts +169 -0
  120. package/src/lib/stair-duplication.ts +126 -0
  121. package/src/lib/window-interaction.ts +86 -0
  122. package/src/store/use-editor.tsx +164 -8
@@ -0,0 +1,715 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ COLUMN_PRESETS,
6
+ type ColumnNode,
7
+ type ColumnPresetId,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { Move, Trash2 } from 'lucide-react'
12
+ import { useCallback } from 'react'
13
+ import { sfxEmitter } from '../../../lib/sfx-bus'
14
+ import useEditor from '../../../store/use-editor'
15
+ import { ActionButton, ActionGroup } from '../controls/action-button'
16
+ import { PanelSection } from '../controls/panel-section'
17
+ import { SliderControl } from '../controls/slider-control'
18
+ import { PanelWrapper } from './panel-wrapper'
19
+
20
+ const SELECT_CLASS =
21
+ 'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
22
+
23
+ const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
24
+ value: value as ColumnPresetId,
25
+ label: preset.label,
26
+ }))
27
+
28
+ const COLUMN_PROPORTION_PRESETS = {
29
+ slender: {
30
+ label: 'Slender',
31
+ height: 3.6,
32
+ width: 0.34,
33
+ baseHeight: 0.18,
34
+ capitalHeight: 0.16,
35
+ baseWidthScale: 1.18,
36
+ capitalWidthScale: 1.16,
37
+ edgeSoftness: 0.02,
38
+ },
39
+ standard: {
40
+ label: 'Standard',
41
+ height: 2.9,
42
+ width: 0.44,
43
+ baseHeight: 0.22,
44
+ capitalHeight: 0.2,
45
+ baseWidthScale: 1.24,
46
+ capitalWidthScale: 1.22,
47
+ edgeSoftness: 0.025,
48
+ },
49
+ heavy: {
50
+ label: 'Heavy',
51
+ height: 3,
52
+ width: 0.58,
53
+ baseHeight: 0.28,
54
+ capitalHeight: 0.26,
55
+ baseWidthScale: 1.34,
56
+ capitalWidthScale: 1.3,
57
+ edgeSoftness: 0.035,
58
+ },
59
+ stout: {
60
+ label: 'Short / Stout',
61
+ height: 2.2,
62
+ width: 0.62,
63
+ baseHeight: 0.3,
64
+ capitalHeight: 0.28,
65
+ baseWidthScale: 1.38,
66
+ capitalWidthScale: 1.34,
67
+ edgeSoftness: 0.04,
68
+ },
69
+ } as const
70
+
71
+ type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
72
+
73
+ const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
74
+ value: value as ColumnProportionPresetId,
75
+ label: preset.label,
76
+ }))
77
+
78
+ function clamp(value: number, min: number, max: number) {
79
+ return Math.min(max, Math.max(min, value))
80
+ }
81
+
82
+ function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
83
+ const { label, ...preset } = COLUMN_PRESETS[presetId]
84
+ return {
85
+ name: label,
86
+ ...preset,
87
+ }
88
+ }
89
+
90
+ function proportionUpdates(
91
+ node: ColumnNode,
92
+ presetId: ColumnProportionPresetId,
93
+ ): Partial<ColumnNode> {
94
+ const preset = COLUMN_PROPORTION_PRESETS[presetId]
95
+ const depth =
96
+ node.crossSection === 'rectangular'
97
+ ? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
98
+ : preset.width
99
+ const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
100
+
101
+ return {
102
+ height: preset.height,
103
+ width: preset.width,
104
+ depth,
105
+ radius: preset.width / 2,
106
+ baseHeight: preset.baseHeight,
107
+ capitalHeight: preset.capitalHeight,
108
+ baseWidthScale: preset.baseWidthScale,
109
+ baseDepthScale: preset.baseWidthScale,
110
+ capitalWidthScale: preset.capitalWidthScale,
111
+ capitalDepthScale: preset.capitalWidthScale,
112
+ edgeSoftness: preset.edgeSoftness,
113
+ shaftCornerRadius,
114
+ }
115
+ }
116
+
117
+ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
118
+ if (shaftProfile === 'tapered') {
119
+ return {
120
+ shaftProfile,
121
+ shaftTaper: 0.14,
122
+ shaftBulge: 0,
123
+ shaftStartScale: 0.82,
124
+ shaftEndScale: 0.72,
125
+ shaftSegmentCount: 32,
126
+ }
127
+ }
128
+
129
+ if (shaftProfile === 'bulged') {
130
+ return {
131
+ shaftProfile,
132
+ shaftTaper: 0,
133
+ shaftBulge: 0.12,
134
+ shaftStartScale: 0.68,
135
+ shaftEndScale: 0.68,
136
+ shaftSegmentCount: 32,
137
+ }
138
+ }
139
+
140
+ if (shaftProfile === 'hourglass') {
141
+ return {
142
+ shaftProfile,
143
+ shaftTaper: 0,
144
+ shaftBulge: 0.12,
145
+ shaftStartScale: 0.84,
146
+ shaftEndScale: 0.84,
147
+ shaftSegmentCount: 32,
148
+ }
149
+ }
150
+
151
+ return {
152
+ shaftProfile,
153
+ shaftTaper: 0,
154
+ shaftBulge: 0,
155
+ shaftStartScale: 0.72,
156
+ shaftEndScale: 0.72,
157
+ shaftSegmentCount: 1,
158
+ shaftTwistStep: 0,
159
+ }
160
+ }
161
+
162
+ export function ColumnPanel() {
163
+ const selectedId = useViewer((s) => s.selection.selectedIds[0])
164
+ const selectedCount = useViewer((s) => s.selection.selectedIds.length)
165
+ const setSelection = useViewer((s) => s.setSelection)
166
+ const updateNode = useScene((s) => s.updateNode)
167
+ const deleteNode = useScene((s) => s.deleteNode)
168
+ const setMovingNode = useEditor((s) => s.setMovingNode)
169
+
170
+ const node = useScene((s) =>
171
+ selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
172
+ )
173
+
174
+ const handleUpdate = useCallback(
175
+ (updates: Partial<ColumnNode>) => {
176
+ if (!selectedId) return
177
+ updateNode(selectedId as AnyNode['id'], updates)
178
+ },
179
+ [selectedId, updateNode],
180
+ )
181
+
182
+ const handleClose = useCallback(() => {
183
+ setSelection({ selectedIds: [] })
184
+ }, [setSelection])
185
+
186
+ const handleDelete = useCallback(() => {
187
+ if (!selectedId) return
188
+ sfxEmitter.emit('sfx:structure-delete')
189
+ deleteNode(selectedId as AnyNode['id'])
190
+ setSelection({ selectedIds: [] })
191
+ }, [deleteNode, selectedId, setSelection])
192
+
193
+ const handleMove = useCallback(() => {
194
+ if (!node) return
195
+ sfxEmitter.emit('sfx:item-pick')
196
+ setMovingNode(node)
197
+ setSelection({ selectedIds: [] })
198
+ }, [node, setMovingNode, setSelection])
199
+
200
+ if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
201
+ const shaftProfile = node.shaftProfile ?? 'straight'
202
+
203
+ return (
204
+ <PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
205
+ <PanelSection title="Preset">
206
+ <select
207
+ className={SELECT_CLASS}
208
+ onChange={(event) => {
209
+ if (!event.target.value) return
210
+ handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
211
+ }}
212
+ value=""
213
+ >
214
+ <option value="">Apply preset...</option>
215
+ {COLUMN_PRESET_OPTIONS.map((option) => (
216
+ <option key={option.value} value={option.value}>
217
+ {option.label}
218
+ </option>
219
+ ))}
220
+ </select>
221
+ </PanelSection>
222
+
223
+ <PanelSection title="Shape">
224
+ <select
225
+ className={SELECT_CLASS}
226
+ onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
227
+ value={node.crossSection}
228
+ >
229
+ <option value="round">Round</option>
230
+ <option value="square">Square</option>
231
+ <option value="rectangular">Rectangular</option>
232
+ </select>
233
+ <SliderControl
234
+ label="Edge Softness"
235
+ max={0.12}
236
+ min={0}
237
+ onChange={(value) => handleUpdate({ edgeSoftness: value })}
238
+ precision={3}
239
+ step={0.005}
240
+ unit="m"
241
+ value={node.edgeSoftness ?? 0.025}
242
+ />
243
+ {(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
244
+ <SliderControl
245
+ label="Shaft Corner Radius"
246
+ max={0.3}
247
+ min={0}
248
+ onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
249
+ precision={3}
250
+ step={0.005}
251
+ unit="m"
252
+ value={node.shaftCornerRadius ?? 0.035}
253
+ />
254
+ )}
255
+ </PanelSection>
256
+
257
+ <PanelSection title="Dimensions">
258
+ <select
259
+ className={SELECT_CLASS}
260
+ onChange={(event) => {
261
+ if (!event.target.value) return
262
+ handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
263
+ }}
264
+ value=""
265
+ >
266
+ <option value="">Apply proportion...</option>
267
+ {COLUMN_PROPORTION_OPTIONS.map((option) => (
268
+ <option key={option.value} value={option.value}>
269
+ {option.label}
270
+ </option>
271
+ ))}
272
+ </select>
273
+ <SliderControl
274
+ label="Height"
275
+ max={6}
276
+ min={0.8}
277
+ onChange={(value) => handleUpdate({ height: value })}
278
+ precision={2}
279
+ step={0.05}
280
+ unit="m"
281
+ value={node.height}
282
+ />
283
+ <SliderControl
284
+ label="Width"
285
+ max={1.6}
286
+ min={0.12}
287
+ onChange={(value) =>
288
+ handleUpdate({
289
+ width: value,
290
+ radius: value / 2,
291
+ ...(node.crossSection === 'rectangular' ? {} : { depth: value }),
292
+ })
293
+ }
294
+ precision={2}
295
+ step={0.02}
296
+ unit="m"
297
+ value={node.width}
298
+ />
299
+ {node.crossSection === 'rectangular' && (
300
+ <SliderControl
301
+ label="Depth"
302
+ max={1.6}
303
+ min={0.12}
304
+ onChange={(value) => handleUpdate({ depth: value })}
305
+ precision={2}
306
+ step={0.02}
307
+ unit="m"
308
+ value={node.depth}
309
+ />
310
+ )}
311
+ </PanelSection>
312
+
313
+ <PanelSection title="Shaft">
314
+ <select
315
+ className={SELECT_CLASS}
316
+ onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
317
+ value={shaftProfile}
318
+ >
319
+ <option value="straight">Straight</option>
320
+ <option value="tapered">Tapered</option>
321
+ <option value="bulged">Bulged</option>
322
+ <option value="hourglass">Hourglass</option>
323
+ </select>
324
+ {shaftProfile === 'straight' && (
325
+ <SliderControl
326
+ label="Shaft Width"
327
+ max={1.2}
328
+ min={0.3}
329
+ onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
330
+ precision={2}
331
+ step={0.02}
332
+ value={node.shaftStartScale ?? 0.72}
333
+ />
334
+ )}
335
+ {shaftProfile === 'tapered' && (
336
+ <>
337
+ <SliderControl
338
+ label="Bottom Width"
339
+ max={1.2}
340
+ min={0.3}
341
+ onChange={(value) => handleUpdate({ shaftStartScale: value })}
342
+ precision={2}
343
+ step={0.02}
344
+ value={node.shaftStartScale ?? 0.82}
345
+ />
346
+ <SliderControl
347
+ label="Top Width"
348
+ max={1.2}
349
+ min={0.3}
350
+ onChange={(value) => handleUpdate({ shaftEndScale: value })}
351
+ precision={2}
352
+ step={0.02}
353
+ value={node.shaftEndScale ?? 0.72}
354
+ />
355
+ <SliderControl
356
+ label="Taper"
357
+ max={0.45}
358
+ min={0}
359
+ onChange={(value) => handleUpdate({ shaftTaper: value })}
360
+ precision={2}
361
+ step={0.01}
362
+ value={node.shaftTaper ?? 0.14}
363
+ />
364
+ </>
365
+ )}
366
+ {shaftProfile === 'bulged' && (
367
+ <>
368
+ <SliderControl
369
+ label="End Width"
370
+ max={1.2}
371
+ min={0.3}
372
+ onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
373
+ precision={2}
374
+ step={0.02}
375
+ value={node.shaftStartScale ?? 0.68}
376
+ />
377
+ <SliderControl
378
+ label="Bulge"
379
+ max={0.35}
380
+ min={0}
381
+ onChange={(value) => handleUpdate({ shaftBulge: value })}
382
+ precision={2}
383
+ step={0.01}
384
+ value={node.shaftBulge ?? 0.12}
385
+ />
386
+ </>
387
+ )}
388
+ {shaftProfile === 'hourglass' && (
389
+ <>
390
+ <SliderControl
391
+ label="End Width"
392
+ max={1.2}
393
+ min={0.3}
394
+ onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
395
+ precision={2}
396
+ step={0.02}
397
+ value={node.shaftStartScale ?? 0.84}
398
+ />
399
+ <SliderControl
400
+ label="Waist"
401
+ max={0.35}
402
+ min={0}
403
+ onChange={(value) => handleUpdate({ shaftBulge: value })}
404
+ precision={2}
405
+ step={0.01}
406
+ value={node.shaftBulge ?? 0.12}
407
+ />
408
+ </>
409
+ )}
410
+ <SliderControl
411
+ label="Segment Twist"
412
+ max={90}
413
+ min={-90}
414
+ onChange={(value) =>
415
+ handleUpdate({
416
+ shaftTwistStep: value,
417
+ ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
418
+ ? { shaftSegmentCount: 12 }
419
+ : {}),
420
+ })
421
+ }
422
+ precision={0}
423
+ step={5}
424
+ unit="°"
425
+ value={node.shaftTwistStep ?? 0}
426
+ />
427
+ {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
428
+ <SliderControl
429
+ label="Twist Segments"
430
+ max={48}
431
+ min={4}
432
+ onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
433
+ precision={0}
434
+ step={1}
435
+ value={node.shaftSegmentCount ?? 12}
436
+ />
437
+ )}
438
+ <SliderControl
439
+ label="Ring Pairs"
440
+ max={4}
441
+ min={0}
442
+ onChange={(value) =>
443
+ handleUpdate({
444
+ ringCount: Math.round(value) * 2,
445
+ ringPlacement: 'ends',
446
+ ringSpread: node.ringSpread ?? 0.16,
447
+ ringThickness: node.ringThickness ?? 0.055,
448
+ })
449
+ }
450
+ precision={0}
451
+ step={1}
452
+ value={Math.ceil((node.ringCount ?? 0) / 2)}
453
+ />
454
+ {(node.ringCount ?? 0) > 0 && (
455
+ <SliderControl
456
+ label="Ring Thickness"
457
+ max={0.14}
458
+ min={0.01}
459
+ onChange={(value) => handleUpdate({ ringThickness: value })}
460
+ precision={3}
461
+ step={0.005}
462
+ unit="m"
463
+ value={node.ringThickness ?? 0.055}
464
+ />
465
+ )}
466
+ {(node.ringCount ?? 0) > 0 && (
467
+ <SliderControl
468
+ label="Ring Spread"
469
+ max={0.45}
470
+ min={0.04}
471
+ onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
472
+ precision={2}
473
+ step={0.01}
474
+ value={node.ringSpread ?? 0.16}
475
+ />
476
+ )}
477
+ </PanelSection>
478
+
479
+ <PanelSection title="Ends">
480
+ <select
481
+ className={SELECT_CLASS}
482
+ onChange={(event) => {
483
+ const capitalStyle = event.target.value as ColumnNode['capitalStyle']
484
+ handleUpdate({
485
+ capitalStyle,
486
+ ...(capitalStyle === 'none'
487
+ ? {}
488
+ : {
489
+ capitalHeight: Math.max(node.capitalHeight, 0.12),
490
+ capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
491
+ capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
492
+ capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
493
+ capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
494
+ }),
495
+ })
496
+ }}
497
+ value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
498
+ >
499
+ <option value="none">No Top</option>
500
+ <option value="simple">Simple Top</option>
501
+ <option value="stepped">Stepped Top</option>
502
+ <option value="rounded">Rounded Top</option>
503
+ </select>
504
+ {node.capitalStyle !== 'none' && (
505
+ <SliderControl
506
+ label="Top Height"
507
+ max={0.8}
508
+ min={0.06}
509
+ onChange={(value) => handleUpdate({ capitalHeight: value })}
510
+ precision={2}
511
+ step={0.02}
512
+ unit="m"
513
+ value={node.capitalHeight}
514
+ />
515
+ )}
516
+ {node.capitalStyle !== 'none' && (
517
+ <SliderControl
518
+ label="Top Width"
519
+ max={2.4}
520
+ min={0.6}
521
+ onChange={(value) =>
522
+ handleUpdate({
523
+ capitalWidthScale: value,
524
+ ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
525
+ })
526
+ }
527
+ precision={2}
528
+ step={0.02}
529
+ value={node.capitalWidthScale ?? 1.28}
530
+ />
531
+ )}
532
+ {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
533
+ <SliderControl
534
+ label="Top Depth"
535
+ max={2.4}
536
+ min={0.6}
537
+ onChange={(value) => handleUpdate({ capitalDepthScale: value })}
538
+ precision={2}
539
+ step={0.02}
540
+ value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
541
+ />
542
+ )}
543
+ {node.capitalStyle === 'stepped' && (
544
+ <SliderControl
545
+ label="Top Tiers"
546
+ max={8}
547
+ min={3}
548
+ onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
549
+ precision={0}
550
+ step={1}
551
+ value={node.capitalTierCount ?? 3}
552
+ />
553
+ )}
554
+ {node.capitalStyle === 'stepped' && (
555
+ <SliderControl
556
+ label="Top Step Spread"
557
+ max={0.9}
558
+ min={0.05}
559
+ onChange={(value) => handleUpdate({ capitalStepSpread: value })}
560
+ precision={2}
561
+ step={0.01}
562
+ value={node.capitalStepSpread ?? 0.34}
563
+ />
564
+ )}
565
+ <select
566
+ className={`${SELECT_CLASS} mt-2`}
567
+ onChange={(event) => {
568
+ const baseStyle = event.target.value as ColumnNode['baseStyle']
569
+ handleUpdate({
570
+ baseStyle,
571
+ ...(baseStyle === 'none'
572
+ ? {}
573
+ : {
574
+ baseHeight: Math.max(node.baseHeight, 0.12),
575
+ baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
576
+ baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
577
+ baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
578
+ baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
579
+ basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
580
+ baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
581
+ baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
582
+ }),
583
+ })
584
+ }}
585
+ value={node.baseStyle ?? 'square-plinth'}
586
+ >
587
+ <option value="none">No Bottom</option>
588
+ <option value="simple-square">Simple Block Bottom</option>
589
+ <option value="square-plinth">Square Plinth Bottom</option>
590
+ <option value="stepped-square">Stepped Bottom</option>
591
+ <option value="round-rings">Rounded Bottom</option>
592
+ </select>
593
+ {node.baseStyle !== 'none' && (
594
+ <SliderControl
595
+ label="Bottom Height"
596
+ max={0.8}
597
+ min={0.06}
598
+ onChange={(value) => handleUpdate({ baseHeight: value })}
599
+ precision={2}
600
+ step={0.02}
601
+ unit="m"
602
+ value={node.baseHeight}
603
+ />
604
+ )}
605
+ {node.baseStyle !== 'none' && (
606
+ <SliderControl
607
+ label="Bottom Width"
608
+ max={2.4}
609
+ min={0.6}
610
+ onChange={(value) =>
611
+ handleUpdate({
612
+ baseWidthScale: value,
613
+ ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
614
+ })
615
+ }
616
+ precision={2}
617
+ step={0.02}
618
+ value={node.baseWidthScale ?? 1.24}
619
+ />
620
+ )}
621
+ {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
622
+ <SliderControl
623
+ label="Bottom Depth"
624
+ max={2.4}
625
+ min={0.6}
626
+ onChange={(value) => handleUpdate({ baseDepthScale: value })}
627
+ precision={2}
628
+ step={0.02}
629
+ value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
630
+ />
631
+ )}
632
+ {node.baseStyle === 'round-rings' && (
633
+ <SliderControl
634
+ label="Plinth Thickness"
635
+ max={0.7}
636
+ min={0.2}
637
+ onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
638
+ precision={2}
639
+ step={0.01}
640
+ value={node.basePlinthHeightRatio ?? 0.44}
641
+ />
642
+ )}
643
+ {node.baseStyle === 'round-rings' && (
644
+ <SliderControl
645
+ label="Round Band Width"
646
+ max={1.2}
647
+ min={0.5}
648
+ onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
649
+ precision={2}
650
+ step={0.01}
651
+ value={node.baseRoundBandScale ?? 0.92}
652
+ />
653
+ )}
654
+ {node.baseStyle === 'round-rings' && (
655
+ <SliderControl
656
+ label="Neck Width"
657
+ max={1}
658
+ min={0.35}
659
+ onChange={(value) => handleUpdate({ baseNeckScale: value })}
660
+ precision={2}
661
+ step={0.01}
662
+ value={node.baseNeckScale ?? 0.72}
663
+ />
664
+ )}
665
+ {node.baseStyle === 'stepped-square' && (
666
+ <SliderControl
667
+ label="Bottom Tiers"
668
+ max={8}
669
+ min={3}
670
+ onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
671
+ precision={0}
672
+ step={1}
673
+ value={node.baseTierCount ?? 3}
674
+ />
675
+ )}
676
+ {node.baseStyle === 'stepped-square' && (
677
+ <SliderControl
678
+ label="Bottom Step Spread"
679
+ max={0.9}
680
+ min={0.05}
681
+ onChange={(value) => handleUpdate({ baseStepSpread: value })}
682
+ precision={2}
683
+ step={0.01}
684
+ value={node.baseStepSpread ?? 0.34}
685
+ />
686
+ )}
687
+ </PanelSection>
688
+
689
+ <PanelSection title="Transform">
690
+ <SliderControl
691
+ label="Yaw"
692
+ max={180}
693
+ min={-180}
694
+ onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
695
+ precision={0}
696
+ step={1}
697
+ unit="°"
698
+ value={Math.round((node.rotation * 180) / Math.PI)}
699
+ />
700
+ </PanelSection>
701
+
702
+ <PanelSection title="Actions">
703
+ <ActionGroup>
704
+ <ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
705
+ <ActionButton
706
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
707
+ icon={<Trash2 className="h-4 w-4" />}
708
+ label="Delete"
709
+ onClick={handleDelete}
710
+ />
711
+ </ActionGroup>
712
+ </PanelSection>
713
+ </PanelWrapper>
714
+ )
715
+ }