Initial commit

This commit is contained in:
makearmy 2025-09-22 10:37:53 -04:00
commit 78f8d225ee
21173 changed files with 2907774 additions and 0 deletions

View file

@ -0,0 +1,39 @@
// /var/www/makearmy.io/app/app/laser-toolkit/_lib/conversions.ts
// ---------- DPI / LPI / DPCM ----------
export function dpiToLpi(dpi: number) {
return dpi; // 1:1 if treating “lines” as rows in raster (common convention)
}
export function dpiToDpcm(dpi: number) {
return dpi / 2.54;
}
export function lpiToDpi(lpi: number) {
return lpi; // same note as above
}
export function lpiToDpcm(lpi: number) {
return lpi / 2.54;
}
export function dpcmToDpi(dpcm: number) {
return dpcm * 2.54;
}
export function dpcmToLpi(dpcm: number) {
return dpcm * 2.54;
}
// ---------- Power & Lens Scaler ----------
// Simple “keep energy density roughly constant” heuristic:
// newSpeed ≈ oldSpeed * (toPower / fromPower) * (fromField / toField)
export function scaleSpeed(
oldSpeed_mm_s: number,
fromPower_W: number,
toPower_W: number,
fromField_mm: number,
toField_mm: number
) {
if (fromPower_W <= 0 || toPower_W <= 0 || fromField_mm <= 0 || toField_mm <= 0) {
return oldSpeed_mm_s;
}
const k = (toPower_W / fromPower_W) * (fromField_mm / toField_mm);
return oldSpeed_mm_s * k;
}

View file

@ -0,0 +1,88 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
// 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
const [beamDm, setBeamDm] = useState("6"); // mm (input beam diameter at lens)
const [m2, setM2] = useState("1.3");
const dUm = useMemo(() => {
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);
}, [lambdaNm, focalMm, beamDm, m2]);
const dMm = dUm / 1000;
return (
<ToolShell title="Beam Spot Size">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Wavelength (nm)</div>
<Input value={lambdaNm} onChange={(e) => setLambdaNm(e.target.value)} />
<div className="mt-1 text-[11px] text-muted-foreground space-x-2">
<button type="button" className="underline" onClick={() => setLambdaNm("1064")}>
Fiber (1064 nm)
</button>
<button type="button" className="underline" onClick={() => setLambdaNm("10600")}>
CO (10600 nm)
</button>
</div>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Focal length (mm)</div>
<Input value={focalMm} onChange={(e) => setFocalMm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Beam Ø @ lens (mm)</div>
<Input value={beamDm} onChange={(e) => setBeamDm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">M²</div>
<Input value={m2} onChange={(e) => setM2(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Result</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div>
<div className="text-sm text-muted-foreground">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>
<div>
<div className="text-sm text-muted-foreground">Spot radius</div>
<div className="text-lg">{(dMm / 2).toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(dUm / 2).toFixed(2)} µm</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
export default function Page() {
const [dpi, setDpi] = useState("300");
const [lpi, setLpi] = useState("300");
const [dpcm, setDpcm] = useState("118.11");
const [active, setActive] = useState<"dpi" | "lpi" | "dpcm">("dpi");
// keep all three in sync based on the most recently edited field
useEffect(() => {
const D = num(dpi), L = num(lpi), C = num(dpcm);
if (active === "dpi") {
const d = Math.max(1e-9, D);
setDpcm((d / 2.54).toFixed(5));
setLpi(D.toFixed(2)); // LPI≈DPI for raster row spacing (workflow convention)
} else if (active === "lpi") {
const l = Math.max(1e-9, L);
setDpi(L.toFixed(2));
setDpcm((L / 2.54).toFixed(5));
} else {
const c = Math.max(1e-9, C);
setDpi((c * 2.54).toFixed(2));
setLpi((c * 2.54).toFixed(2));
}
}, [dpi, lpi, dpcm, active]);
const gapFromDpiMm = 25.4 / Math.max(1e-9, num(dpi));
const gapFromLpiMm = 25.4 / Math.max(1e-9, num(lpi));
return (
<ToolShell title="DPI / LPI / DPCM Converter">
<Card>
<CardHeader>
<CardTitle className="text-base">Values</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPI</div>
<Input
inputMode="decimal"
value={dpi}
onChange={(e) => {
setActive("dpi");
setDpi(e.target.value);
}}
/>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">LPI</div>
<Input
inputMode="decimal"
value={lpi}
onChange={(e) => {
setActive("lpi");
setLpi(e.target.value);
}}
/>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPCM</div>
<Input
inputMode="decimal"
value={dpcm}
onChange={(e) => {
setActive("dpcm");
setDpcm(e.target.value);
}}
/>
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Derived spacing</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-sm text-muted-foreground">Pixel/line gap from DPI</div>
<div className="text-lg">{gapFromDpiMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(gapFromDpiMm * 1000).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Line gap from LPI</div>
<div className="text-lg">{gapFromLpiMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(gapFromLpiMm * 1000).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Pixels/cm from DPCM</div>
<div className="text-lg">{num(dpcm).toFixed(2)} px/cm</div>
<div className="text-xs text-muted-foreground">
{(num(dpcm) * 2.54).toFixed(2)} px/in
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,97 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
const UM_PER_INCH = 25400;
export default function Page() {
const [spotUm, setSpotUm] = useState("60");
const [gapUm, setGapUm] = useState("40");
const [lpi, setLpi] = useState("635"); // 635 LPI ≈ 40 µm gap
// Keep gap and LPI linked both ways
function onGapChange(v: string) {
setGapUm(v);
const g = num(v);
setLpi(g > 0 ? (UM_PER_INCH / g).toFixed(2) : "");
}
function onLpiChange(v: string) {
setLpi(v);
const L = num(v);
setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : "");
}
const overlap = 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)));
}, [spotUm, gapUm]);
const gapMm = (num(gapUm) / 1000) || 0;
const spotMm = (num(spotUm) / 1000) || 0;
return (
<ToolShell title="Hatch Overlap">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Hatch gap (µm)</div>
<Input inputMode="decimal" value={gapUm} onChange={(e) => onGapChange(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Hatch LPI</div>
<Input inputMode="decimal" value={lpi} onChange={(e) => onLpiChange(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Result</CardTitle>
</CardHeader>
<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>
<div>
<div className="text-sm text-muted-foreground">Gap</div>
<div className="text-lg">{gapMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{num(gapUm).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Spot Ø</div>
<div className="text-lg">{spotMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{num(spotUm).toFixed(1)} µm</div>
</div>
<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
</div>
<div className="text-xs text-muted-foreground">
{(UM_PER_INCH / Math.max(1, num(lpi))).toFixed(1)} µm
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
function fmtTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return "0 s";
const s = Math.round(seconds);
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
export default function Page() {
const [mode, setMode] = useState<"raster" | "vector">("raster");
const [passes, setPasses] = useState("1");
// raster
const [width, setWidth] = useState("100"); // mm
const [height, setHeight] = useState("100");// mm
const [dpi, setDpi] = useState("300");
const [speedRaster, setSpeedRaster] = useState("800"); // mm/s
const [overheadR, setOverheadR] = useState("1.10"); // factor
// vector
const [length, setLength] = useState("500"); // mm
const [speedVector, setSpeedVector] = useState("50"); // mm/s
const [overheadV, setOverheadV] = useState("1.05"); // factor
const computed = useMemo(() => {
const p = Math.max(1, Math.round(num(passes)));
if (mode === "raster") {
const w = num(width), h = num(height), D = num(dpi), v = num(speedRaster), k = Math.max(0.5, num(overheadR));
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 t = rows * (w / v) * p * k;
return { t, gapMm, gapUm, rows };
} else {
const L = num(length), v = num(speedVector), k = Math.max(0.5, num(overheadV));
if (L <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
const t = (L / v) * Math.max(1, Math.round(num(passes))) * k;
return { t, gapMm: 0, gapUm: 0, rows: 0 };
}
}, [mode, passes, width, height, dpi, speedRaster, overheadR, length, speedVector, overheadV]);
return (
<ToolShell title="Job Time Estimator">
<Card>
<CardHeader>
<CardTitle className="text-base">Mode</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<label className="text-[11px] sm:text-xs col-span-2 sm:col-span-1">
<div className="mb-1 text-muted-foreground">Type</div>
<select
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={mode}
onChange={(e) => (setMode(e.target.value as any))}
>
<option value="raster">Raster</option>
<option value="vector">Vector</option>
</select>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Passes</div>
<Input inputMode="numeric" value={passes} onChange={(e) => setPasses(e.target.value)} />
</label>
</CardContent>
</Card>
{mode === "raster" ? (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Raster Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-5">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Width (mm)</div>
<Input value={width} onChange={(e) => setWidth(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Height (mm)</div>
<Input value={height} onChange={(e) => setHeight(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPI</div>
<Input value={dpi} onChange={(e) => setDpi(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input value={speedRaster} onChange={(e) => setSpeedRaster(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Overhead factor</div>
<Input value={overheadR} onChange={(e) => setOverheadR(e.target.value)} />
</label>
</CardContent>
</Card>
) : (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Vector Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Total path length (mm)</div>
<Input value={length} onChange={(e) => setLength(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input value={speedVector} onChange={(e) => setSpeedVector(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Overhead factor</div>
<Input value={overheadV} onChange={(e) => setOverheadV(e.target.value)} />
</label>
</CardContent>
</Card>
)}
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Estimate</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-sm text-muted-foreground">Estimated time</div>
<div className="text-lg">{fmtTime(computed.t)}</div>
</div>
{mode === "raster" && (
<>
<div>
<div className="text-sm text-muted-foreground">Scan gap</div>
<div className="text-lg">{computed.gapMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{computed.gapUm.toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Line count</div>
<div className="text-lg">{computed.rows.toFixed(0)}</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 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,
bidirectional settle time, and controller latency.{" "}
<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
segments or lots of jumps closer to 1.15). Raster engraving{" "}
<span className="font-medium">1.101.40</span> (lower DPI and long sweeps near 1.10;
very high DPI or short scan width near 1.301.40). Galvo systems often have lower overhead
at small sizes; gantry systems tend to have higher overhead at high DPI/short strokes.
</p>
</ToolShell>
);
}

124
app/laser-toolkit/page.tsx Normal file
View file

@ -0,0 +1,124 @@
import Link from "next/link";
import { Metadata } from "next";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Gauge,
Ruler,
Timer,
Focus,
MoveRight,
ChevronRight,
} from "lucide-react";
export const metadata: Metadata = {
title: "Laser Toolkit",
description: "Quick utilities for scaling settings and converting resolution units.",
};
type Tool = {
slug: string;
title: string;
description: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
};
const TOOLS: Tool[] = [
{
slug: "power-lens-scaler",
title: "Power & Lens Scaler",
description: "Scale speed, power, and frequency when wattage or lens field size changes.",
icon: Gauge,
},
{
slug: "dpi-lpi-dpcm",
title: "DPI ▸ LPI ▸ DPCM",
description: "Convert between DPI, LPI, and DPCM. Bidirectional. Assumes LPI≈DPI for raster rows (common workflow).",
icon: Ruler,
},
// NEW
{
slug: "pulse-overlap",
title: "Pulse Overlap",
description:
"Given speed (mm/s), frequency (kHz) and spot size (µm), compute pulse spacing, overlap %, and pulses/mm.",
icon: MoveRight,
},
{
slug: "hatch-overlap",
title: "Hatch Overlap",
description:
"Given spot size (µm) and hatch gap (µm) or LPI, compute hatch overlap %. Great for vector fills.",
icon: Ruler,
},
{
slug: "job-time-estimator",
title: "Job Time Estimator",
description:
"Quick estimate for raster or vector jobs. Uses dimensions, DPI/LPI or path length, speed, passes, and a small overhead factor.",
icon: Timer,
},
{
slug: "beam-spot-size",
title: "Beam Spot Size",
description:
"Approximate diffraction-limited spot size from wavelength, focal length, beam diameter, and M².",
icon: Focus,
},
];
export default function ToolkitSplash() {
return (
<div className="mx-auto w-full max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Laser Toolkit</h1>
<p className="mt-1 text-sm text-muted-foreground">
Handy calculators and converters for daily laser work {" "}
<span className="italic">hover for details</span>.
</p>
</div>
<Button asChild variant="outline">
<Link href="/">Back to Main Menu</Link>
</Button>
</div>
{/* Grid of tools */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{TOOLS.map((tool) => (
<Link key={tool.slug} href={`/laser-toolkit/${tool.slug}`} className="group">
<Card className="relative overflow-hidden transition-shadow hover:shadow-md">
<CardHeader className="p-4">
<div className="flex items-start gap-3">
<div className="rounded-xl border bg-card p-2">
<tool.icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base">{tool.title}</CardTitle>
{/* Full description on hover (no truncation) */}
<p
className="
max-h-0 overflow-hidden text-xs text-muted-foreground opacity-0
transition-all duration-200
group-hover:max-h-96 group-hover:opacity-100
mt-1 whitespace-pre-wrap
"
>
{tool.description}
</p>
</div>
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,378 @@
'use client';
import { useMemo, useState } from 'react';
import ToolShell from '@/components/toolkit/ToolShell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
type Mode = 'vector' | 'raster' | 'irradiance' | 'pulse';
function num(v: string, d = 0): number {
const n = Number(v);
return Number.isFinite(n) ? n : d;
}
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;
return r * r;
}
export default function Page() {
// MODE
const [mode, setMode] = useState<Mode>('vector');
// SOURCE machine/lens
const [wSrc, setWSrc] = useState('100'); // rated W
const [pSrc, setPSrc] = useState('50'); // %
const [vSrc, setVSrc] = useState('300'); // mm/s
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
// DEST machine/lens
const [wDst, setWDst] = useState('50'); // rated W
const [vDst, setVDst] = useState('300'); // mm/s
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));
// Prefer adjusting speed/freq instead of exceeding 100% power
const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true);
const result = useMemo(() => {
const W1 = Math.max(num(wSrc, 1), 0.1);
const W2 = Math.max(num(wDst, 1), 0.1);
const p1 = clamp(num(pSrc, 0), 0, 100) / 100; // 0..1
const v1 = Math.max(num(vSrc, 0), 0.0001);
const v2 = Math.max(num(vDst, 0), 0.0001);
const h1 = Math.max(num(hSrc, 0), 0.000001);
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);
// Effective average power (W) after frequency efficiency
const P1eff = W1 * p1 * eta1;
let p2Frac = p1; // destination power fraction (0..1)
let suggestedSpeed: number | undefined;
let suggestedFreq_kHz: number | undefined;
// 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);
};
if (mode === 'vector') {
// Match energy per length: P1eff / v1 = P2eff / v2
const P2effReq = P1eff * (v2 / v1);
p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1
p2Frac = 1;
}
} else if (mode === 'raster') {
// Match energy per area: P1eff/(v1*h1) = P2eff/(v2*h2)
const P2effReq = P1eff * ((v2 * h2) / (v1 * h1));
p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1);
p2Frac = 1;
}
} else if (mode === 'irradiance') {
// Match irradiance: (P1eff/A1) = (P2eff/A2) => P2eff = P1eff*(A2/A1)
const P2effReq = P1eff * aFac;
p2Frac = powerPercentFromEff(P2effReq);
// no speed suggestion; consider lens/field change if >100%
} else if (mode === 'pulse') {
// Match pulse energy: Ep1 = P1eff / f1 (kHz → Hz)
const f1 = f1k * 1e3, f2 = f2k * 1e3;
const Ep1 = P1eff / f1; // J
// Require P2eff = Ep1 * f2
const P2effReq = Ep1 * f2;
p2Frac = powerPercentFromEff(P2effReq);
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
suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1);
p2Frac = 1;
}
}
// Compute pulse metrics (for display) using **destination** settings
const p2Clamped = clamp(p2Frac, 0, 2);
const P2eff = W2 * p2Clamped * eta2;
const f2Hz = f2k * 1e3;
const tau2_s = tau2_ns * 1e-9;
const Ep2 = P2eff / f2Hz; // J
const Ppeak2 = Ep2 / Math.max(tau2_s, 1e-12); // W, shape factor ~1 assumed
return {
p2Percent: clamp(p2Clamped * 100, 0, 200),
suggestedSpeed,
suggestedFreq_kHz,
eta1,
eta2,
P1eff,
P2eff,
Ep2,
Ppeak2,
aFac,
};
}, [
mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst,
fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst,
]);
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."
>
<Card className="mb-6">
<CardHeader>
<CardTitle>Match Mode</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<Label className="text-sm">Quantity to Match</Label>
<Select value={mode} onValueChange={(v: Mode) => setMode(v)}>
<SelectTrigger><SelectValue placeholder="Mode" /></SelectTrigger>
<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="pulse">Pulse energy: J (fiber)</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2">
<input
id="preferSpeed"
type="checkbox"
className="h-4 w-4"
checked={preferSpeedAdjust}
onChange={(e) => setPreferSpeedAdjust(e.target.checked)}
/>
<span className="text-sm">If Power % &gt; 100, prefer adjusting speed/frequency</span>
</label>
</CardContent>
</Card>
{/* Source */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Source (what you have)</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Rated power (W)</Label>
<Input value={wSrc} onChange={(e) => setWSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Power (%)</Label>
<Input value={pSrc} onChange={(e) => setPSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Frequency (kHz)</Label>
<Input value={fSrc} onChange={(e) => setFSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Pulse width (ns)</Label>
<Input value={tauSrc} onChange={(e) => setTauSrc(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
<Label className="text-sm">Speed (mm/s)</Label>
<Input value={vSrc} onChange={(e) => setVSrc(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
<Label className="text-sm">Line spacing h (mm)</Label>
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Lens field size (mm)</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 */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Destination (what you want to run on)</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Rated power (W)</Label>
<Input value={wDst} onChange={(e) => setWDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Frequency (kHz)</Label>
<Input value={fDst} onChange={(e) => setFDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Pulse width (ns)</Label>
<Input value={tauDst} onChange={(e) => setTauDst(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
<Label className="text-sm">Speed (mm/s)</Label>
<Input value={vDst} onChange={(e) => setVDst(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
<Label className="text-sm">Line spacing h (mm)</Label>
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Lens field size (mm)</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 machines real powerfrequency curve.
</div>
</div>
</CardContent>
</Card>
{/* Result */}
<Card>
<CardHeader>
<CardTitle>Result</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold">
Suggested Power (dest): {result.p2Percent.toFixed(1)}%
</div>
{typeof result.suggestedSpeed === 'number' && mode !== 'pulse' && (
<p className="text-sm">
To keep Power 100%, try destination speed {' '}
<span className="font-medium">{result.suggestedSpeed.toFixed(1)} mm/s</span>.
</p>
)}
{typeof result.suggestedFreq_kHz === 'number' && mode === 'pulse' && (
<p className="text-sm">
To keep Power 100%, try destination frequency {' '}
<span className="font-medium">{result.suggestedFreq_kHz.toFixed(0)} kHz</span>.
</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>
<div className="text-muted-foreground">Dest pulse energy</div>
<div className="font-medium">
{(result.Ep2 >= 1e-3 ? (result.Ep2 * 1e3).toFixed(3) + ' mJ' : (result.Ep2 * 1e6).toFixed(1) + ' µJ')}
</div>
</div>
<div>
<div className="text-muted-foreground">Dest peak power</div>
<div className="font-medium">{(result.Ppeak2 / 1000).toFixed(1)} kW</div>
</div>
</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 powerfrequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves.
</p>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
export default function Page() {
const [speed, setSpeed] = useState("800"); // mm/s
const [freq, setFreq] = useState("60"); // kHz
const [spotUm, setSpotUm] = useState("50");// µm
const result = useMemo(() => {
const v = num(speed); // mm/s
const f = num(freq); // kHz
const dUm = num(spotUm); // µm
if (v <= 0 || f <= 0 || dUm <= 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 overlapPct = Math.max(0, Math.min(100, 100 * (1 - spacingUm / dUm)));
const pulsesPerMm = (f * 1000) / v;
return { spacingUm, spacingMm, overlapPct, pulsesPerMm };
}, [speed, freq, spotUm]);
return (
<ToolShell title="Pulse Overlap">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input inputMode="decimal" value={speed} onChange={(e) => setSpeed(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Frequency (kHz)</div>
<Input inputMode="decimal" value={freq} onChange={(e) => setFreq(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<div>
<div className="text-sm text-muted-foreground">Pulse spacing</div>
<div className="text-lg">{result.spacingMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{result.spacingUm.toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Overlap</div>
<div className="text-lg">{result.overlapPct.toFixed(1)}%</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">
6080% overlap is common for marking; deeper engraving often higher.
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}