diff --git a/app/app/portal/utilities/Client.tsx b/app/app/portal/utilities/Client.tsx index 5c58e3a..0e8e1e1 100644 --- a/app/app/portal/utilities/Client.tsx +++ b/app/app/portal/utilities/Client.tsx @@ -9,13 +9,11 @@ const UtilitySwitcher = dynamic(() => import("@/components/portal/UtilitySwitche export default function UtilitiesClient() { return ( -
-
-
~/utilities
-

Workshop utilities

-

Calculators and production tools in one workspace.

-
+
+
+

Utilities

+
); } diff --git a/app/app/styles/globals.css b/app/app/styles/globals.css index 9ec318d..4841171 100644 --- a/app/app/styles/globals.css +++ b/app/app/styles/globals.css @@ -64,23 +64,3 @@ } } -@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; - } -} diff --git a/app/components/portal/LaserToolkitSwitcher.tsx b/app/components/portal/LaserToolkitSwitcher.tsx index 3448b7f..69ddfda 100644 --- a/app/components/portal/LaserToolkitSwitcher.tsx +++ b/app/components/portal/LaserToolkitSwitcher.tsx @@ -4,7 +4,6 @@ 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 }) { @@ -35,38 +34,23 @@ export default function LaserToolkitSwitcher({ basePath = "/portal/utilities" }: const ActiveCmp = active.component; return ( -
-
-
- - - -
-
+
+
{TOOLKIT_TABS.map(t => ( ))} -
+
-
+
diff --git a/app/components/portal/UtilitySwitcher.tsx b/app/components/portal/UtilitySwitcher.tsx index 86260aa..36faf0e 100644 --- a/app/components/portal/UtilitySwitcher.tsx +++ b/app/components/portal/UtilitySwitcher.tsx @@ -4,7 +4,6 @@ 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= @@ -217,13 +216,9 @@ export default function UtilitySwitcher() { }, []); return ( -
- +
-
- -
+ {/* ⛔️ removed the old border/padding frame here */} +
); } diff --git a/app/components/toolkit/ToolShell.tsx b/app/components/toolkit/ToolShell.tsx index 28d134d..3611a9d 100644 --- a/app/components/toolkit/ToolShell.tsx +++ b/app/components/toolkit/ToolShell.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { cn } from "@/lib/utils"; type ToolShellProps = { @@ -18,23 +19,31 @@ export default function ToolShell({ subtitle, className, children, + backHref = "/laser-toolkit", + backLabel = "Back to Toolkit", }: ToolShellProps) { const desc = description ?? subtitle; return ( -
-
+
+
-
laser.calc
-

{title}

+

{title}

{desc ? (

{desc}

) : null}
+ + {backLabel} +
{children}
); } + diff --git a/app/components/utilities/laser-toolkit/beam-spot-size/page.tsx b/app/components/utilities/laser-toolkit/beam-spot-size/page.tsx index a91bb90..6874057 100644 --- a/app/components/utilities/laser-toolkit/beam-spot-size/page.tsx +++ b/app/components/utilities/laser-toolkit/beam-spot-size/page.tsx @@ -10,7 +10,7 @@ function num(v: string) { return Number.isFinite(n) ? n : 0; } -// Focused Gaussian 1/e² diameter: d = 4 M² λ f / (π D). +// Spot diameter (µm) ≈ 1.27 * M² * λ(µm) * f(mm) / D(mm) 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 = num(m2); - if (lamUm <= 0 || f <= 0 || D <= 0 || M2 < 1) return 0; - return (4 / Math.PI) * M2 * lamUm * (f / D); + const M2 = Math.max(1, num(m2)); + if (lamUm <= 0 || f <= 0 || D <= 0) return 0; + return 1.27 * M2 * lamUm * (f / D); }, [lambdaNm, focalMm, beamDm, m2]); const dMm = dUm / 1000; @@ -71,7 +71,7 @@ export default function Page() {
-
1/e² spot diameter
+
Spot diameter
{dMm.toFixed(4)} mm
{dUm.toFixed(2)} µm
@@ -82,10 +82,7 @@ export default function Page() {
-

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

); } + diff --git a/app/components/utilities/laser-toolkit/hatch-overlap/page.tsx b/app/components/utilities/laser-toolkit/hatch-overlap/page.tsx index 34470c8..dbad915 100644 --- a/app/components/utilities/laser-toolkit/hatch-overlap/page.tsx +++ b/app/components/utilities/laser-toolkit/hatch-overlap/page.tsx @@ -29,12 +29,11 @@ export default function Page() { setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : ""); } - const coverage = useMemo(() => { + const overlap = useMemo(() => { const d = num(spotUm); const g = num(gapUm); - 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) }; + if (d <= 0 || g <= 0) return 0; + return Math.max(0, Math.min(100, 100 * (1 - g / d))); }, [spotUm, gapUm]); const gapMm = (num(gapUm) / 1000) || 0; @@ -69,10 +68,7 @@ export default function Page() {
Overlap
-
{coverage.overlap.toFixed(1)}%
- {coverage.uncovered > 0 && ( -
{coverage.uncovered.toFixed(1)}% uncovered gap
- )} +
{overlap.toFixed(1)}%
Gap
@@ -87,10 +83,10 @@ export default function Page() {
From LPI
- {num(lpi) > 0 ? (UM_PER_INCH / num(lpi) / 1000).toFixed(4) : "0.0000"} mm + {(UM_PER_INCH / Math.max(1, num(lpi)) / 1000).toFixed(4)} mm
- {num(lpi) > 0 ? (UM_PER_INCH / num(lpi)).toFixed(1) : "0.0"} µm + {(UM_PER_INCH / Math.max(1, num(lpi))).toFixed(1)} µm
@@ -98,3 +94,4 @@ export default function Page() { ); } + diff --git a/app/components/utilities/laser-toolkit/job-time-estimator/page.tsx b/app/components/utilities/laser-toolkit/job-time-estimator/page.tsx index ec28886..24a1fd2 100644 --- a/app/components/utilities/laser-toolkit/job-time-estimator/page.tsx +++ b/app/components/utilities/laser-toolkit/job-time-estimator/page.tsx @@ -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 = Math.max(1, Math.ceil(h / gapMm)); + const rows = h / gapMm; const t = rows * (w / v) * p * k; return { t, gapMm, gapUm, rows }; } else { @@ -159,7 +159,7 @@ export default function Page() { {/* Footnote */}

Overhead factor* accounts for real-world slowdowns: - acceleration/deceleration, jump moves, polygon delays, laser on/off timing, overscan, + acceleration/decelleration, jump moves, polygon delays, laser on/off timing, overscan, bidirectional settle time, and controller latency.{" "} Typical values: Vector cuts/marks{" "} 1.05–1.15 (simple paths, long runs closer to 1.05; tiny @@ -171,3 +171,4 @@ export default function Page() { ); } + diff --git a/app/components/utilities/laser-toolkit/power-lens-scaler/page.tsx b/app/components/utilities/laser-toolkit/power-lens-scaler/page.tsx index 3fdb31b..6113608 100644 --- a/app/components/utilities/laser-toolkit/power-lens-scaler/page.tsx +++ b/app/components/utilities/laser-toolkit/power-lens-scaler/page.tsx @@ -18,10 +18,33 @@ function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } -/** Circular spot-area ratio; pi/4 cancels. */ -function areaFactorFromDiameter(spotSrc: number, spotDst: number) { - if (spotSrc <= 0 || spotDst <= 0) return 1; - const r = spotDst / spotSrc; +/** 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; return r * r; } @@ -36,7 +59,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('60'); // µm, 1/e² spot diameter + const [fieldSrc, setFieldSrc] = useState('110'); // mm // DEST machine/lens const [wDst, setWDst] = useState('50'); // rated W @@ -44,7 +67,16 @@ 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('40'); // µm + 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)); // Prefer adjusting speed/freq instead of exceeding 100% power const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true); @@ -59,11 +91,21 @@ 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 = areaFactorFromDiameter(num(fieldSrc, 0), num(fieldDst, 0)); + 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); // Effective average power (W) after frequency efficiency - const P1eff = W1 * p1; + const P1eff = W1 * p1 * eta1; let p2Frac = p1; // destination power fraction (0..1) let suggestedSpeed: number | undefined; @@ -72,7 +114,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; + return P2effReq / (W2 * eta2); }; if (mode === 'vector') { @@ -80,7 +122,7 @@ export default function Page() { const P2effReq = P1eff * (v2 / v1); p2Frac = powerPercentFromEff(P2effReq); if (preferSpeedAdjust && p2Frac > 1) { - suggestedSpeed = v1 * W2 / (W1 * p1); // from p2<=1 + suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1 p2Frac = 1; } } else if (mode === 'raster') { @@ -88,7 +130,7 @@ export default function Page() { const P2effReq = P1eff * ((v2 * h2) / (v1 * h1)); p2Frac = powerPercentFromEff(P2effReq); if (preferSpeedAdjust && p2Frac > 1) { - suggestedSpeed = v1 * W2 * (h1 / h2) / (W1 * p1); + suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1); p2Frac = 1; } } else if (mode === 'irradiance') { @@ -107,7 +149,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 / Ep1; // Hz + const f2_req = (W2 * eta2) / Ep1; // Hz suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1); p2Frac = 1; } @@ -115,7 +157,7 @@ export default function Page() { // Compute pulse metrics (for display) using **destination** settings const p2Clamped = clamp(p2Frac, 0, 2); - const P2eff = W2 * p2Clamped; + const P2eff = W2 * p2Clamped * eta2; const f2Hz = f2k * 1e3; const tau2_s = tau2_ns * 1e-9; const Ep2 = P2eff / f2Hz; // J @@ -125,6 +167,8 @@ export default function Page() { p2Percent: clamp(p2Clamped * 100, 0, 200), suggestedSpeed, suggestedFreq_kHz, + eta1, + eta2, P1eff, P2eff, Ep2, @@ -133,13 +177,13 @@ export default function Page() { }; }, [ mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst, - fieldSrc, fieldDst, preferSpeedAdjust, + fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst, ]); return ( @@ -153,7 +197,7 @@ export default function Page() { Vector: Energy per length (J/mm) Raster: Energy per area (J/mm²) - Spot irradiance: W/mm² + Irradiance: W/mm² (spot/field) Pulse energy: J (fiber) @@ -203,11 +247,32 @@ export default function Page() { setHSrc(e.target.value)} inputMode="decimal" />

- + setFieldSrc(e.target.value)} inputMode="decimal" />
+ + +
+
+ + setFPeakSrc(e.target.value)} inputMode="decimal" /> +
+
+ + setSigmaSrc(e.target.value)} inputMode="decimal" /> +
+
+ η(f) is log-normal; 1.0 at fₚ, rolls off by σ. +
+
+
{/* Destination */} @@ -237,11 +302,26 @@ export default function Page() { setHDst(e.target.value)} inputMode="decimal" />
- + setFieldDst(e.target.value)} inputMode="decimal" />
+ +
+
+ + setFPeakDst(e.target.value)} inputMode="decimal" /> +
+
+ + setSigmaDst(e.target.value)} inputMode="decimal" /> +
+
+ Adjust if you know your machine’s real power–frequency curve. +
+
+
{/* Result */} @@ -268,7 +348,11 @@ export default function Page() {

)} -
+
+
+
η(f) source / dest
+
{result.eta1.toFixed(3)} / {result.eta2.toFixed(3)}
+
Dest pulse energy
@@ -282,12 +366,13 @@ export default function Page() {

- 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. + 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 fp and σ if you have vendor curves.

); } + diff --git a/app/components/utilities/laser-toolkit/pulse-overlap/page.tsx b/app/components/utilities/laser-toolkit/pulse-overlap/page.tsx index fb443eb..e8ce13c 100644 --- a/app/components/utilities/laser-toolkit/pulse-overlap/page.tsx +++ b/app/components/utilities/laser-toolkit/pulse-overlap/page.tsx @@ -21,19 +21,16 @@ export default function Page() { const dUm = num(spotUm); // µm if (v <= 0 || f <= 0 || dUm <= 0) { - return { spacingUm: 0, spacingMm: 0, overlapPct: 0, gapPct: 0, pulsesPerMm: 0, pulsesPerSpot: 0 }; + return { spacingUm: 0, spacingMm: 0, overlapPct: 0, pulsesPerMm: 0 }; } // distance per pulse const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 1000) const spacingMm = spacingUm / 1000; - const signedOverlapPct = 100 * (1 - spacingUm / dUm); - const overlapPct = Math.max(0, signedOverlapPct); - const gapPct = Math.max(0, -signedOverlapPct); + const overlapPct = Math.max(0, Math.min(100, 100 * (1 - spacingUm / dUm))); const pulsesPerMm = (f * 1000) / v; - const pulsesPerSpot = dUm / spacingUm; - return { spacingUm, spacingMm, overlapPct, gapPct, pulsesPerMm, pulsesPerSpot }; + return { spacingUm, spacingMm, overlapPct, pulsesPerMm }; }, [speed, freq, spotUm]); return ( @@ -71,24 +68,20 @@ export default function Page() {
Overlap
{result.overlapPct.toFixed(1)}%
- {result.gapPct > 0 && ( -
{result.gapPct.toFixed(1)}% gap between pulses
- )}
Pulses per mm
{result.pulsesPerMm.toFixed(1)}
-
Pulses per spot diameter
-
{result.pulsesPerSpot.toFixed(2)}
+
Rule of thumb
+
+ 60–80% overlap is common for marking; deeper engraving often higher. +
-

- Geometric overlap along the scan direction only. It does not predict material response; - pulse energy, spot profile, hatch spacing, and thermal accumulation also matter. -

); } +