Enhance BadgeCanvas and controls with dual ring support, improved star configuration, and text constraints. Added auto-centering on zoom, updated geometry controls for ring spacing, and refined text controls for font size limits based on ring thickness. Updated badge store to manage new geometry properties and ensure backward compatibility.

This commit is contained in:
defiQUG
2025-11-28 20:10:50 -08:00
parent 275dbacd3a
commit e022139b3c
7 changed files with 795 additions and 185 deletions

View File

@@ -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

View File

@@ -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 (
<div className="space-y-4">
<div>
<Label htmlFor="diameter">
Diameter: {geometry.diameter.toFixed(2)}"
Ring Diameter: {geometry.diameter.toFixed(2)}"
</Label>
<Slider
id="diameter"
@@ -39,7 +42,7 @@ export function GeometryControls() {
<div>
<Label htmlFor="strokeWidth">
Stroke Width: {geometry.strokeWidth.toFixed(3)}"
Ring Thickness: {geometry.strokeWidth.toFixed(3)}"
</Label>
<Slider
id="strokeWidth"
@@ -72,27 +75,30 @@ export function GeometryControls() {
{geometry.hasDualRing && (
<div>
<Label htmlFor="dualRingOffset">
Dual Ring Offset: {geometry.dualRingOffset.toFixed(2)}"
<Label htmlFor="ringSpacing">
Ring Spacing: {geometry.ringSpacing.toFixed(2)}"
</Label>
<Slider
id="dualRingOffset"
id="ringSpacing"
min={0.05}
max={0.5}
max={1.0}
step={0.05}
value={[geometry.dualRingOffset]}
onValueChange={(value) => setDualRingOffset(value[0])}
value={[geometry.ringSpacing]}
onValueChange={(value) => setRingSpacing(value[0])}
className="mt-2"
/>
<Input
type="number"
min={0.05}
max={0.5}
max={1.0}
step={0.05}
value={geometry.dualRingOffset}
onChange={(e) => setDualRingOffset(parseFloat(e.target.value) || 0.1)}
value={geometry.ringSpacing}
onChange={(e) => setRingSpacing(parseFloat(e.target.value) || 0.1)}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">
Distance between inner and outer rings
</p>
</div>
)}
</div>

View File

@@ -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 (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="starEnabled"
checked={star.enabled}
onCheckedChange={setStarEnabled}
/>
<Label htmlFor="starEnabled">Enable Star</Label>
{geometry.hasDualRing && (
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4">
<p className="text-sm text-blue-900">
<strong>Ring Integration:</strong> Star outer points automatically touch the inner ring edge (scale = 1.0) or extend beyond it (scale &gt; 1.0).
</p>
</div>
)}
<div>
<Label htmlFor="starPoints">Number of Points</Label>
<Select
value={star.points.toString()}
onValueChange={(value) => setStarPoints(parseInt(value))}
>
<SelectTrigger id="starPoints" className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((points) => (
<SelectItem key={points} value={points.toString()}>
{points} Points
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{star.enabled && (
<>
<div>
<Label htmlFor="starPoints">Number of Points</Label>
<Select
value={star.points.toString()}
onValueChange={(value) => setStarPoints(parseInt(value))}
>
<SelectTrigger id="starPoints" className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[5, 6, 7, 8, 9, 10].map((points) => (
<SelectItem key={points} value={points.toString()}>
{points} Points
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="innerRadius">
Inner Radius: {(star.innerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="innerRadius"
min={0.1}
max={Math.max(0.1, star.outerRadius - 0.05)}
step={0.05}
value={[star.innerRadius]}
onValueChange={(value) => setStarInnerRadius(value[0])}
className="mt-2"
/>
<Input
type="number"
min={0.1}
max={star.outerRadius - 0.05}
step={0.05}
value={star.innerRadius}
onChange={(e) => setStarInnerRadius(parseFloat(e.target.value) || 0.4)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="innerRadius">
Inner Radius: {(star.innerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="innerRadius"
min={0.1}
max={0.9}
step={0.05}
value={[star.innerRadius]}
onValueChange={(value) => setStarInnerRadius(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="outerRadius">
Outer Radius: {(star.outerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="outerRadius"
min={1.0}
max={1.5}
step={0.05}
value={[star.outerRadius]}
onValueChange={(value) => setStarOuterRadius(value[0])}
className="mt-2"
/>
<Input
type="number"
min={1.0}
step={0.05}
value={star.outerRadius}
onChange={(e) => setStarOuterRadius(parseFloat(e.target.value) || 1.0)}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">
100% = badge edge. &gt;100% extends beyond badge.
</p>
</div>
<div>
<Label htmlFor="outerRadius">
Outer Radius: {(star.outerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="outerRadius"
min={0.3}
max={1.0}
step={0.05}
value={[star.outerRadius]}
onValueChange={(value) => setStarOuterRadius(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="bevelDepth">
Bevel Depth: {star.bevelDepth.toFixed(2)}
</Label>
<Slider
id="bevelDepth"
min={0}
max={0.5}
step={0.05}
value={[star.bevelDepth]}
onValueChange={(value) => setStarBevelDepth(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="bevelDepth">
Bevel Depth: {star.bevelDepth.toFixed(2)}
</Label>
<Slider
id="bevelDepth"
min={0}
max={0.5}
step={0.05}
value={[star.bevelDepth]}
onValueChange={(value) => setStarBevelDepth(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="scale">
Scale: {(star.scale * 100).toFixed(0)}%
{geometry.hasDualRing && (
<span className="text-xs text-muted-foreground ml-2">
(1.0 = touches inner ring)
</span>
)}
</Label>
<Slider
id="scale"
min={0.1}
max={2.0}
step={0.1}
value={[star.scale]}
onValueChange={(value) => setStarScale(value[0])}
className="mt-2"
/>
{geometry.hasDualRing && (
<p className="text-xs text-muted-foreground mt-1">
Scale 1.0: Star points touch inner ring edge. Scale &gt; 1.0: Star extends beyond inner ring.
</p>
)}
</div>
<div>
<Label htmlFor="scale">
Scale: {(star.scale * 100).toFixed(0)}%
</Label>
<Slider
id="scale"
min={0.1}
max={2.0}
step={0.1}
value={[star.scale]}
onValueChange={(value) => setStarScale(value[0])}
className="mt-2"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="starMetallic"
checked={star.metallic}
onCheckedChange={setStarMetallic}
/>
<Label htmlFor="starMetallic">Metallic Finish</Label>
</div>
</>
)}
<div className="flex items-center space-x-2">
<Switch
id="starMetallic"
checked={star.metallic}
onCheckedChange={setStarMetallic}
/>
<Label htmlFor="starMetallic">Metallic Finish</Label>
</div>
</div>
);
}

View File

@@ -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() {
</div>
<div>
<Label>Font Size: {arcText.fontSize}px</Label>
<Label>
Font Size: {arcText.fontSize}px
{(arcText.ringTarget || 'outer') !== 'none' && (
<span className="text-xs text-muted-foreground ml-2">
(Max: {maxFontSizeForRing}px for ring thickness)
</span>
)}
</Label>
<Slider
min={8}
max={48}
max={(arcText.ringTarget || 'outer') !== 'none' ? maxFontSizeForRing : 48}
step={1}
value={[arcText.fontSize]}
onValueChange={(value) => 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"
/>
<Input
type="number"
min={8}
max={(arcText.ringTarget || 'outer') !== 'none' ? maxFontSizeForRing : 48}
step={1}
value={arcText.fontSize}
onChange={(e) => {
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 && (
<p className="text-xs text-amber-600 mt-1">
Font size will be limited to {maxFontSizeForRing}px to fit within ring thickness ({geometry.strokeWidth.toFixed(3)}")
</p>
)}
</div>
<div>
<Label>Ring Target</Label>
<Select
value={arcText.ringTarget || 'outer'}
onValueChange={(value) => {
const ringTarget = value as 'none' | 'outer' | 'inner';
// If switching to a ring target, constrain font size
let fontSize = arcText.fontSize;
if (ringTarget !== 'none' && fontSize > maxFontSizeForRing) {
fontSize = maxFontSizeForRing;
}
updateArcText(arcText.id, { ringTarget, fontSize });
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outer">
Outer Ring {geometry.hasDualRing ? '(Dual)' : '(Main)'}
</SelectItem>
<SelectItem value="inner" disabled={!geometry.hasDualRing}>
Inner Ring {geometry.hasDualRing ? '' : '(requires Dual Ring)'}
</SelectItem>
<SelectItem value="none">Custom Position</SelectItem>
</SelectContent>
</Select>
{(arcText.ringTarget || 'outer') !== 'none' && (
<p className="text-xs text-muted-foreground mt-1">
Text will follow the {(arcText.ringTarget || 'outer') === 'outer' ? 'outer' : 'inner'} ring path
{((arcText.ringTarget || 'outer') === 'outer' || geometry.hasDualRing) && (
<span className="block mt-1">
Font size limited to {maxFontSizeForRing}px by ring thickness ({geometry.strokeWidth.toFixed(3)}")
</span>
)}
</p>
)}
</div>
<div>
<Label>Color</Label>
<Input
type="color"
value={arcText.color}
value={arcText.color || '#ffffff'}
onChange={(e) => updateArcText(arcText.id, { color: e.target.value })}
className="mt-1 h-10"
/>
<p className="text-xs text-muted-foreground mt-1">
Current: {arcText.color || '#ffffff'} (use white for black rings)
</p>
</div>
</div>
))}
@@ -166,6 +274,33 @@ export function TextControls() {
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
<div>
<Label>Ring Target</Label>
<Select
value={newArcText.ringTarget}
onValueChange={(value) => {
const ringTarget = value as 'none' | 'outer' | 'inner';
let fontSize = newArcText.fontSize;
if (ringTarget !== 'none' && fontSize > maxFontSizeForRing) {
fontSize = maxFontSizeForRing;
}
setNewArcText({ ...newArcText, ringTarget, fontSize });
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Custom Position</SelectItem>
<SelectItem value="outer" disabled={!geometry.hasDualRing}>
Outer Ring {geometry.hasDualRing ? '' : '(requires Dual Ring)'}
</SelectItem>
<SelectItem value="inner" disabled={!geometry.hasDualRing}>
Inner Ring {geometry.hasDualRing ? '' : '(requires Dual Ring)'}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddArcText} className="w-full">
Add Arc Text
</Button>

View File

@@ -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<BadgeStore>((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<BadgeStore>((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<BadgeStore>((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();

View File

@@ -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 {

View File

@@ -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 `<path d="${outerPath} ${innerPath}" fill="${fillColor}" fill-rule="evenodd" stroke="none" />`;
}
// 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 = `<circle cx="${centerX}" cy="${centerY}" r="${radius}"
fill="none" stroke="#000000" stroke-width="${inchesToPixels(geometry.strokeWidth)}" />`;
const { outerRingOuter, outerRingInner } = calculateRingRadii(geometry);
if (geometry.hasDualRing) {
const innerRadius = radius - inchesToPixels(geometry.dualRingOffset);
svg += `<circle cx="${centerX}" cy="${centerY}" r="${innerRadius}"
fill="none" stroke="#000000" stroke-width="${inchesToPixels(geometry.strokeWidth)}" />`;
}
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 `<defs>
<path id="arc-text-${arcText.id}" d="${path}" />
</defs>
<text font-family="${arcText.fontFamily}" font-size="${arcText.fontSize}"
fill="${arcText.color}">
<text font-family="${arcText.fontFamily}" font-size="${fontSize}"
fill="${textColor}">
<textPath href="#arc-text-${arcText.id}" startOffset="${arcText.alignment === 'center' ? '50%' : arcText.alignment === 'right' ? '100%' : '0%'}"
text-anchor="${arcText.alignment === 'center' ? 'middle' : arcText.alignment === 'right' ? 'end' : 'start'}">
${arcText.text}
@@ -183,6 +362,21 @@ export function generateArcText(
</text>`;
}
// 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 `<rect x="${x - width / 2}" y="${y - height / 2}"
width="${width}" height="${height}"
fill="#ffffff" stroke="#000000" stroke-width="1" rx="2" />`;
}
// Generate ribbon text SVG
export function generateRibbonText(
ribbonText: RibbonText,
@@ -193,10 +387,10 @@ export function generateRibbonText(
return `<text x="${x}" y="${y}" font-family="${ribbonText.fontFamily}"
font-size="${ribbonText.fontSize}" fill="${ribbonText.color}"
text-anchor="middle">${ribbonText.text}</text>`;
text-anchor="middle" dominant-baseline="central">${ribbonText.text}</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 = `<svg width="${diameterPx}" height="${diameterPx}"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${diameterPx} ${diameterPx}">`;
// 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 += '</svg>';
return svg;