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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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. >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 > 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user