From 85ec3b9928ed4714b5ab89d699ebcd24f5c00280 Mon Sep 17 00:00:00 2001 From: hyper Date: Sat, 14 Mar 2026 16:17:46 +0800 Subject: [PATCH] feat: plotcutter --- src/plotcutter/bezier.ts | 49 +++++++++++++++++++++++++ src/plotcutter/index.ts | 3 ++ src/plotcutter/normalize.ts | 57 +++++++++++++++++++++++++++++ src/plotcutter/plotter.ts | 73 +++++++++++++++++++++++++++++++++++++ src/plotcutter/vector.ts | 18 +++++++++ 5 files changed, 200 insertions(+) create mode 100644 src/plotcutter/bezier.ts create mode 100644 src/plotcutter/index.ts create mode 100644 src/plotcutter/normalize.ts create mode 100644 src/plotcutter/plotter.ts create mode 100644 src/plotcutter/vector.ts diff --git a/src/plotcutter/bezier.ts b/src/plotcutter/bezier.ts new file mode 100644 index 0000000..37c774f --- /dev/null +++ b/src/plotcutter/bezier.ts @@ -0,0 +1,49 @@ +export type Pt = [number, number]; + +export function cubicBezierCommand(pts: Pt[], p1: Pt, p2: Pt, p3: Pt){ + const p0 = pts[pts.length - 1]; + if (!p0) return pts; + + const [b1p0, b1p1, b1p2] = getMidPoints(p0, p1, p2, p3); + const [b2p0, b2p1] = getMidPoints(b1p0, b1p1, b1p2); + const [b3p0] = getMidPoints(b2p0, b2p1); + + const a1 = Math.atan2(b3p0[1] - p0[1], b3p0[0] - p0[0]); + const a2 = Math.atan2(p3[1] - b3p0[1], p3[0] - b3p0[0]); + const d = a2 - a1 - Math.round((a2 - a1) / Math.PI / 2) * Math.PI * 2; + if (isNaN(d)) { + console.error('NaN found', { d, a2, a1, p0, p1, p2, p3 }); + return pts; + } + + const d03 = sqdist(p0, p3); + if (d * d * d03 < Math.PI * Math.PI / 18 / 18) { + pts.push(p3); + return pts; + } + + cubicBezierCommand(pts, b1p0, b2p0, b3p0); + pts.push(b3p0); + cubicBezierCommand(pts, b2p1, b1p2, p3); + + return pts; +} + +function sqdist(pt: Pt, to: Pt) { + const x = pt[0] - to[0]; + const y = pt[1] - to[1]; + return x * x + y * y; +} + +function getMidPoints(...pts: Pt[]){ + const mps = [] as typeof pts; + + for(let i = 1; i < pts.length; i ++){ + mps[i-1] = [ + (pts[i][0] + pts[i-1][0])/2, + (pts[i][1] + pts[i-1][1])/2, + ]; + } + + return mps; +} \ No newline at end of file diff --git a/src/plotcutter/index.ts b/src/plotcutter/index.ts new file mode 100644 index 0000000..22bb6f1 --- /dev/null +++ b/src/plotcutter/index.ts @@ -0,0 +1,3 @@ +export * from "./bezier"; +export * from "./vector"; +export * from "./plotter"; \ No newline at end of file diff --git a/src/plotcutter/normalize.ts b/src/plotcutter/normalize.ts new file mode 100644 index 0000000..110a30f --- /dev/null +++ b/src/plotcutter/normalize.ts @@ -0,0 +1,57 @@ +// pts is a point loop +// reorder the points, such that: +// 1. the points are in counter-clockwise order +// 2. the first edge(pts[0] to pts[1]) points to the (0,1) direction, as much as possible +export function normalize(pts: [number, number][]){ + if (pts.length < 3) { + return pts; // Need at least 3 points to form a polygon + } + + // Calculate the signed area to determine winding order + let area = 0; + for (let i = 0; i < pts.length; i++) { + const [x1, y1] = pts[i]; + const [x2, y2] = pts[(i + 1) % pts.length]; + area += x1 * y2 - x2 * y1; + } + + // If area is negative, points are in clockwise order, so reverse them + if (area < 0) { + pts.reverse(); + } + + // Find the best starting point to maximize alignment with (1,0) direction + let bestIndex = 0; + let maxDotProduct = -Infinity; + + for (let i = 0; i < pts.length; i++) { + const [x1, y1] = pts[i]; + const [x2, y2] = pts[(i + 1) % pts.length]; + + // Calculate the direction vector of the edge + const dx = x2 - x1; + const dy = y2 - y1; + + // Normalize the vector + const length = Math.sqrt(dx * dx + dy * dy); + if (length > 0) { + const normalizedDx = dx / length; + const normalizedDy = dy / length; + + // Dot product with (1, 0) is just normalizedDx + const dotProduct = normalizedDy; + + if (dotProduct > maxDotProduct) { + maxDotProduct = dotProduct; + bestIndex = i; + } + } + } + + // Rotate array to start with the best edge + if (bestIndex !== 0) { + pts = [...pts.slice(bestIndex), ...pts.slice(0, bestIndex)]; + } + + return pts; +} \ No newline at end of file diff --git a/src/plotcutter/plotter.ts b/src/plotcutter/plotter.ts new file mode 100644 index 0000000..e7a9c23 --- /dev/null +++ b/src/plotcutter/plotter.ts @@ -0,0 +1,73 @@ +import {normalize} from "./normalize"; + +export function pts2plotter(pts: [number, number][][], width: number, height: number, px2mm = 0.1){ + let str = init(width * px2mm, height * px2mm); + + // sort paths by x(long) then by y(short) + const sorted = pts.slice(); + sorted.sort(function (a, b) { + const [ax,ay] = topleft(a); + const [bx,by] = topleft(b); + + if (ax !== bx) return ax - bx; + return ay - by; + }); + + let lead = true; + for(const path of sorted){ + for (const cmd of poly(normalize(path), height, px2mm, lead)) { + str += cmd; + } + lead = false; + } + + str += end(); + + return str; +} + +function topleft(pts: [number, number][]){ + let minx = NaN; + let miny = NaN; + for(const pt of pts){ + if (isNaN(minx) || minx > pt[0]) minx = pt[0]; + if (isNaN(miny) || miny > pt[1]) miny = pt[1]; + } + return [minx, miny] as [number, number]; +} + +function init(w: number, h: number) { + return ` IN TB26,${plu(w)},${plu(h)} CT1 U0,0 D0,0 D40,0`; +} + +function end() { + return ' U0,0 @ @'; +} + +function* poly(pts: [number, number][], height: number, px2mm: number, lead = false){ + function cutpt(down: boolean, pt: [number, number]) { + return ` ${down ? 'D' : 'U'}${plu(pt[0] * px2mm)},${plu((height - pt[1]) * px2mm)}`; + } + + if (lead) { + yield cutpt(false, [0, 0]); + yield cutpt(true, [0, 1]); + } + + yield cutpt(false, pts[0]); + for(const pt of pts){ + yield cutpt(true, pt); + } +} + +function sqrlen(x: number, y: number) { + return x * x + y * y; +} + +function lerp(s: [number, number], e: [number, number], i: number) { + return [s[0] + (e[0] - s[0]) * i, s[1] + (e[1] - s[1]) * i] as typeof s; +} + +function plu(n: number) { + return Math.round(n / 0.025); +} \ No newline at end of file diff --git a/src/plotcutter/vector.ts b/src/plotcutter/vector.ts new file mode 100644 index 0000000..22089f8 --- /dev/null +++ b/src/plotcutter/vector.ts @@ -0,0 +1,18 @@ +import { cubicBezierCommand, Pt } from "./bezier"; + +export function frame2preview(pts: Pt[][], width: number, height: number, px2mm = 0.1) { + return ` + + + + ${ (width * px2mm).toFixed(1)}mm x ${(height * px2mm).toFixed(1)}mm`; +} + +function pts2command(pts: Pt[][]){ + return pts.map( + path => { + const pts = path.map(pt=> `${pt[0]} ${pt[1]}`).join(' L '); + return `M ${pts} Z`; + } + ).join(' '); +}