fix laser utility calculations

This commit is contained in:
makearmy 2026-07-10 17:22:41 -04:00
parent b59e8e54bb
commit 0855724cb0
5 changed files with 58 additions and 131 deletions

View file

@ -10,7 +10,7 @@ function num(v: string) {
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
// Spot diameter (µm) ≈ 1.27 * M² * λ(µm) * f(mm) / D(mm) // Focused Gaussian 1/e² diameter: d = 4 M² λ f / (π D).
export default function Page() { export default function Page() {
const [lambdaNm, setLambdaNm] = useState("1064"); // nm (default fiber) const [lambdaNm, setLambdaNm] = useState("1064"); // nm (default fiber)
const [focalMm, setFocalMm] = useState("160"); // mm const [focalMm, setFocalMm] = useState("160"); // mm
@ -21,9 +21,9 @@ export default function Page() {
const lamUm = num(lambdaNm) / 1000; // convert nm -> µm const lamUm = num(lambdaNm) / 1000; // convert nm -> µm
const f = num(focalMm); const f = num(focalMm);
const D = num(beamDm); const D = num(beamDm);
const M2 = Math.max(1, num(m2)); const M2 = num(m2);
if (lamUm <= 0 || f <= 0 || D <= 0) return 0; if (lamUm <= 0 || f <= 0 || D <= 0 || M2 < 1) return 0;
return 1.27 * M2 * lamUm * (f / D); return (4 / Math.PI) * M2 * lamUm * (f / D);
}, [lambdaNm, focalMm, beamDm, m2]); }, [lambdaNm, focalMm, beamDm, m2]);
const dMm = dUm / 1000; const dMm = dUm / 1000;
@ -71,7 +71,7 @@ export default function Page() {
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2"> <CardContent className="grid gap-3 sm:grid-cols-2">
<div> <div>
<div className="text-sm text-muted-foreground">Spot diameter</div> <div className="text-sm text-muted-foreground">1/e² spot diameter</div>
<div className="text-lg">{dMm.toFixed(4)} mm</div> <div className="text-lg">{dMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{dUm.toFixed(2)} µm</div> <div className="text-xs text-muted-foreground">{dUm.toFixed(2)} µm</div>
</div> </div>
@ -82,7 +82,10 @@ export default function Page() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
Gaussian-beam estimate: d = 4 M²λf/(πD), where D is the 1/e² beam diameter at the
lens. Real spots may be larger because of lens aberration, clipping, beam expansion, and focus error.
</p>
</ToolShell> </ToolShell>
); );
} }

View file

@ -29,11 +29,12 @@ export default function Page() {
setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : ""); setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : "");
} }
const overlap = useMemo(() => { const coverage = useMemo(() => {
const d = num(spotUm); const d = num(spotUm);
const g = num(gapUm); const g = num(gapUm);
if (d <= 0 || g <= 0) return 0; if (d <= 0 || g <= 0) return { overlap: 0, uncovered: 0 };
return Math.max(0, Math.min(100, 100 * (1 - g / d))); const signed = 100 * (1 - g / d);
return { overlap: Math.max(0, signed), uncovered: Math.max(0, -signed) };
}, [spotUm, gapUm]); }, [spotUm, gapUm]);
const gapMm = (num(gapUm) / 1000) || 0; const gapMm = (num(gapUm) / 1000) || 0;
@ -68,7 +69,10 @@ export default function Page() {
<CardContent className="grid gap-3 sm:grid-cols-4"> <CardContent className="grid gap-3 sm:grid-cols-4">
<div> <div>
<div className="text-sm text-muted-foreground">Overlap</div> <div className="text-sm text-muted-foreground">Overlap</div>
<div className="text-lg">{overlap.toFixed(1)}%</div> <div className="text-lg">{coverage.overlap.toFixed(1)}%</div>
{coverage.uncovered > 0 && (
<div className="text-xs text-amber-600">{coverage.uncovered.toFixed(1)}% uncovered gap</div>
)}
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Gap</div> <div className="text-sm text-muted-foreground">Gap</div>
@ -83,10 +87,10 @@ export default function Page() {
<div> <div>
<div className="text-sm text-muted-foreground">From LPI</div> <div className="text-sm text-muted-foreground">From LPI</div>
<div className="text-lg"> <div className="text-lg">
{(UM_PER_INCH / Math.max(1, num(lpi)) / 1000).toFixed(4)} mm {num(lpi) > 0 ? (UM_PER_INCH / num(lpi) / 1000).toFixed(4) : "0.0000"} mm
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{(UM_PER_INCH / Math.max(1, num(lpi))).toFixed(1)} µm {num(lpi) > 0 ? (UM_PER_INCH / num(lpi)).toFixed(1) : "0.0"} µm
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -94,4 +98,3 @@ export default function Page() {
</ToolShell> </ToolShell>
); );
} }

View file

@ -44,7 +44,7 @@ export default function Page() {
if (w <= 0 || h <= 0 || D <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 }; if (w <= 0 || h <= 0 || D <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
const gapMm = 25.4 / D; const gapMm = 25.4 / D;
const gapUm = gapMm * 1000; const gapUm = gapMm * 1000;
const rows = h / gapMm; const rows = Math.max(1, Math.ceil(h / gapMm));
const t = rows * (w / v) * p * k; const t = rows * (w / v) * p * k;
return { t, gapMm, gapUm, rows }; return { t, gapMm, gapUm, rows };
} else { } else {
@ -159,7 +159,7 @@ export default function Page() {
{/* Footnote */} {/* Footnote */}
<p className="mt-4 text-xs leading-relaxed text-muted-foreground"> <p className="mt-4 text-xs leading-relaxed text-muted-foreground">
<span className="font-semibold">Overhead factor*</span> accounts for real-world slowdowns: <span className="font-semibold">Overhead factor*</span> accounts for real-world slowdowns:
acceleration/decelleration, jump moves, polygon delays, laser on/off timing, overscan, acceleration/deceleration, jump moves, polygon delays, laser on/off timing, overscan,
bidirectional settle time, and controller latency.{" "} bidirectional settle time, and controller latency.{" "}
<span className="font-semibold">Typical values:</span> Vector cuts/marks{" "} <span className="font-semibold">Typical values:</span> Vector cuts/marks{" "}
<span className="font-medium">1.051.15</span> (simple paths, long runs closer to 1.05; tiny <span className="font-medium">1.051.15</span> (simple paths, long runs closer to 1.05; tiny
@ -171,4 +171,3 @@ export default function Page() {
</ToolShell> </ToolShell>
); );
} }

View file

@ -18,33 +18,10 @@ function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v)); return Math.max(lo, Math.min(hi, v));
} }
/** Default curve parameters based on rated power (very rough, editable). */ /** Circular spot-area ratio; pi/4 cancels. */
function defaultCurveForRatedW(W: number) { function areaFactorFromDiameter(spotSrc: number, spotDst: number) {
// Peak frequency guess (kHz). Tune these to your hardware fleet. if (spotSrc <= 0 || spotDst <= 0) return 1;
let fPeak = 50; const r = spotDst / spotSrc;
if (W <= 35) fPeak = 25;
else if (W <= 60) fPeak = 50;
else if (W <= 90) fPeak = 75;
else fPeak = 100;
// Log-normal width parameter (dimensionless). Smaller = narrower peak.
const sigma = 0.35;
return { fPeak, sigma };
}
/** Log-normal shaped efficiency curve normalized to 1 at fPeak. */
function etaOfF(f_kHz: number, fPeak_kHz: number, sigma: number) {
const f = Math.max(f_kHz, 0.1);
const r = Math.log(f / Math.max(fPeak_kHz, 0.1));
const eta = Math.exp(-0.5 * (r / Math.max(sigma, 0.05)) ** 2);
// Keep within [0.1, 1] to avoid absurd zeros; adjust if you want tails to hit 0.
return clamp(eta, 0.1, 1);
}
/** Area factor from field (proxy for spot area scaling) */
function areaFactorFromField(fieldSrc: number, fieldDst: number) {
if (fieldSrc <= 0 || fieldDst <= 0) return 1;
const r = fieldDst / fieldSrc;
return r * r; return r * r;
} }
@ -59,7 +36,7 @@ export default function Page() {
const [hSrc, setHSrc] = useState('0.1'); // mm (raster line spacing) const [hSrc, setHSrc] = useState('0.1'); // mm (raster line spacing)
const [fSrc, setFSrc] = useState('30'); // kHz const [fSrc, setFSrc] = useState('30'); // kHz
const [tauSrc, setTauSrc] = useState('100'); // ns pulse width const [tauSrc, setTauSrc] = useState('100'); // ns pulse width
const [fieldSrc, setFieldSrc] = useState('110'); // mm const [fieldSrc, setFieldSrc] = useState('60'); // µm, 1/e² spot diameter
// DEST machine/lens // DEST machine/lens
const [wDst, setWDst] = useState('50'); // rated W const [wDst, setWDst] = useState('50'); // rated W
@ -67,16 +44,7 @@ export default function Page() {
const [hDst, setHDst] = useState('0.1'); // mm const [hDst, setHDst] = useState('0.1'); // mm
const [fDst, setFDst] = useState('30'); // kHz const [fDst, setFDst] = useState('30'); // kHz
const [tauDst, setTauDst] = useState('100'); // ns const [tauDst, setTauDst] = useState('100'); // ns
const [fieldDst, setFieldDst] = useState('70'); // mm const [fieldDst, setFieldDst] = useState('40'); // µm
// Curve tuning / advanced
const [advanced, setAdvanced] = useState(false);
const srcDefaults = defaultCurveForRatedW(num(wSrc, 50));
const dstDefaults = defaultCurveForRatedW(num(wDst, 50));
const [fPeakSrc, setFPeakSrc] = useState(String(srcDefaults.fPeak));
const [sigmaSrc, setSigmaSrc] = useState(String(srcDefaults.sigma));
const [fPeakDst, setFPeakDst] = useState(String(dstDefaults.fPeak));
const [sigmaDst, setSigmaDst] = useState(String(dstDefaults.sigma));
// Prefer adjusting speed/freq instead of exceeding 100% power // Prefer adjusting speed/freq instead of exceeding 100% power
const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true); const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true);
@ -91,21 +59,11 @@ export default function Page() {
const h2 = Math.max(num(hDst, 0), 0.000001); const h2 = Math.max(num(hDst, 0), 0.000001);
const f1k = Math.max(num(fSrc, 0), 0.1); const f1k = Math.max(num(fSrc, 0), 0.1);
const f2k = Math.max(num(fDst, 0), 0.1); const f2k = Math.max(num(fDst, 0), 0.1);
const tau1_ns = Math.max(num(tauSrc, 0), 0.1);
const tau2_ns = Math.max(num(tauDst, 0), 0.1); const tau2_ns = Math.max(num(tauDst, 0), 0.1);
const aFac = areaFactorFromField(num(fieldSrc, 0), num(fieldDst, 0)); const aFac = areaFactorFromDiameter(num(fieldSrc, 0), num(fieldDst, 0));
const fpk1 = Math.max(num(fPeakSrc, defaultCurveForRatedW(W1).fPeak), 0.1);
const sig1 = Math.max(num(sigmaSrc, defaultCurveForRatedW(W1).sigma), 0.05);
const fpk2 = Math.max(num(fPeakDst, defaultCurveForRatedW(W2).fPeak), 0.1);
const sig2 = Math.max(num(sigmaDst, defaultCurveForRatedW(W2).sigma), 0.05);
// Efficiency factors (0..1)
const eta1 = etaOfF(f1k, fpk1, sig1);
const eta2 = etaOfF(f2k, fpk2, sig2);
// Effective average power (W) after frequency efficiency // Effective average power (W) after frequency efficiency
const P1eff = W1 * p1 * eta1; const P1eff = W1 * p1;
let p2Frac = p1; // destination power fraction (0..1) let p2Frac = p1; // destination power fraction (0..1)
let suggestedSpeed: number | undefined; let suggestedSpeed: number | undefined;
@ -114,7 +72,7 @@ export default function Page() {
// Helper: compute required P2eff for each match, then map to power% // Helper: compute required P2eff for each match, then map to power%
const powerPercentFromEff = (P2effReq: number) => { const powerPercentFromEff = (P2effReq: number) => {
// P2eff = W2 * p2 * eta2 => p2 = P2eff / (W2*eta2) // P2eff = W2 * p2 * eta2 => p2 = P2eff / (W2*eta2)
return P2effReq / (W2 * eta2); return P2effReq / W2;
}; };
if (mode === 'vector') { if (mode === 'vector') {
@ -122,7 +80,7 @@ export default function Page() {
const P2effReq = P1eff * (v2 / v1); const P2effReq = P1eff * (v2 / v1);
p2Frac = powerPercentFromEff(P2effReq); p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) { if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1 suggestedSpeed = v1 * W2 / (W1 * p1); // from p2<=1
p2Frac = 1; p2Frac = 1;
} }
} else if (mode === 'raster') { } else if (mode === 'raster') {
@ -130,7 +88,7 @@ export default function Page() {
const P2effReq = P1eff * ((v2 * h2) / (v1 * h1)); const P2effReq = P1eff * ((v2 * h2) / (v1 * h1));
p2Frac = powerPercentFromEff(P2effReq); p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) { if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1); suggestedSpeed = v1 * W2 * (h1 / h2) / (W1 * p1);
p2Frac = 1; p2Frac = 1;
} }
} else if (mode === 'irradiance') { } else if (mode === 'irradiance') {
@ -149,7 +107,7 @@ export default function Page() {
if (preferSpeedAdjust && p2Frac > 1) { if (preferSpeedAdjust && p2Frac > 1) {
// Suggest lowering f2 to keep p2<=1: P2eff_max = W2*eta2*1 // Suggest lowering f2 to keep p2<=1: P2eff_max = W2*eta2*1
// f2_req = P2eff_max / Ep1 // f2_req = P2eff_max / Ep1
const f2_req = (W2 * eta2) / Ep1; // Hz const f2_req = W2 / Ep1; // Hz
suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1); suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1);
p2Frac = 1; p2Frac = 1;
} }
@ -157,7 +115,7 @@ export default function Page() {
// Compute pulse metrics (for display) using **destination** settings // Compute pulse metrics (for display) using **destination** settings
const p2Clamped = clamp(p2Frac, 0, 2); const p2Clamped = clamp(p2Frac, 0, 2);
const P2eff = W2 * p2Clamped * eta2; const P2eff = W2 * p2Clamped;
const f2Hz = f2k * 1e3; const f2Hz = f2k * 1e3;
const tau2_s = tau2_ns * 1e-9; const tau2_s = tau2_ns * 1e-9;
const Ep2 = P2eff / f2Hz; // J const Ep2 = P2eff / f2Hz; // J
@ -167,8 +125,6 @@ export default function Page() {
p2Percent: clamp(p2Clamped * 100, 0, 200), p2Percent: clamp(p2Clamped * 100, 0, 200),
suggestedSpeed, suggestedSpeed,
suggestedFreq_kHz, suggestedFreq_kHz,
eta1,
eta2,
P1eff, P1eff,
P2eff, P2eff,
Ep2, Ep2,
@ -177,13 +133,13 @@ export default function Page() {
}; };
}, [ }, [
mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst, mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst,
fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst, fieldSrc, fieldDst, preferSpeedAdjust,
]); ]);
return ( return (
<ToolShell <ToolShell
title="Power, Frequency & Lens Scaler" title="Power, Frequency & Lens Scaler"
description="Match settings across different lasers and lenses using effective power with a frequency efficiency curve. Includes pulse width to report pulse energy and peak power." description="Translate a starting recipe by matching energy per length, energy per area, spot irradiance, or pulse energy."
> >
<Card className="mb-6"> <Card className="mb-6">
<CardHeader> <CardHeader>
@ -197,7 +153,7 @@ export default function Page() {
<SelectContent> <SelectContent>
<SelectItem value="vector">Vector: Energy per length (J/mm)</SelectItem> <SelectItem value="vector">Vector: Energy per length (J/mm)</SelectItem>
<SelectItem value="raster">Raster: Energy per area (J/mm²)</SelectItem> <SelectItem value="raster">Raster: Energy per area (J/mm²)</SelectItem>
<SelectItem value="irradiance">Irradiance: W/mm² (spot/field)</SelectItem> <SelectItem value="irradiance">Spot irradiance: W/mm²</SelectItem>
<SelectItem value="pulse">Pulse energy: J (fiber)</SelectItem> <SelectItem value="pulse">Pulse energy: J (fiber)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -247,32 +203,11 @@ export default function Page() {
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" /> <Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
</div> </div>
<div> <div>
<Label className="text-sm">Lens field size (mm)</Label> <Label className="text-sm">1/e² spot diameter (µm)</Label>
<Input value={fieldSrc} onChange={(e) => setFieldSrc(e.target.value)} inputMode="decimal" /> <Input value={fieldSrc} onChange={(e) => setFieldSrc(e.target.value)} inputMode="decimal" />
</div> </div>
</CardContent> </CardContent>
<CardContent className="pt-0">
<button
className="text-xs underline text-muted-foreground"
onClick={() => setAdvanced((s) => !s)}
>
{advanced ? 'Hide' : 'Show'} advanced frequency curve
</button>
<div className={cn('mt-3 grid gap-4 md:grid-cols-3', advanced ? 'block' : 'hidden')}>
<div>
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
<Input value={fPeakSrc} onChange={(e) => setFPeakSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Curve width σ (log-normal)</Label>
<Input value={sigmaSrc} onChange={(e) => setSigmaSrc(e.target.value)} inputMode="decimal" />
</div>
<div className="flex items-end text-xs text-muted-foreground">
η(f) is log-normal; 1.0 at fₚ, rolls off by σ.
</div>
</div>
</CardContent>
</Card> </Card>
{/* Destination */} {/* Destination */}
@ -302,26 +237,11 @@ export default function Page() {
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" /> <Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
</div> </div>
<div> <div>
<Label className="text-sm">Lens field size (mm)</Label> <Label className="text-sm">1/e² spot diameter (µm)</Label>
<Input value={fieldDst} onChange={(e) => setFieldDst(e.target.value)} inputMode="decimal" /> <Input value={fieldDst} onChange={(e) => setFieldDst(e.target.value)} inputMode="decimal" />
</div> </div>
</CardContent> </CardContent>
<CardContent className={cn('pt-0', advanced ? 'block' : 'hidden')}>
<div className="mt-3 grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
<Input value={fPeakDst} onChange={(e) => setFPeakDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Curve width σ (log-normal)</Label>
<Input value={sigmaDst} onChange={(e) => setSigmaDst(e.target.value)} inputMode="decimal" />
</div>
<div className="flex items-end text-xs text-muted-foreground">
Adjust if you know your machines real powerfrequency curve.
</div>
</div>
</CardContent>
</Card> </Card>
{/* Result */} {/* Result */}
@ -348,11 +268,7 @@ export default function Page() {
</p> </p>
)} )}
<div className="mt-3 grid gap-2 md:grid-cols-3 text-sm"> <div className="mt-3 grid gap-2 md:grid-cols-2 text-sm">
<div>
<div className="text-muted-foreground">η(f) source / dest</div>
<div className="font-medium">{result.eta1.toFixed(3)} / {result.eta2.toFixed(3)}</div>
</div>
<div> <div>
<div className="text-muted-foreground">Dest pulse energy</div> <div className="text-muted-foreground">Dest pulse energy</div>
<div className="font-medium"> <div className="font-medium">
@ -366,13 +282,12 @@ export default function Page() {
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
Assumptions: Effective power includes a frequency efficiency factor η(f). Peak power uses a rectangular pulse Assumptions: displayed power percentage scales average output linearly, spots are circular,
approximation (shape factor 1). For real MOPA sources, pulse shape and and peak power uses a rectangular pulse approximation. Confirm the result with a low-power test;
true powerfrequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves. real sources can have model-specific power limits versus frequency and pulse width.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</ToolShell> </ToolShell>
); );
} }

View file

@ -21,16 +21,19 @@ export default function Page() {
const dUm = num(spotUm); // µm const dUm = num(spotUm); // µm
if (v <= 0 || f <= 0 || dUm <= 0) { if (v <= 0 || f <= 0 || dUm <= 0) {
return { spacingUm: 0, spacingMm: 0, overlapPct: 0, pulsesPerMm: 0 }; return { spacingUm: 0, spacingMm: 0, overlapPct: 0, gapPct: 0, pulsesPerMm: 0, pulsesPerSpot: 0 };
} }
// distance per pulse // distance per pulse
const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 1000) const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 1000)
const spacingMm = spacingUm / 1000; const spacingMm = spacingUm / 1000;
const overlapPct = Math.max(0, Math.min(100, 100 * (1 - spacingUm / dUm))); const signedOverlapPct = 100 * (1 - spacingUm / dUm);
const overlapPct = Math.max(0, signedOverlapPct);
const gapPct = Math.max(0, -signedOverlapPct);
const pulsesPerMm = (f * 1000) / v; const pulsesPerMm = (f * 1000) / v;
const pulsesPerSpot = dUm / spacingUm;
return { spacingUm, spacingMm, overlapPct, pulsesPerMm }; return { spacingUm, spacingMm, overlapPct, gapPct, pulsesPerMm, pulsesPerSpot };
}, [speed, freq, spotUm]); }, [speed, freq, spotUm]);
return ( return (
@ -68,20 +71,24 @@ export default function Page() {
<div> <div>
<div className="text-sm text-muted-foreground">Overlap</div> <div className="text-sm text-muted-foreground">Overlap</div>
<div className="text-lg">{result.overlapPct.toFixed(1)}%</div> <div className="text-lg">{result.overlapPct.toFixed(1)}%</div>
{result.gapPct > 0 && (
<div className="text-xs text-amber-600">{result.gapPct.toFixed(1)}% gap between pulses</div>
)}
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Pulses per mm</div> <div className="text-sm text-muted-foreground">Pulses per mm</div>
<div className="text-lg">{result.pulsesPerMm.toFixed(1)}</div> <div className="text-lg">{result.pulsesPerMm.toFixed(1)}</div>
</div> </div>
<div> <div>
<div className="text-sm text-muted-foreground">Rule of thumb</div> <div className="text-sm text-muted-foreground">Pulses per spot diameter</div>
<div className="text-xs"> <div className="text-lg">{result.pulsesPerSpot.toFixed(2)}</div>
6080% overlap is common for marking; deeper engraving often higher.
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
Geometric overlap along the scan direction only. It does not predict material response;
pulse energy, spot profile, hatch spacing, and thermal accumulation also matter.
</p>
</ToolShell> </ToolShell>
); );
} }