Compare commits
2 commits
b59e8e54bb
...
305ea41ad3
| Author | SHA1 | Date | |
|---|---|---|---|
| 305ea41ad3 | |||
| 0855724cb0 |
10 changed files with 129 additions and 168 deletions
|
|
@ -9,11 +9,13 @@ const UtilitySwitcher = dynamic(() => import("@/components/portal/UtilitySwitche
|
|||
|
||||
export default function UtilitiesClient() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Utilities</h2>
|
||||
<UtilitySwitcher />
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-1 font-mono text-xs uppercase tracking-[0.2em] text-accent">~/utilities</div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Workshop utilities</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Calculators and production tools in one workspace.</p>
|
||||
</div>
|
||||
<UtilitySwitcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,3 +64,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Dense, terminal-inspired calculator workspace without nested floating cards. */
|
||||
.laser-tool-workspace [class~="rounded-lg"][class~="border"] {
|
||||
@apply rounded-lg border-border/60 bg-background/20 shadow-none;
|
||||
}
|
||||
|
||||
.laser-tool-workspace [class~="rounded-lg"][class~="border"] > div:first-child {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
|
||||
.laser-tool-workspace [class~="rounded-lg"][class~="border"] > div:not(:first-child) {
|
||||
@apply px-4 pb-4;
|
||||
}
|
||||
|
||||
.laser-tool-workspace input,
|
||||
.laser-tool-workspace select,
|
||||
.laser-tool-workspace button[role="combobox"] {
|
||||
@apply border-border/70 bg-background/70 shadow-none transition-colors focus:border-accent/70 focus:ring-1 focus:ring-accent/30;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { useMemo } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calculator, ChevronDown } from "lucide-react";
|
||||
import { TOOLKIT_TABS } from "@/components/utilities/laser-toolkit/registry";
|
||||
|
||||
export default function LaserToolkitSwitcher({ basePath = "/portal/utilities" }: { basePath?: string }) {
|
||||
|
|
@ -34,23 +35,38 @@ export default function LaserToolkitSwitcher({ basePath = "/portal/utilities" }:
|
|||
const ActiveCmp = active.component;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="border-b border-border/60 bg-background/30 p-3">
|
||||
<div className="relative sm:hidden">
|
||||
<Calculator className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-accent" />
|
||||
<select
|
||||
aria-label="Select laser calculator"
|
||||
value={activeKey}
|
||||
onChange={(e) => setTab(e.target.value)}
|
||||
className="h-10 w-full appearance-none rounded-lg border-border/70 bg-card pl-10 pr-9 text-sm"
|
||||
>
|
||||
{TOOLKIT_TABS.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 opacity-50" />
|
||||
</div>
|
||||
<div className="hidden flex-wrap gap-1 sm:flex">
|
||||
{TOOLKIT_TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm",
|
||||
activeKey === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
"rounded-md border border-transparent px-3 py-1.5 text-xs transition-colors",
|
||||
activeKey === t.key
|
||||
? "border-border/60 bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted/30 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="laser-tool-workspace">
|
||||
<ActiveCmp />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, ExternalLink, TerminalSquare } from "lucide-react";
|
||||
|
||||
type Item = {
|
||||
key: string; // used in ?t=
|
||||
|
|
@ -216,9 +217,13 @@ export default function UtilitySwitcher() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* top buttons unchanged */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<aside className="h-fit overflow-hidden rounded-xl border border-border/70 bg-card/40 lg:sticky lg:top-4">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2.5 text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<TerminalSquare className="h-3.5 w-3.5 text-accent" />
|
||||
Select utility
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 p-1.5 sm:grid-cols-3 lg:grid-cols-1">
|
||||
{ITEMS.map((it) => {
|
||||
const isInline = Boolean(it.component);
|
||||
const external = !isInline && isExternalHref(it.href);
|
||||
|
|
@ -235,10 +240,10 @@ export default function UtilitySwitcher() {
|
|||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
|
||||
"group flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-xs transition-all sm:text-sm",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
? "bg-accent/15 text-foreground shadow-[inset_2px_0_0_hsl(var(--accent))]"
|
||||
: "text-muted-foreground hover:bg-muted/30 hover:text-foreground"
|
||||
)}
|
||||
title={it.note || it.label}
|
||||
>
|
||||
|
|
@ -249,27 +254,27 @@ export default function UtilitySwitcher() {
|
|||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm border object-cover"
|
||||
className="h-5 w-5 shrink-0 rounded border border-border/60 object-cover opacity-80"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span className="truncate">{it.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{it.label}</span>
|
||||
|
||||
{!isInline && external && (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
new tab
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0 opacity-50" />
|
||||
)}
|
||||
{isInline && <ChevronRight className="hidden h-3.5 w-3.5 shrink-0 opacity-0 transition group-hover:opacity-50 lg:block" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div></aside>
|
||||
|
||||
{/* ⛔️ removed the old border/padding frame here */}
|
||||
<Panel item={activeItem} />
|
||||
<section className="min-w-0 overflow-hidden rounded-xl border border-border/70 bg-card/20 shadow-sm">
|
||||
<Panel item={activeItem} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToolShellProps = {
|
||||
|
|
@ -19,31 +18,23 @@ export default function ToolShell({
|
|||
subtitle,
|
||||
className,
|
||||
children,
|
||||
backHref = "/laser-toolkit",
|
||||
backLabel = "Back to Toolkit",
|
||||
}: ToolShellProps) {
|
||||
const desc = description ?? subtitle;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-5xl px-4 py-6", className)}>
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div className={cn("w-full p-4 sm:p-5", className)}>
|
||||
<div className="mb-4 flex items-start justify-between gap-4 border-b border-border/50 pb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<div className="mb-1 text-[10px] uppercase tracking-[0.18em] text-accent">laser.calc</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{desc ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{desc}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{backLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function num(v: string) {
|
|||
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() {
|
||||
const [lambdaNm, setLambdaNm] = useState("1064"); // nm (default fiber)
|
||||
const [focalMm, setFocalMm] = useState("160"); // mm
|
||||
|
|
@ -21,9 +21,9 @@ export default function Page() {
|
|||
const lamUm = num(lambdaNm) / 1000; // convert nm -> µm
|
||||
const f = num(focalMm);
|
||||
const D = num(beamDm);
|
||||
const M2 = Math.max(1, num(m2));
|
||||
if (lamUm <= 0 || f <= 0 || D <= 0) return 0;
|
||||
return 1.27 * M2 * lamUm * (f / D);
|
||||
const M2 = num(m2);
|
||||
if (lamUm <= 0 || f <= 0 || D <= 0 || M2 < 1) return 0;
|
||||
return (4 / Math.PI) * M2 * lamUm * (f / D);
|
||||
}, [lambdaNm, focalMm, beamDm, m2]);
|
||||
|
||||
const dMm = dUm / 1000;
|
||||
|
|
@ -71,7 +71,7 @@ export default function Page() {
|
|||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<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-xs text-muted-foreground">{dUm.toFixed(2)} µm</div>
|
||||
</div>
|
||||
|
|
@ -82,7 +82,10 @@ export default function Page() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,11 +29,12 @@ export default function Page() {
|
|||
setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : "");
|
||||
}
|
||||
|
||||
const overlap = useMemo(() => {
|
||||
const coverage = useMemo(() => {
|
||||
const d = num(spotUm);
|
||||
const g = num(gapUm);
|
||||
if (d <= 0 || g <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, 100 * (1 - g / d)));
|
||||
if (d <= 0 || g <= 0) return { overlap: 0, uncovered: 0 };
|
||||
const signed = 100 * (1 - g / d);
|
||||
return { overlap: Math.max(0, signed), uncovered: Math.max(0, -signed) };
|
||||
}, [spotUm, gapUm]);
|
||||
|
||||
const gapMm = (num(gapUm) / 1000) || 0;
|
||||
|
|
@ -68,7 +69,10 @@ export default function Page() {
|
|||
<CardContent className="grid gap-3 sm:grid-cols-4">
|
||||
<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 className="text-sm text-muted-foreground">Gap</div>
|
||||
|
|
@ -83,10 +87,10 @@ export default function Page() {
|
|||
<div>
|
||||
<div className="text-sm text-muted-foreground">From LPI</div>
|
||||
<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 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>
|
||||
</CardContent>
|
||||
|
|
@ -94,4 +98,3 @@ export default function Page() {
|
|||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
const gapMm = 25.4 / D;
|
||||
const gapUm = gapMm * 1000;
|
||||
const rows = h / gapMm;
|
||||
const rows = Math.max(1, Math.ceil(h / gapMm));
|
||||
const t = rows * (w / v) * p * k;
|
||||
return { t, gapMm, gapUm, rows };
|
||||
} else {
|
||||
|
|
@ -159,7 +159,7 @@ export default function Page() {
|
|||
{/* Footnote */}
|
||||
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
|
||||
<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.{" "}
|
||||
<span className="font-semibold">Typical values:</span> Vector cuts/marks{" "}
|
||||
<span className="font-medium">1.05–1.15</span> (simple paths, long runs closer to 1.05; tiny
|
||||
|
|
@ -171,4 +171,3 @@ export default function Page() {
|
|||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,33 +18,10 @@ function clamp(v: number, lo: number, hi: number) {
|
|||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
/** Default curve parameters based on rated power (very rough, editable). */
|
||||
function defaultCurveForRatedW(W: number) {
|
||||
// Peak frequency guess (kHz). Tune these to your hardware fleet.
|
||||
let fPeak = 50;
|
||||
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;
|
||||
/** Circular spot-area ratio; pi/4 cancels. */
|
||||
function areaFactorFromDiameter(spotSrc: number, spotDst: number) {
|
||||
if (spotSrc <= 0 || spotDst <= 0) return 1;
|
||||
const r = spotDst / spotSrc;
|
||||
return r * r;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +36,7 @@ export default function Page() {
|
|||
const [hSrc, setHSrc] = useState('0.1'); // mm (raster line spacing)
|
||||
const [fSrc, setFSrc] = useState('30'); // kHz
|
||||
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
|
||||
const [wDst, setWDst] = useState('50'); // rated W
|
||||
|
|
@ -67,16 +44,7 @@ export default function Page() {
|
|||
const [hDst, setHDst] = useState('0.1'); // mm
|
||||
const [fDst, setFDst] = useState('30'); // kHz
|
||||
const [tauDst, setTauDst] = useState('100'); // ns
|
||||
const [fieldDst, setFieldDst] = useState('70'); // mm
|
||||
|
||||
// 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));
|
||||
const [fieldDst, setFieldDst] = useState('40'); // µm
|
||||
|
||||
// Prefer adjusting speed/freq instead of exceeding 100% power
|
||||
const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true);
|
||||
|
|
@ -91,21 +59,11 @@ export default function Page() {
|
|||
const h2 = Math.max(num(hDst, 0), 0.000001);
|
||||
const f1k = Math.max(num(fSrc, 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 aFac = areaFactorFromField(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);
|
||||
const aFac = areaFactorFromDiameter(num(fieldSrc, 0), num(fieldDst, 0));
|
||||
|
||||
// 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 suggestedSpeed: number | undefined;
|
||||
|
|
@ -114,7 +72,7 @@ export default function Page() {
|
|||
// Helper: compute required P2eff for each match, then map to power%
|
||||
const powerPercentFromEff = (P2effReq: number) => {
|
||||
// P2eff = W2 * p2 * eta2 => p2 = P2eff / (W2*eta2)
|
||||
return P2effReq / (W2 * eta2);
|
||||
return P2effReq / W2;
|
||||
};
|
||||
|
||||
if (mode === 'vector') {
|
||||
|
|
@ -122,7 +80,7 @@ export default function Page() {
|
|||
const P2effReq = P1eff * (v2 / v1);
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1
|
||||
suggestedSpeed = v1 * W2 / (W1 * p1); // from p2<=1
|
||||
p2Frac = 1;
|
||||
}
|
||||
} else if (mode === 'raster') {
|
||||
|
|
@ -130,7 +88,7 @@ export default function Page() {
|
|||
const P2effReq = P1eff * ((v2 * h2) / (v1 * h1));
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1);
|
||||
suggestedSpeed = v1 * W2 * (h1 / h2) / (W1 * p1);
|
||||
p2Frac = 1;
|
||||
}
|
||||
} else if (mode === 'irradiance') {
|
||||
|
|
@ -149,7 +107,7 @@ export default function Page() {
|
|||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
// Suggest lowering f2 to keep p2<=1: P2eff_max = W2*eta2*1
|
||||
// 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);
|
||||
p2Frac = 1;
|
||||
}
|
||||
|
|
@ -157,7 +115,7 @@ export default function Page() {
|
|||
|
||||
// Compute pulse metrics (for display) using **destination** settings
|
||||
const p2Clamped = clamp(p2Frac, 0, 2);
|
||||
const P2eff = W2 * p2Clamped * eta2;
|
||||
const P2eff = W2 * p2Clamped;
|
||||
const f2Hz = f2k * 1e3;
|
||||
const tau2_s = tau2_ns * 1e-9;
|
||||
const Ep2 = P2eff / f2Hz; // J
|
||||
|
|
@ -167,8 +125,6 @@ export default function Page() {
|
|||
p2Percent: clamp(p2Clamped * 100, 0, 200),
|
||||
suggestedSpeed,
|
||||
suggestedFreq_kHz,
|
||||
eta1,
|
||||
eta2,
|
||||
P1eff,
|
||||
P2eff,
|
||||
Ep2,
|
||||
|
|
@ -177,13 +133,13 @@ export default function Page() {
|
|||
};
|
||||
}, [
|
||||
mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst,
|
||||
fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst,
|
||||
fieldSrc, fieldDst, preferSpeedAdjust,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolShell
|
||||
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">
|
||||
<CardHeader>
|
||||
|
|
@ -197,7 +153,7 @@ export default function Page() {
|
|||
<SelectContent>
|
||||
<SelectItem value="vector">Vector: Energy per length (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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -247,32 +203,11 @@ export default function Page() {
|
|||
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
|
||||
</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" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Destination */}
|
||||
|
|
@ -302,26 +237,11 @@ export default function Page() {
|
|||
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
|
||||
</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" />
|
||||
</div>
|
||||
</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 machine’s real power–frequency curve.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
|
|
@ -348,11 +268,7 @@ export default function Page() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-3 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 className="mt-3 grid gap-2 md:grid-cols-2 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Dest pulse energy</div>
|
||||
<div className="font-medium">
|
||||
|
|
@ -366,13 +282,12 @@ export default function Page() {
|
|||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Assumptions: Effective power includes a frequency efficiency factor η(f). Peak power uses a rectangular pulse
|
||||
approximation (shape factor ≈ 1). For real MOPA sources, pulse shape and
|
||||
true power–frequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves.
|
||||
Assumptions: displayed power percentage scales average output linearly, spots are circular,
|
||||
and peak power uses a rectangular pulse approximation. Confirm the result with a low-power test;
|
||||
real sources can have model-specific power limits versus frequency and pulse width.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,16 +21,19 @@ export default function Page() {
|
|||
const dUm = num(spotUm); // µm
|
||||
|
||||
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
|
||||
const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 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 pulsesPerSpot = dUm / spacingUm;
|
||||
|
||||
return { spacingUm, spacingMm, overlapPct, pulsesPerMm };
|
||||
return { spacingUm, spacingMm, overlapPct, gapPct, pulsesPerMm, pulsesPerSpot };
|
||||
}, [speed, freq, spotUm]);
|
||||
|
||||
return (
|
||||
|
|
@ -68,20 +71,24 @@ export default function Page() {
|
|||
<div>
|
||||
<div className="text-sm text-muted-foreground">Overlap</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 className="text-sm text-muted-foreground">Pulses per mm</div>
|
||||
<div className="text-lg">{result.pulsesPerMm.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Rule of thumb</div>
|
||||
<div className="text-xs">
|
||||
60–80% overlap is common for marking; deeper engraving often higher.
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Pulses per spot diameter</div>
|
||||
<div className="text-lg">{result.pulsesPerSpot.toFixed(2)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue