diff --git a/src/components/BadgeCanvas.tsx b/src/components/BadgeCanvas.tsx index a987b14..23977cc 100644 --- a/src/components/BadgeCanvas.tsx +++ b/src/components/BadgeCanvas.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState, useCallback } from 'react'; import { useBadgeStore } from '@/stores/badgeStore'; import { generateBadgeSVG } from '@/utils/svgGenerators'; @@ -8,6 +8,7 @@ export function BadgeCanvas() { const [pan, setPan] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + const [autoCenterOnZoom, setAutoCenterOnZoom] = useState(true); const geometry = useBadgeStore((state) => state.geometry); const star = useBadgeStore((state) => state.star); @@ -20,22 +21,53 @@ export function BadgeCanvas() { }, [geometry, star, shield, arcTexts, ribbonTexts]); const diameterPx = geometry.diameter * 96; + + // Calculate pan position to keep badge centered when zooming + const calculateCenteredPan = useCallback((zoomLevel: number) => { + // Badge center is at (diameterPx/2, diameterPx/2) + // ViewBox center should map to badge center + // pan.x + viewBoxWidth/2 = diameterPx/2 + // pan.x = diameterPx/2 - viewBoxWidth/2 + // pan.x = diameterPx/2 - (diameterPx/zoom)/2 + // pan.x = diameterPx * (1 - 1/zoom) / 2 + const panX = diameterPx * (1 - 1 / zoomLevel) / 2; + const panY = diameterPx * (1 - 1 / zoomLevel) / 2; + return { x: panX, y: panY }; + }, [diameterPx]); + const viewBox = useMemo(() => { const baseWidth = diameterPx; const baseHeight = diameterPx; - return `${pan.x} ${pan.y} ${baseWidth / zoom} ${baseHeight / zoom}`; - }, [diameterPx, zoom, pan]); + // Use auto-centered pan when auto-centering is enabled, otherwise use manual pan + const effectivePan = autoCenterOnZoom ? calculateCenteredPan(zoom) : pan; + return `${effectivePan.x} ${effectivePan.y} ${baseWidth / zoom} ${baseHeight / zoom}`; + }, [diameterPx, zoom, pan, autoCenterOnZoom, calculateCenteredPan]); const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; - setZoom((prev) => Math.max(0.1, Math.min(5, prev * delta))); + const newZoom = Math.max(0.1, Math.min(5, zoom * delta)); + setZoom(newZoom); + // Auto-center on zoom unless user is actively panning + if (!isPanning) { + setAutoCenterOnZoom(true); + } }; const handleMouseDown = (e: React.MouseEvent) => { if (e.button === 1 || (e.button === 0 && e.ctrlKey)) { setIsPanning(true); - setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); + // When starting to pan, disable auto-center and initialize pan position + if (autoCenterOnZoom) { + // If we were auto-centering, calculate and set the current pan position + const currentPan = calculateCenteredPan(zoom); + setPan(currentPan); + setAutoCenterOnZoom(false); + setPanStart({ x: e.clientX - currentPan.x, y: e.clientY - currentPan.y }); + } else { + // If already in manual pan mode, use existing pan + setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); + } e.preventDefault(); } }; @@ -54,16 +86,21 @@ export function BadgeCanvas() { }; const handleZoomIn = () => { - setZoom((prev) => Math.min(5, prev * 1.2)); + const newZoom = Math.min(5, zoom * 1.2); + setZoom(newZoom); + setAutoCenterOnZoom(true); }; const handleZoomOut = () => { - setZoom((prev) => Math.max(0.1, prev / 1.2)); + const newZoom = Math.max(0.1, zoom / 1.2); + setZoom(newZoom); + setAutoCenterOnZoom(true); }; const handleReset = () => { setZoom(1); setPan({ x: 0, y: 0 }); + setAutoCenterOnZoom(true); }; // Parse and update SVG with viewBox diff --git a/src/components/controls/GeometryControls.tsx b/src/components/controls/GeometryControls.tsx index 849bc80..be6030d 100644 --- a/src/components/controls/GeometryControls.tsx +++ b/src/components/controls/GeometryControls.tsx @@ -10,12 +10,15 @@ export function GeometryControls() { const setStrokeWidth = useBadgeStore((state) => state.setStrokeWidth); const setDualRing = useBadgeStore((state) => state.setDualRing); const setDualRingOffset = useBadgeStore((state) => state.setDualRingOffset); + const setInnerRingRadius = useBadgeStore((state) => state.setInnerRingRadius); + const setOuterRingRadius = useBadgeStore((state) => state.setOuterRingRadius); + const setRingSpacing = useBadgeStore((state) => state.setRingSpacing); return (
-
)}
diff --git a/src/components/controls/StarControls.tsx b/src/components/controls/StarControls.tsx index f4911e9..94ddc7c 100644 --- a/src/components/controls/StarControls.tsx +++ b/src/components/controls/StarControls.tsx @@ -7,116 +7,141 @@ import { Input } from '@/components/ui/input'; export function StarControls() { const star = useBadgeStore((state) => state.star); - const setStarEnabled = useBadgeStore((state) => state.setStarEnabled); const setStarPoints = useBadgeStore((state) => state.setStarPoints); const setStarInnerRadius = useBadgeStore((state) => state.setStarInnerRadius); const setStarOuterRadius = useBadgeStore((state) => state.setStarOuterRadius); const setStarBevelDepth = useBadgeStore((state) => state.setStarBevelDepth); const setStarMetallic = useBadgeStore((state) => state.setStarMetallic); const setStarScale = useBadgeStore((state) => state.setStarScale); + const geometry = useBadgeStore((state) => state.geometry); return (
-
- - + {geometry.hasDualRing && ( +
+

+ Ring Integration: Star outer points automatically touch the inner ring edge (scale = 1.0) or extend beyond it (scale > 1.0). +

+
+ )} + +
+ +
- {star.enabled && ( - <> -
- - -
+
+ + setStarInnerRadius(value[0])} + className="mt-2" + /> + setStarInnerRadius(parseFloat(e.target.value) || 0.4)} + className="mt-2" + /> +
-
- - setStarInnerRadius(value[0])} - className="mt-2" - /> -
+
+ + setStarOuterRadius(value[0])} + className="mt-2" + /> + setStarOuterRadius(parseFloat(e.target.value) || 1.0)} + className="mt-2" + /> +

+ 100% = badge edge. >100% extends beyond badge. +

+
-
- - setStarOuterRadius(value[0])} - className="mt-2" - /> -
+
+ + setStarBevelDepth(value[0])} + className="mt-2" + /> +
-
- - setStarBevelDepth(value[0])} - className="mt-2" - /> -
+
+ + setStarScale(value[0])} + className="mt-2" + /> + {geometry.hasDualRing && ( +

+ Scale 1.0: Star points touch inner ring edge. Scale > 1.0: Star extends beyond inner ring. +

+ )} +
-
- - setStarScale(value[0])} - className="mt-2" - /> -
- -
- - -
- - )} +
+ + +
); } diff --git a/src/components/controls/TextControls.tsx b/src/components/controls/TextControls.tsx index 115fed2..42c8016 100644 --- a/src/components/controls/TextControls.tsx +++ b/src/components/controls/TextControls.tsx @@ -5,9 +5,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Slider } from '@/components/ui/slider'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { ArcText, RibbonText } from '@/types/badge'; +// Calculate maximum font size based on ring thickness +function getMaxFontSizeForRing(thicknessInches: number): number { + const thicknessPx = thicknessInches * 96; + return Math.max(8, Math.floor(thicknessPx * 0.75)); +} + export function TextControls() { const arcTexts = useBadgeStore((state) => state.text.arcTexts); const ribbonTexts = useBadgeStore((state) => state.text.ribbonTexts); @@ -17,16 +23,32 @@ export function TextControls() { const addRibbonText = useBadgeStore((state) => state.addRibbonText); const updateRibbonText = useBadgeStore((state) => state.updateRibbonText); const removeRibbonText = useBadgeStore((state) => state.removeRibbonText); + const geometry = useBadgeStore((state) => state.geometry); + + // Calculate max font size for ring-constrained text + const maxFontSizeForRing = useMemo(() => { + if (!geometry.hasDualRing) { + // For single ring, still constrain based on ring thickness + return getMaxFontSizeForRing(geometry.strokeWidth); + } + return getMaxFontSizeForRing(geometry.strokeWidth); + }, [geometry.strokeWidth, geometry.hasDualRing]); + + // Calculate initial font size that fits within ring + const initialFontSize = useMemo(() => { + return Math.min(12, maxFontSizeForRing); + }, [maxFontSizeForRing]); const [newArcText, setNewArcText] = useState({ text: '', position: 'top' as 'top' | 'bottom', - fontSize: 12, + fontSize: initialFontSize, fontFamily: 'Arial', - color: '#000000', + color: '#ffffff', // Default white for visibility on black rings curvature: 50, spacing: 1, alignment: 'center' as 'left' | 'center' | 'right', + ringTarget: 'outer' as 'none' | 'outer' | 'inner', // Default to outer ring }); const [newRibbonText, setNewRibbonText] = useState({ @@ -40,20 +62,31 @@ export function TextControls() { const handleAddArcText = () => { if (newArcText.text) { + // Ensure font size fits within ring if ring target is set + let fontSize = newArcText.fontSize; + if (newArcText.ringTarget !== 'none' && fontSize > maxFontSizeForRing) { + fontSize = maxFontSizeForRing; + } + const arcText: ArcText = { id: Date.now().toString(), ...newArcText, + fontSize, }; addArcText(arcText); + + // Reset with default font size that fits ring + const defaultFontSize = Math.min(12, maxFontSizeForRing); setNewArcText({ text: '', position: 'top', - fontSize: 12, + fontSize: defaultFontSize, fontFamily: 'Arial', - color: '#000000', + color: '#ffffff', // Default white for visibility on black rings curvature: 50, spacing: 1, alignment: 'center', + ringTarget: 'outer', // Default to outer ring }); } }; @@ -124,25 +157,100 @@ export function TextControls() {
- + updateArcText(arcText.id, { fontSize: value[0] })} + value={[Math.min(arcText.fontSize, (arcText.ringTarget || 'outer') !== 'none' ? maxFontSizeForRing : 48)]} + onValueChange={(value) => { + const constrainedValue = (arcText.ringTarget || 'outer') !== 'none' + ? Math.min(value[0], maxFontSizeForRing) + : value[0]; + updateArcText(arcText.id, { fontSize: constrainedValue }); + }} className="mt-2" /> + { + let fontSize = parseInt(e.target.value) || 8; + if ((arcText.ringTarget || 'outer') !== 'none') { + fontSize = Math.min(fontSize, maxFontSizeForRing); + } + fontSize = Math.max(8, Math.min(fontSize, (arcText.ringTarget || 'outer') !== 'none' ? maxFontSizeForRing : 48)); + updateArcText(arcText.id, { fontSize }); + }} + className="mt-2" + /> + {(arcText.ringTarget || 'outer') !== 'none' && arcText.fontSize > maxFontSizeForRing && ( +

+ Font size will be limited to {maxFontSizeForRing}px to fit within ring thickness ({geometry.strokeWidth.toFixed(3)}") +

+ )} +
+ +
+ + + {(arcText.ringTarget || 'outer') !== 'none' && ( +

+ Text will follow the {(arcText.ringTarget || 'outer') === 'outer' ? 'outer' : 'inner'} ring path + {((arcText.ringTarget || 'outer') === 'outer' || geometry.hasDualRing) && ( + + Font size limited to {maxFontSizeForRing}px by ring thickness ({geometry.strokeWidth.toFixed(3)}") + + )} +

+ )}
updateArcText(arcText.id, { color: e.target.value })} className="mt-1 h-10" /> +

+ Current: {arcText.color || '#ffffff'} (use white for black rings) +

))} @@ -166,6 +274,33 @@ export function TextControls() { Bottom +
+ + +
diff --git a/src/stores/badgeStore.ts b/src/stores/badgeStore.ts index be3714a..7a6facf 100644 --- a/src/stores/badgeStore.ts +++ b/src/stores/badgeStore.ts @@ -5,19 +5,23 @@ const defaultGeometry: BadgeGeometry = { diameter: 3.0, strokeWidth: 0.05, hasDualRing: false, - dualRingOffset: 0.1, + ringSpacing: 0.1, // spacing between rings in inches + innerRingRadius: 0.7, // calculated + outerRingRadius: 0.95, // calculated + dualRingOffset: 0.1, // deprecated }; const defaultStar: StarConfig = { - enabled: false, + enabled: true, // Star is always enabled points: 5, innerRadius: 0.4, - outerRadius: 0.8, + outerRadius: 1.0, // Minimum is 100% of badge radius bevelDepth: 0.1, metallic: false, x: 0, y: 0, scale: 1, + autoResizeToRing: false, // Will be automatically applied when dual ring is enabled }; const defaultShield: ShieldConfig = { @@ -46,6 +50,9 @@ interface BadgeStore extends BadgeState { setStrokeWidth: (width: number) => void; setDualRing: (enabled: boolean) => void; setDualRingOffset: (offset: number) => void; + setInnerRingRadius: (radius: number) => void; + setOuterRingRadius: (radius: number) => void; + setRingSpacing: (spacing: number) => void; // Star actions setStarEnabled: (enabled: boolean) => void; @@ -56,6 +63,7 @@ interface BadgeStore extends BadgeState { setStarMetallic: (metallic: boolean) => void; setStarPosition: (x: number, y: number) => void; setStarScale: (scale: number) => void; + setStarAutoResizeToRing: (enabled: boolean) => void; // Shield actions setShieldEnabled: (enabled: boolean) => void; @@ -96,23 +104,102 @@ export const useBadgeStore = create((set, get) => ({ historyIndex: 0, setDiameter: (diameter) => { - set((state) => ({ - geometry: { ...state.geometry, diameter: Math.max(2.0, Math.min(4.0, diameter)) }, - })); + set((state) => { + const newDiameter = Math.max(2.0, Math.min(4.0, diameter)); + // Recalculate ring positions if dual ring is enabled + if (state.geometry.hasDualRing) { + const radius = newDiameter / 2; + const thickness = state.geometry.strokeWidth; + const spacing = state.geometry.ringSpacing; + + const outerRingPosition = radius - thickness / 2; + const innerRingPosition = outerRingPosition - spacing - thickness; + + const outerRingRadius = Math.max(0.1, Math.min(0.99, outerRingPosition / radius)); + const innerRingRadius = Math.max(0.1, Math.min(outerRingRadius - 0.01, innerRingPosition / radius)); + + return { + geometry: { + ...state.geometry, + diameter: newDiameter, + outerRingRadius, + innerRingRadius, + }, + }; + } + return { + geometry: { ...state.geometry, diameter: newDiameter }, + }; + }); get().saveHistory(); }, setStrokeWidth: (width) => { - set((state) => ({ - geometry: { ...state.geometry, strokeWidth: Math.max(0, width) }, - })); + set((state) => { + const newWidth = Math.max(0.01, width); + // Ring thickness determines inner edge position: inner edge = outer edge - thickness + // For dual ring: inner ring outer edge = outer ring inner edge - spacing + // inner ring inner edge = inner ring outer edge - thickness + if (state.geometry.hasDualRing) { + const radius = state.geometry.diameter / 2; + const outerRingRadius = state.geometry.outerRingRadius; + const spacing = state.geometry.ringSpacing; + + // Outer ring: outer edge at outerRingRadius, inner edge at outerRingRadius - thickness + const outerRingOuterEdge = radius * outerRingRadius; + const outerRingInnerEdge = outerRingOuterEdge - (newWidth * 96); + + // Inner ring: outer edge = outer ring inner edge - spacing, inner edge = inner ring outer edge - thickness + const innerRingOuterEdge = outerRingInnerEdge - (spacing * 96); + const innerRingInnerEdge = innerRingOuterEdge - (newWidth * 96); + + // Store inner ring outer edge position as percentage + const innerRingRadius = Math.max(0.1, Math.min(outerRingRadius - 0.01, innerRingOuterEdge / radius)); + + return { + geometry: { + ...state.geometry, + strokeWidth: newWidth, + innerRingRadius, + }, + }; + } else { + // Single ring: thickness adjusts inner ring automatically in rendering + return { + geometry: { ...state.geometry, strokeWidth: newWidth }, + }; + } + }); get().saveHistory(); }, setDualRing: (enabled) => { - set((state) => ({ - geometry: { ...state.geometry, hasDualRing: enabled }, - })); + set((state) => { + if (enabled && !state.geometry.hasDualRing) { + // Initialize ring positions when enabling dual ring + const radius = state.geometry.diameter / 2; + const thickness = state.geometry.strokeWidth; + const spacing = state.geometry.ringSpacing; + + const outerRingPosition = radius - thickness / 2; + const innerRingPosition = outerRingPosition - spacing - thickness; + + const outerRingRadius = Math.max(0.1, Math.min(0.99, outerRingPosition / radius)); + const innerRingRadius = Math.max(0.1, Math.min(outerRingRadius - 0.01, innerRingPosition / radius)); + + return { + geometry: { + ...state.geometry, + hasDualRing: enabled, + outerRingRadius, + innerRingRadius, + }, + }; + } + return { + geometry: { ...state.geometry, hasDualRing: enabled }, + }; + }); get().saveHistory(); }, @@ -123,29 +210,91 @@ export const useBadgeStore = create((set, get) => ({ get().saveHistory(); }, + setInnerRingRadius: (radius) => { + set((state) => ({ + geometry: { + ...state.geometry, + innerRingRadius: Math.max(0.1, Math.min(0.99, radius)), + }, + })); + get().saveHistory(); + }, + + setOuterRingRadius: (radius) => { + set((state) => { + const newRadius = Math.max(0.1, Math.min(0.99, radius)); + const innerRadius = state.geometry.innerRingRadius; + return { + geometry: { + ...state.geometry, + outerRingRadius: newRadius < innerRadius ? innerRadius : newRadius, + }, + }; + }); + get().saveHistory(); + }, + + setRingSpacing: (spacing) => { + set((state) => { + const newSpacing = Math.max(0.05, Math.min(1.0, spacing)); + const radius = state.geometry.diameter / 2; + const thickness = state.geometry.strokeWidth; + const outerRingRadius = state.geometry.outerRingRadius; + + // Calculate inner ring position based on spacing + // Outer ring: outer edge at outerRingRadius, inner edge at outerRingRadius - thickness + // Inner ring: outer edge = outer ring inner edge - spacing + const outerRingOuterEdge = radius * outerRingRadius; + const outerRingInnerEdge = outerRingOuterEdge - (thickness * 96); + const innerRingOuterEdge = outerRingInnerEdge - (newSpacing * 96); + + // Store inner ring outer edge position as percentage + const innerRingRadius = Math.max(0.1, Math.min(outerRingRadius - 0.01, innerRingOuterEdge / radius)); + + return { + geometry: { + ...state.geometry, + ringSpacing: newSpacing, + innerRingRadius, + }, + }; + }); + get().saveHistory(); + }, + setStarEnabled: (enabled) => { - set((state) => ({ star: { ...state.star, enabled } })); + // Star is always enabled now, but keep this function for backward compatibility + // Always set enabled to true regardless of input + set((state) => ({ star: { ...state.star, enabled: true } })); get().saveHistory(); }, setStarPoints: (points) => { set((state) => ({ - star: { ...state.star, points: Math.max(5, Math.min(10, points)) }, + star: { ...state.star, points: Math.max(3, Math.min(15, points)) }, })); get().saveHistory(); }, setStarInnerRadius: (radius) => { - set((state) => ({ - star: { ...state.star, innerRadius: Math.max(0, Math.min(1, radius)) }, - })); + set((state) => { + const outerRadius = state.star.outerRadius; + return { + star: { ...state.star, innerRadius: Math.max(0.1, Math.min(outerRadius - 0.05, radius)) }, + }; + }); get().saveHistory(); }, setStarOuterRadius: (radius) => { - set((state) => ({ - star: { ...state.star, outerRadius: Math.max(0, Math.min(1, radius)) }, - })); + set((state) => { + const newOuterRadius = Math.max(1.0, radius); // Minimum is 1.0 (100%) + // Ensure inner radius doesn't exceed outer radius + const innerRadius = Math.min(state.star.innerRadius, newOuterRadius - 0.05); + return { + star: { ...state.star, outerRadius: newOuterRadius, innerRadius }, + }; + }); get().saveHistory(); }, @@ -173,6 +322,11 @@ export const useBadgeStore = create((set, get) => ({ get().saveHistory(); }, + setStarAutoResizeToRing: (enabled) => { + set((state) => ({ star: { ...state.star, autoResizeToRing: enabled } })); + get().saveHistory(); + }, + setShieldEnabled: (enabled) => { set((state) => ({ shield: { ...state.shield, enabled } })); get().saveHistory(); diff --git a/src/types/badge.ts b/src/types/badge.ts index 67cbae2..91f93e9 100644 --- a/src/types/badge.ts +++ b/src/types/badge.ts @@ -1,13 +1,17 @@ export interface BadgeGeometry { - diameter: number; // in inches (2.0 - 4.0) - strokeWidth: number; + diameter: number; // in inches (2.0 - 4.0) - Ring Diameter + strokeWidth: number; // in inches - Ring Thickness hasDualRing: boolean; - dualRingOffset: number; + ringSpacing: number; // in inches - spacing between inner and outer rings (for dual ring) + // Legacy fields for backward compatibility + innerRingRadius: number; // calculated from spacing + outerRingRadius: number; // calculated from spacing + dualRingOffset: number; // deprecated } export interface StarConfig { enabled: boolean; - points: number; // 5-10 + points: number; // 3-15 innerRadius: number; // percentage of badge radius outerRadius: number; // percentage of badge radius bevelDepth: number; @@ -15,6 +19,7 @@ export interface StarConfig { x: number; y: number; scale: number; + autoResizeToRing: boolean; // if true, star auto-resizes based on ring dimensions } export interface ShieldQuadrant { @@ -43,6 +48,7 @@ export interface ArcText { curvature: number; // 0-100 spacing: number; alignment: 'left' | 'center' | 'right'; + ringTarget: 'none' | 'outer' | 'inner'; // Which ring to follow (none = custom radius) } export interface RibbonText { diff --git a/src/utils/svgGenerators.ts b/src/utils/svgGenerators.ts index 211d8c4..0c1c8f4 100644 --- a/src/utils/svgGenerators.ts +++ b/src/utils/svgGenerators.ts @@ -49,33 +49,139 @@ export function generateArcPath( return `M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`; } -// Generate badge circle SVG -export function generateBadgeCircle(geometry: BadgeGeometry): string { +// Calculate ring radii based on thickness +// Thickness decreases inner diameter: inner edge = outer edge - thickness +// Returns the outer and inner edges of the outer ring (for single ring) or both rings (for dual) +function calculateRingRadii(geometry: BadgeGeometry): { + outerRingOuter: number; + outerRingInner: number; + innerRingOuter: number | null; + innerRingInner: number | null; +} { const diameterPx = inchesToPixels(geometry.diameter); const radius = diameterPx / 2; + const thicknessPx = inchesToPixels(geometry.strokeWidth); + + // Outer ring: outer edge at outerRingRadius, inner edge = outer - thickness + const outerRingOuterPx = radius * geometry.outerRingRadius; + const outerRingInnerPx = outerRingOuterPx - thicknessPx; + + if (geometry.hasDualRing) { + // Inner ring: outer edge at innerRingRadius, inner edge = outer - thickness + const innerRingOuterPx = radius * geometry.innerRingRadius; + const innerRingInnerPx = innerRingOuterPx - thicknessPx; + + return { + outerRingOuter: outerRingOuterPx, + outerRingInner: outerRingInnerPx, + innerRingOuter: Math.max(radius * 0.1, innerRingOuterPx), + innerRingInner: Math.max(radius * 0.05, innerRingInnerPx), + }; + } else { + // Single ring only + return { + outerRingOuter: outerRingOuterPx, + outerRingInner: Math.max(radius * 0.1, outerRingInnerPx), + innerRingOuter: null, + innerRingInner: null, + }; + } +} + +// Generate ring as an annulus (filled band between outer and inner circles) +function generateRingAnnulus( + outerRadius: number, + innerRadius: number, + centerX: number, + centerY: number, + fillColor: string = '#000000' +): string { + // Create an annulus using SVG path with fill-rule="evenodd" + // Outer circle (clockwise) + inner circle (counter-clockwise) creates the ring + const outerPath = `M ${centerX + outerRadius} ${centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${centerX - outerRadius} ${centerY} A ${outerRadius} ${outerRadius} 0 1 1 ${centerX + outerRadius} ${centerY} Z`; + const innerPath = `M ${centerX + innerRadius} ${centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${centerX - innerRadius} ${centerY} A ${innerRadius} ${innerRadius} 0 1 0 ${centerX + innerRadius} ${centerY} Z`; + + return ``; +} + +// Generate outer badge circle (outer ring) - rendered on top of star +export function generateOuterBadgeCircle(geometry: BadgeGeometry): string { + const diameterPx = inchesToPixels(geometry.diameter); const centerX = diameterPx / 2; const centerY = diameterPx / 2; - let svg = ``; + const { outerRingOuter, outerRingInner } = calculateRingRadii(geometry); - if (geometry.hasDualRing) { - const innerRadius = radius - inchesToPixels(geometry.dualRingOffset); - svg += ``; - } - - return svg; + // Render as filled ring band (annulus) between outer and inner edges + return generateRingAnnulus(outerRingOuter, outerRingInner, centerX, centerY); } +// Generate inner badge circle (inner ring) - calculated from thickness and spacing +export function generateInnerBadgeCircle(geometry: BadgeGeometry): string { + if (!geometry.hasDualRing) { + return ''; + } + + const diameterPx = inchesToPixels(geometry.diameter); + const centerX = diameterPx / 2; + const centerY = diameterPx / 2; + + const { innerRingOuter, innerRingInner } = calculateRingRadii(geometry); + + // Only render if inner ring has positive thickness + if (innerRingOuter && innerRingInner && innerRingInner > 0) { + return generateRingAnnulus(innerRingOuter, innerRingInner, centerX, centerY); + } + + return ''; +} + +// Note: calculateStarSize function removed - sizing logic moved directly into generateStar +// for clarity and to ensure star always respects ring constraints + // Generate star SVG -export function generateStar(star: StarConfig, badgeRadius: number): string { - if (!star.enabled) return ''; +export function generateStar( + star: StarConfig, + badgeRadius: number, + geometry?: BadgeGeometry +): string { + // Star is always enabled, so we always render it const centerX = inchesToPixels(star.x) + badgeRadius; const centerY = inchesToPixels(star.y) + badgeRadius; - const outerRadius = badgeRadius * star.outerRadius * star.scale; - const innerRadius = badgeRadius * star.innerRadius * star.scale; + + // Calculate star size with ring constraints if geometry provided + // When dual ring is enabled, star MUST touch or exceed inner ring + let outerRadius: number; + let innerRadius: number; + + if (geometry && geometry.hasDualRing) { + // Automatic sizing: star outer points MUST touch inner ring's inner edge or exceed it + // Calculate the inner ring's inner edge (where star should touch) + const { innerRingInner } = calculateRingRadii(geometry); + const ringInnerEdge = innerRingInner || (badgeRadius * geometry.innerRingRadius); + + const pointAngle = (2 * Math.PI) / star.points; + const halfPointAngle = pointAngle / 2; + + // Calculate the radius where star outer points touch the inner ring's inner edge + // Using: outerRadius = ringInnerEdge / cos(halfPointAngle) + const touchInnerRingRadius = ringInnerEdge / Math.cos(halfPointAngle); + + // Use scale to determine if star touches inner ring (scale = 1.0) or exceeds it (scale > 1.0) + outerRadius = touchInnerRingRadius * star.scale; + + // Keep inner radius proportional to maintain star shape + innerRadius = outerRadius * (star.innerRadius / star.outerRadius); + } else if (geometry) { + // No dual ring, use normal star sizing + outerRadius = badgeRadius * star.outerRadius * star.scale; + innerRadius = badgeRadius * star.innerRadius * star.scale; + } else { + // No geometry provided, use defaults + outerRadius = badgeRadius * star.outerRadius * star.scale; + innerRadius = badgeRadius * star.innerRadius * star.scale; + } const path = generateStarPath(star.points, innerRadius, outerRadius, centerX, centerY); @@ -153,29 +259,102 @@ export function generateShield( return svg; } -// Generate arc text SVG +// Calculate maximum font size based on ring thickness +function getMaxFontSizeForRing(thicknessInches: number): number { + // Convert thickness to pixels (96 DPI) + const thicknessPx = thicknessInches * 96; + // Font size should be about 75% of ring thickness to fit comfortably + return Math.max(8, Math.floor(thicknessPx * 0.75)); +} + +// Generate arc text SVG with ring positioning and size constraints export function generateArcText( arcText: ArcText, - badgeRadius: number + badgeRadius: number, + geometry?: BadgeGeometry ): string { const centerX = badgeRadius; const centerY = badgeRadius; - const textRadius = badgeRadius * (arcText.position === 'top' ? 0.7 : 0.7); - const startAngle = arcText.position === 'top' - ? Math.PI + (arcText.curvature / 100) * Math.PI / 2 - : 0 - (arcText.curvature / 100) * Math.PI / 2; - const endAngle = arcText.position === 'top' - ? 0 - (arcText.curvature / 100) * Math.PI / 2 - : Math.PI + (arcText.curvature / 100) * Math.PI / 2; + // Handle backward compatibility: default to 'outer' (on ring) if ringTarget is undefined + const ringTarget = arcText.ringTarget || 'outer'; + + // Determine text radius based on ring target + // Text MUST be centered between outer and inner ring edges + let textRadius: number; + let maxFontSize: number | null = null; + + if (geometry && ringTarget !== 'none') { + const { outerRingOuter, outerRingInner, innerRingOuter, innerRingInner } = calculateRingRadii(geometry); + + if (geometry.hasDualRing) { + if (ringTarget === 'outer') { + // Text centered between outer ring's outer and inner edges + textRadius = (outerRingOuter + outerRingInner) / 2; + } else { + // Inner ring text: centered between inner ring's outer and inner edges + if (innerRingOuter && innerRingInner) { + textRadius = (innerRingOuter + innerRingInner) / 2; + } else { + textRadius = badgeRadius * 0.7; + } + } + // Constrain font size to ring thickness + maxFontSize = getMaxFontSizeForRing(geometry.strokeWidth); + } else { + // Single ring: text centered in the ring + if (ringTarget === 'outer') { + // Text centered between outer edge and inner edge (outer - thickness) + textRadius = (outerRingOuter + outerRingInner) / 2; + maxFontSize = getMaxFontSizeForRing(geometry.strokeWidth); + } else { + // Inner ring not available without dual ring, fall back to custom + textRadius = badgeRadius * 0.7; + } + } + } else { + // Custom radius (no ring constraint) + textRadius = badgeRadius * 0.7; + } + + // Constrain font size if max is set + const fontSize = maxFontSize ? Math.min(arcText.fontSize, maxFontSize) : arcText.fontSize; + + // Calculate arc angles + // When on ring, use full arc span (180 degrees for top, -180 for bottom) + // Curvature adjusts the arc position, but we still want full span for readability + let startAngle: number; + let endAngle: number; + + if (ringTarget !== 'none') { + // On ring: full arc span for top (π to 0) or bottom (0 to π) + if (arcText.position === 'top') { + startAngle = Math.PI; // Start at left (180°) + endAngle = 0; // End at right (0°/360°) + } else { + startAngle = 0; // Start at right + endAngle = Math.PI; // End at left + } + } else { + // Custom position: use curvature to adjust arc + startAngle = arcText.position === 'top' + ? Math.PI + (arcText.curvature / 100) * Math.PI / 2 + : 0 - (arcText.curvature / 100) * Math.PI / 2; + endAngle = arcText.position === 'top' + ? 0 - (arcText.curvature / 100) * Math.PI / 2 + : Math.PI + (arcText.curvature / 100) * Math.PI / 2; + } const path = generateArcPath(textRadius, startAngle, endAngle, centerX, centerY); + // Default to white if no color is set (for visibility on black rings) + const textColor = arcText.color || '#ffffff'; + return ` - + ${arcText.text} @@ -183,6 +362,21 @@ export function generateArcText( `; } +// Generate ribbon banner/shape SVG (rectangle banner) +export function generateRibbonBanner( + ribbonText: RibbonText, + badgeRadius: number +): string { + const x = inchesToPixels(ribbonText.x) + badgeRadius; + const y = inchesToPixels(ribbonText.y) + badgeRadius; + const width = ribbonText.text.length * ribbonText.fontSize * 0.6; // Approximate width + const height = ribbonText.fontSize * 1.5; + + return ``; +} + // Generate ribbon text SVG export function generateRibbonText( ribbonText: RibbonText, @@ -193,10 +387,10 @@ export function generateRibbonText( return `${ribbonText.text}`; + text-anchor="middle" dominant-baseline="central">${ribbonText.text}`; } -// Generate complete badge SVG +// Generate complete badge SVG with exact layer ordering export function generateBadgeSVG( geometry: BadgeGeometry, star: StarConfig, @@ -210,19 +404,72 @@ export function generateBadgeSVG( let svg = ``; - // Generate all components - const circle = generateBadgeCircle(geometry); - const starSvg = generateStar(star, radius); + // Generate base components + const starSvg = generateStar(star, radius, geometry); const shieldSvg = generateShield(shield, radius); - const arcTextsSvg = arcTexts.map(at => generateArcText(at, radius)).join('\n'); - const ribbonTextsSvg = ribbonTexts.map(rt => generateRibbonText(rt, radius)).join('\n'); + const outerRingSvg = generateOuterBadgeCircle(geometry); + const innerRingSvg = generateInnerBadgeCircle(geometry); - // Combine in order (background to foreground) - svg += circle; - svg += shieldSvg; + // Separate ribbon texts into upper and lower (by y position) + // Upper = smaller y value (closer to top), Lower = larger y value (closer to bottom) + const sortedRibbons = [...ribbonTexts].sort((a, b) => a.y - b.y); + const upperRibbons = sortedRibbons.slice(0, Math.ceil(sortedRibbons.length / 2)); + const lowerRibbons = sortedRibbons.slice(Math.ceil(sortedRibbons.length / 2)); + + // Separate arc texts by position and ring target + const topArcTextsOuter = arcTexts.filter(at => at.position === 'top' && (at.ringTarget || 'outer') === 'outer'); + const topArcTextsInner = arcTexts.filter(at => at.position === 'top' && (at.ringTarget || 'outer') === 'inner'); + const bottomArcTextsInner = arcTexts.filter(at => at.position === 'bottom' && (at.ringTarget || 'outer') === 'inner'); + const bottomArcTextsOuter = arcTexts.filter(at => at.position === 'bottom' && (at.ringTarget || 'outer') === 'outer'); + + // Generate text SVGs + const topArcOuterSvg = topArcTextsOuter.map(at => generateArcText(at, radius, geometry)).join('\n'); + const topArcInnerSvg = topArcTextsInner.map(at => generateArcText(at, radius, geometry)).join('\n'); + const bottomArcInnerSvg = bottomArcTextsInner.map(at => generateArcText(at, radius, geometry)).join('\n'); + const bottomArcOuterSvg = bottomArcTextsOuter.map(at => generateArcText(at, radius, geometry)).join('\n'); + + // Generate ribbon banners and texts + const upperRibbonBannersSvg = upperRibbons.map(rt => generateRibbonBanner(rt, radius)).join('\n'); + const lowerRibbonBannersSvg = lowerRibbons.map(rt => generateRibbonBanner(rt, radius)).join('\n'); + const upperRibbonTextsSvg = upperRibbons.map(rt => generateRibbonText(rt, radius)).join('\n'); + const lowerRibbonTextsSvg = lowerRibbons.map(rt => generateRibbonText(rt, radius)).join('\n'); + + // Render in exact layer order (bottom to top): + // 1. Star (base layer) svg += starSvg; - svg += arcTextsSvg; - svg += ribbonTextsSvg; + + // 2. Shield background (Texture Layer of/on Star) + svg += shieldSvg; + + // 3. Outer badge circle (on top of star - Outer Ring) + svg += outerRingSvg; + + // 4. Inner badge circle (on top of star - Inner Ring) + svg += innerRingSvg; + + // 5. Upper Ribbon/Scroll Banner + svg += upperRibbonBannersSvg; + + // 6. Lower Ribbon/Scroll Banner + svg += lowerRibbonBannersSvg; + + // 7. Top Arc text (Outer badge circle) + svg += topArcOuterSvg; + + // 8. Top Arc text (Inner badge circle) + svg += topArcInnerSvg; + + // 9. Upper Ribbon/Scroll text + svg += upperRibbonTextsSvg; + + // 10. Lower Ribbon/Scroll text + svg += lowerRibbonTextsSvg; + + // 11. Bottom Arc text (Inner badge circle) + svg += bottomArcInnerSvg; + + // 12. Bottom Arc text (Outer badge circle) + svg += bottomArcOuterSvg; svg += ''; return svg;