@pascal-app/editor 0.5.1 → 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 (150) hide show
  1. package/package.json +12 -7
  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 +29 -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 +281 -83
  10. package/src/components/editor/floating-building-action-menu.tsx +4 -3
  11. package/src/components/editor/floorplan-background-selection.ts +113 -0
  12. package/src/components/editor/floorplan-panel.tsx +10442 -3275
  13. package/src/components/editor/index.tsx +270 -20
  14. package/src/components/editor/node-action-menu.tsx +14 -1
  15. package/src/components/editor/selection-manager.tsx +766 -12
  16. package/src/components/editor/site-edge-labels.tsx +9 -3
  17. package/src/components/editor/thumbnail-generator.tsx +350 -157
  18. package/src/components/editor/use-floorplan-background-placement.ts +257 -0
  19. package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
  20. package/src/components/editor/use-floorplan-scene-data.ts +189 -0
  21. package/src/components/editor/wall-measurement-label.tsx +377 -58
  22. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
  23. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
  24. package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
  25. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
  26. package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
  27. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
  28. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
  29. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
  30. package/src/components/editor-2d/svg-paths.ts +119 -0
  31. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  32. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  33. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
  34. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +2 -0
  35. package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
  36. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  37. package/src/components/tools/column/column-tool.tsx +97 -0
  38. package/src/components/tools/column/move-column-tool.tsx +105 -0
  39. package/src/components/tools/door/door-tool.tsx +19 -0
  40. package/src/components/tools/door/move-door-tool.tsx +38 -8
  41. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  42. package/src/components/tools/fence/fence-drafting.ts +27 -8
  43. package/src/components/tools/fence/fence-tool.tsx +159 -3
  44. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +438 -0
  45. package/src/components/tools/fence/move-fence-tool.tsx +102 -27
  46. package/src/components/tools/item/move-tool.tsx +19 -1
  47. package/src/components/tools/item/placement-math.ts +44 -7
  48. package/src/components/tools/item/placement-strategies.ts +111 -33
  49. package/src/components/tools/item/placement-types.ts +7 -0
  50. package/src/components/tools/item/use-draft-node.ts +2 -0
  51. package/src/components/tools/item/use-placement-coordinator.tsx +701 -61
  52. package/src/components/tools/roof/move-roof-tool.tsx +111 -43
  53. package/src/components/tools/shared/polygon-editor.tsx +244 -29
  54. package/src/components/tools/shared/segment-angle.ts +156 -0
  55. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  56. package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
  57. package/src/components/tools/slab/slab-hole-editor.tsx +2 -0
  58. package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
  59. package/src/components/tools/spawn/spawn-tool.tsx +130 -0
  60. package/src/components/tools/stair/stair-tool.tsx +11 -3
  61. package/src/components/tools/tool-manager.tsx +30 -3
  62. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  63. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +423 -0
  64. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  65. package/src/components/tools/wall/wall-drafting.ts +348 -17
  66. package/src/components/tools/wall/wall-tool.tsx +134 -2
  67. package/src/components/tools/window/move-window-tool.tsx +28 -0
  68. package/src/components/tools/window/window-tool.tsx +17 -0
  69. package/src/components/ui/action-menu/camera-actions.tsx +37 -33
  70. package/src/components/ui/action-menu/control-modes.tsx +37 -5
  71. package/src/components/ui/action-menu/index.tsx +91 -1
  72. package/src/components/ui/action-menu/structure-tools.tsx +2 -0
  73. package/src/components/ui/action-menu/view-toggles.tsx +424 -35
  74. package/src/components/ui/command-palette/editor-commands.tsx +27 -5
  75. package/src/components/ui/command-palette/index.tsx +0 -1
  76. package/src/components/ui/controls/material-picker.tsx +189 -169
  77. package/src/components/ui/controls/slider-control.tsx +88 -26
  78. package/src/components/ui/floating-level-selector.tsx +286 -55
  79. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  80. package/src/components/ui/item-catalog/catalog-items.tsx +1121 -1219
  81. package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
  82. package/src/components/ui/level-duplicate-dialog.tsx +115 -0
  83. package/src/components/ui/panels/ceiling-panel.tsx +47 -27
  84. package/src/components/ui/panels/column-panel.tsx +715 -0
  85. package/src/components/ui/panels/door-panel.tsx +986 -294
  86. package/src/components/ui/panels/fence-panel.tsx +55 -12
  87. package/src/components/ui/panels/item-panel.tsx +5 -5
  88. package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
  89. package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
  90. package/src/components/ui/panels/node-display.ts +39 -0
  91. package/src/components/ui/panels/paint-panel.tsx +138 -0
  92. package/src/components/ui/panels/panel-manager.tsx +241 -30
  93. package/src/components/ui/panels/panel-wrapper.tsx +48 -39
  94. package/src/components/ui/panels/reference-panel.tsx +243 -9
  95. package/src/components/ui/panels/roof-panel.tsx +30 -62
  96. package/src/components/ui/panels/roof-segment-panel.tsx +8 -23
  97. package/src/components/ui/panels/slab-panel.tsx +46 -24
  98. package/src/components/ui/panels/spawn-panel.tsx +155 -0
  99. package/src/components/ui/panels/stair-panel.tsx +117 -69
  100. package/src/components/ui/panels/stair-segment-panel.tsx +13 -27
  101. package/src/components/ui/panels/wall-panel.tsx +71 -17
  102. package/src/components/ui/panels/window-panel.tsx +665 -146
  103. package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
  104. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  105. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +9 -5
  106. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +7 -3
  107. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
  108. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -3
  109. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +7 -3
  110. package/src/components/ui/sidebar/panels/site-panel/index.tsx +138 -56
  111. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +7 -3
  112. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +9 -5
  113. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  114. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +7 -3
  115. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
  116. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +7 -3
  117. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +3 -3
  118. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +12 -6
  119. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +7 -3
  120. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -3
  121. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +15 -8
  122. package/src/components/ui/sidebar/tab-bar.tsx +3 -0
  123. package/src/components/ui/viewer-toolbar.tsx +96 -2
  124. package/src/components/viewer-overlay.tsx +25 -19
  125. package/src/hooks/use-auto-frame.ts +45 -0
  126. package/src/hooks/use-contextual-tools.ts +14 -13
  127. package/src/hooks/use-keyboard.ts +67 -9
  128. package/src/hooks/use-mobile.ts +12 -12
  129. package/src/index.tsx +2 -1
  130. package/src/lib/door-interaction.ts +88 -0
  131. package/src/lib/floorplan/geometry.ts +263 -0
  132. package/src/lib/floorplan/index.ts +38 -0
  133. package/src/lib/floorplan/items.ts +179 -0
  134. package/src/lib/floorplan/selection-tool.ts +231 -0
  135. package/src/lib/floorplan/stairs.ts +478 -0
  136. package/src/lib/floorplan/types.ts +57 -0
  137. package/src/lib/floorplan/walls.ts +23 -0
  138. package/src/lib/guide-events.ts +10 -0
  139. package/src/lib/history.ts +20 -0
  140. package/src/lib/level-duplication.test.ts +72 -0
  141. package/src/lib/level-duplication.ts +153 -0
  142. package/src/lib/local-guide-image.ts +42 -0
  143. package/src/lib/material-paint.ts +284 -0
  144. package/src/lib/roof-duplication.ts +214 -0
  145. package/src/lib/scene-bounds.test.ts +183 -0
  146. package/src/lib/scene-bounds.ts +169 -0
  147. package/src/lib/sfx-player.ts +96 -13
  148. package/src/lib/stair-duplication.ts +126 -0
  149. package/src/lib/window-interaction.ts +86 -0
  150. package/src/store/use-editor.tsx +279 -15
@@ -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
+ }