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(' ');
+}