'use client' import { type AnyNode, COLUMN_PRESETS, type ColumnNode, type ColumnPresetId, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, cn, PanelSection, PanelWrapper, SliderControl, ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' const SELECT_CLASS = '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' const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({ value: value as ColumnPresetId, label: preset.label, })) const COLUMN_PROPORTION_PRESETS = { slender: { label: 'Slender', height: 3.6, width: 0.34, baseHeight: 0.18, capitalHeight: 0.16, baseWidthScale: 1.18, capitalWidthScale: 1.16, edgeSoftness: 0.02, }, standard: { label: 'Standard', height: 2.9, width: 0.44, baseHeight: 0.22, capitalHeight: 0.2, baseWidthScale: 1.24, capitalWidthScale: 1.22, edgeSoftness: 0.025, }, heavy: { label: 'Heavy', height: 3, width: 0.58, baseHeight: 0.28, capitalHeight: 0.26, baseWidthScale: 1.34, capitalWidthScale: 1.3, edgeSoftness: 0.035, }, stout: { label: 'Short / Stout', height: 2.2, width: 0.62, baseHeight: 0.3, capitalHeight: 0.28, baseWidthScale: 1.38, capitalWidthScale: 1.34, edgeSoftness: 0.04, }, } as const type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map( ([value, preset]) => ({ value: value as ColumnProportionPresetId, label: preset.label, }), ) const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [ { label: 'Vertical', value: 'vertical' }, { label: 'A-Frame', value: 'a-frame' }, { label: 'Y Support', value: 'y-frame' }, { label: 'V Support', value: 'v-frame' }, { label: 'X Brace', value: 'x-brace' }, { label: 'K Brace', value: 'k-brace' }, { label: 'Single Strut', value: 'single-strut' }, { label: 'Tripod', value: 'tripod' }, { label: 'Trestle', value: 'trestle' }, { label: 'Portal Frame', value: 'portal-frame' }, { label: 'Box Frame', value: 'box-frame' }, ] function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function presetUpdates(presetId: ColumnPresetId): Partial { const { label, ...preset } = COLUMN_PRESETS[presetId] return { name: label, supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical', ...preset, } } function proportionUpdates( node: ColumnNode, presetId: ColumnProportionPresetId, ): Partial { const preset = COLUMN_PROPORTION_PRESETS[presetId] const depth = node.crossSection === 'rectangular' ? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6) : preset.width const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18) return { height: preset.height, width: preset.width, depth, radius: preset.width / 2, baseHeight: preset.baseHeight, capitalHeight: preset.capitalHeight, baseWidthScale: preset.baseWidthScale, baseDepthScale: preset.baseWidthScale, capitalWidthScale: preset.capitalWidthScale, capitalDepthScale: preset.capitalWidthScale, edgeSoftness: preset.edgeSoftness, shaftCornerRadius, } } function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial { if (shaftProfile === 'tapered') { return { shaftProfile, shaftTaper: 0.14, shaftBulge: 0, shaftStartScale: 0.82, shaftEndScale: 0.72, shaftSegmentCount: 32, } } if (shaftProfile === 'bulged') { return { shaftProfile, shaftTaper: 0, shaftBulge: 0.12, shaftStartScale: 0.68, shaftEndScale: 0.68, shaftSegmentCount: 32, } } if (shaftProfile === 'hourglass') { return { shaftProfile, shaftTaper: 0, shaftBulge: 0.12, shaftStartScale: 0.84, shaftEndScale: 0.84, shaftSegmentCount: 32, } } return { shaftProfile, shaftTaper: 0, shaftBulge: 0, shaftStartScale: 0.72, shaftEndScale: 0.72, shaftSegmentCount: 1, shaftTwistStep: 0, } } export default function ColumnPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined, ) const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) }, [selectedId, updateNode], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleDelete = useCallback(() => { if (!selectedId) return triggerSFX('sfx:structure-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [deleteNode, selectedId, setSelection]) const handleMove = useCallback(() => { if (!node) return triggerSFX('sfx:item-pick') setMovingNode(node) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null const shaftProfile = node.shaftProfile ?? 'straight' const supportStyle = node.supportStyle ?? 'vertical' const isBraceSupport = supportStyle === 'a-frame' || supportStyle === 'y-frame' || supportStyle === 'v-frame' || supportStyle === 'x-brace' || supportStyle === 'k-brace' || supportStyle === 'single-strut' || supportStyle === 'tripod' || supportStyle === 'trestle' || supportStyle === 'portal-frame' || supportStyle === 'box-frame' return (
{SUPPORT_STYLE_OPTIONS.map((option) => { const isSelected = supportStyle === option.value return ( ) })}
{isBraceSupport ? ( <> handleUpdate({ braceWidth: value, width: value })} precision={2} step={0.01} unit="m" value={node.braceWidth ?? node.width} /> handleUpdate({ braceDepth: value, depth: value })} precision={2} step={0.01} unit="m" value={node.braceDepth ?? node.depth} /> ) : ( <> handleUpdate({ edgeSoftness: value })} precision={3} step={0.005} unit="m" value={node.edgeSoftness ?? 0.025} /> {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( handleUpdate({ shaftCornerRadius: value })} precision={3} step={0.005} unit="m" value={node.shaftCornerRadius ?? 0.035} /> )} )}
{!isBraceSupport && ( )} handleUpdate({ height: value })} precision={2} step={0.05} unit="m" value={node.height} /> {isBraceSupport ? ( <> {(supportStyle === 'a-frame' || supportStyle === 'x-brace' || supportStyle === 'k-brace' || supportStyle === 'single-strut' || supportStyle === 'tripod' || supportStyle === 'trestle' || supportStyle === 'portal-frame' || supportStyle === 'box-frame') && ( handleUpdate({ braceBottomSpread: value, braceTopSpread: supportStyle === 'a-frame' ? Math.min(node.braceTopSpread ?? 0.12, value) : (node.braceTopSpread ?? 1), }) } precision={2} step={0.05} unit="m" value={node.braceBottomSpread ?? 1.2} /> )} handleUpdate({ braceTopSpread: value })} precision={2} step={0.02} unit="m" value={ node.braceTopSpread ?? (supportStyle === 'y-frame' || supportStyle === 'v-frame' || supportStyle === 'x-brace' || supportStyle === 'k-brace' || supportStyle === 'single-strut' || supportStyle === 'tripod' || supportStyle === 'trestle' || supportStyle === 'portal-frame' || supportStyle === 'box-frame' ? 1 : 0.12) } /> handleUpdate({ bracePlateEnabled: checked })} /> ) : ( <> handleUpdate({ width: value, radius: value / 2, ...(node.crossSection === 'rectangular' ? {} : { depth: value }), }) } precision={2} step={0.02} unit="m" value={node.width} /> {node.crossSection === 'rectangular' && ( handleUpdate({ depth: value })} precision={2} step={0.02} unit="m" value={node.depth} /> )} )} {!isBraceSupport && ( {shaftProfile === 'straight' && ( handleUpdate({ shaftStartScale: value, shaftEndScale: value })} precision={2} step={0.02} value={node.shaftStartScale ?? 0.72} /> )} {shaftProfile === 'tapered' && ( <> handleUpdate({ shaftStartScale: value })} precision={2} step={0.02} value={node.shaftStartScale ?? 0.82} /> handleUpdate({ shaftEndScale: value })} precision={2} step={0.02} value={node.shaftEndScale ?? 0.72} /> handleUpdate({ shaftTaper: value })} precision={2} step={0.01} value={node.shaftTaper ?? 0.14} /> )} {shaftProfile === 'bulged' && ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} precision={2} step={0.02} value={node.shaftStartScale ?? 0.68} /> handleUpdate({ shaftBulge: value })} precision={2} step={0.01} value={node.shaftBulge ?? 0.12} /> )} {shaftProfile === 'hourglass' && ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} precision={2} step={0.02} value={node.shaftStartScale ?? 0.84} /> handleUpdate({ shaftBulge: value })} precision={2} step={0.01} value={node.shaftBulge ?? 0.12} /> )} handleUpdate({ shaftTwistStep: value, ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 ? { shaftSegmentCount: 12 } : {}), }) } precision={0} step={5} unit="°" value={node.shaftTwistStep ?? 0} /> {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( handleUpdate({ shaftSegmentCount: Math.round(value) })} precision={0} step={1} value={node.shaftSegmentCount ?? 12} /> )} handleUpdate({ ringCount: Math.round(value) * 2, ringPlacement: 'ends', ringSpread: node.ringSpread ?? 0.16, ringThickness: node.ringThickness ?? 0.055, }) } precision={0} step={1} value={Math.ceil((node.ringCount ?? 0) / 2)} /> {(node.ringCount ?? 0) > 0 && ( handleUpdate({ ringThickness: value })} precision={3} step={0.005} unit="m" value={node.ringThickness ?? 0.055} /> )} {(node.ringCount ?? 0) > 0 && ( handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} precision={2} step={0.01} value={node.ringSpread ?? 0.16} /> )} )} {!isBraceSupport && ( {node.capitalStyle !== 'none' && ( handleUpdate({ capitalHeight: value })} precision={2} step={0.02} unit="m" value={node.capitalHeight} /> )} {node.capitalStyle !== 'none' && ( handleUpdate({ capitalWidthScale: value, ...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }), }) } precision={2} step={0.02} value={node.capitalWidthScale ?? 1.28} /> )} {node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && ( handleUpdate({ capitalDepthScale: value })} precision={2} step={0.02} value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28} /> )} {node.capitalStyle === 'stepped' && ( handleUpdate({ capitalTierCount: Math.round(value) })} precision={0} step={1} value={node.capitalTierCount ?? 3} /> )} {node.capitalStyle === 'stepped' && ( handleUpdate({ capitalStepSpread: value })} precision={2} step={0.01} value={node.capitalStepSpread ?? 0.34} /> )} {node.baseStyle !== 'none' && ( handleUpdate({ baseHeight: value })} precision={2} step={0.02} unit="m" value={node.baseHeight} /> )} {node.baseStyle !== 'none' && ( handleUpdate({ baseWidthScale: value, ...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }), }) } precision={2} step={0.02} value={node.baseWidthScale ?? 1.24} /> )} {node.baseStyle !== 'none' && node.crossSection === 'rectangular' && ( handleUpdate({ baseDepthScale: value })} precision={2} step={0.02} value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24} /> )} {node.baseStyle === 'round-rings' && ( handleUpdate({ basePlinthHeightRatio: value })} precision={2} step={0.01} value={node.basePlinthHeightRatio ?? 0.44} /> )} {node.baseStyle === 'round-rings' && ( handleUpdate({ baseRoundBandScale: value })} precision={2} step={0.01} value={node.baseRoundBandScale ?? 0.92} /> )} {node.baseStyle === 'round-rings' && ( handleUpdate({ baseNeckScale: value })} precision={2} step={0.01} value={node.baseNeckScale ?? 0.72} /> )} {node.baseStyle === 'stepped-square' && ( handleUpdate({ baseTierCount: Math.round(value) })} precision={0} step={1} value={node.baseTierCount ?? 3} /> )} {node.baseStyle === 'stepped-square' && ( handleUpdate({ baseStepSpread: value })} precision={2} step={0.01} value={node.baseStepSpread ?? 0.34} /> )} )} handleUpdate({ rotation: (value * Math.PI) / 180 })} precision={0} step={1} unit="°" value={Math.round((node.rotation * 180) / Math.PI)} /> } label="Move" onClick={handleMove} /> } label="Delete" onClick={handleDelete} />
) }