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