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)}")
+
+ )}
+
+ )}
))}
@@ -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 = `';
return svg;