feat(proxy): add image tracing endpoint

Add GET /trace which fetches an image, traces it into a vector shape, and returns the result BSON-encoded. Tracing is configurable via mode (alpha, bw, color) and format (shape, svg). The shape format parses the vtracer SVG into an outline/holes polygon matching @tts/mesh's Shape interface.
This commit is contained in:
2026-08-08 14:51:36 +08:00
parent d554d6bde1
commit 445dbc1781
10 changed files with 922 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import svgpath from 'svgpath';
import type { TracedShape } from '@tts/shared';
/** Number of samples per cubic bezier when flattening curves to polylines. */
const CURVE_STEPS = 12;
/**
* Parse an SVG produced by vtracer into a `TracedShape` (outline + holes),
* matching the `Shape` interface in `@tts/mesh` so the result can be extruded
* directly. vtracer emits one `<path>` per traced region; a region with holes
* is a single path containing multiple subpaths (one per `M` command).
*
* Subpaths are classified by winding: counter-clockwise rings become the
* outline, clockwise rings become holes. Holes are assigned to the smallest
* outline that contains them (point-in-polygon on the first vertex).
*/
export function parseSvgShape(svg: string): TracedShape {
const paths = [...svg.matchAll(/<path\s+d="([^"]*)"/g)].map((m) => m[1]!);
const rings = paths.flatMap(parseRings);
if (rings.length === 0) {
return { outline: [] };
}
const outlines = rings.filter((r) => signedArea(r) > 0);
const holes = rings.filter((r) => signedArea(r) <= 0);
// Assign each hole to the smallest outline that contains its first vertex.
const assigned = new Map<number, number[][][]>();
for (const hole of holes) {
const p = hole[0]! as [number, number];
let best: number | null = null;
let bestArea = Infinity;
for (let i = 0; i < outlines.length; i++) {
const area = Math.abs(signedArea(outlines[i]!));
if (area < bestArea && pointInPolygon(p, outlines[i]!)) {
best = i;
bestArea = area;
}
}
if (best !== null) {
const list = assigned.get(best) ?? [];
list.push(hole);
assigned.set(best, list);
}
}
// The largest outline is the outer boundary; any remaining outlines are
// treated as additional (disjoint) regions, which vtracer emits as separate
// paths anyway. Keep the first/largest as the primary outline.
const primary = outlines.reduce((a, b) =>
Math.abs(signedArea(a)) >= Math.abs(signedArea(b)) ? a : b,
);
return {
outline: primary,
holes: assigned.get(outlines.indexOf(primary)),
};
}
/** Split a path's `d` attribute into rings, one per `M` subpath. */
function parseRings(d: string): number[][][] {
const rings: number[][][] = [];
let cur: number[][] | null = null;
let pen: [number, number] = [0, 0];
let ctrl: [number, number] | null = null;
svgpath(d)
.abs()
.iterate((seg) => {
const cmd = seg[0];
switch (cmd) {
case 'M':
if (cur && cur.length) rings.push(cur);
cur = [[seg[1]!, seg[2]!]];
pen = [seg[1]!, seg[2]!];
ctrl = null;
break;
case 'L':
cur?.push([seg[1]!, seg[2]!]);
pen = [seg[1]!, seg[2]!];
ctrl = null;
break;
case 'C': {
const p1: [number, number] = [seg[1]!, seg[2]!];
const p2: [number, number] = [seg[3]!, seg[4]!];
const p3: [number, number] = [seg[5]!, seg[6]!];
flattenCubic(pen, p1, p2, p3, cur!);
pen = p3;
ctrl = p2;
break;
}
case 'Q': {
// Approximate a quadratic bezier with a cubic.
const q1: [number, number] = [seg[1]!, seg[2]!];
const q2: [number, number] = [seg[3]!, seg[4]!];
const p1: [number, number] = [
pen[0] + (2 / 3) * (q1[0] - pen[0]),
pen[1] + (2 / 3) * (q1[1] - pen[1]),
];
const p2: [number, number] = [
q2[0] + (2 / 3) * (q1[0] - q2[0]),
q2[1] + (2 / 3) * (q1[1] - q2[1]),
];
flattenCubic(pen, p1, p2, q2, cur!);
pen = q2;
ctrl = q1;
break;
}
case 'Z':
case 'z':
cur?.push([pen[0], pen[1]]);
ctrl = null;
break;
default:
break;
}
});
if (cur !== null) rings.push(cur);
// Drop the duplicate closing vertex (first == last).
for (const ring of rings) {
const first = ring[0]!;
const last = ring[ring.length - 1]!;
if (
Math.abs(first[0]! - last[0]!) < 1e-6 &&
Math.abs(first[1]! - last[1]!) < 1e-6
) {
ring.pop();
}
}
return rings;
}
/** Sample a cubic bezier into `CURVE_STEPS` points appended to `out`. */
function flattenCubic(
p0: [number, number],
p1: [number, number],
p2: [number, number],
p3: [number, number],
out: number[][],
): void {
for (let i = 1; i <= CURVE_STEPS; i++) {
const t = i / CURVE_STEPS;
const mt = 1 - t;
const x =
mt * mt * mt * p0[0] +
3 * mt * mt * t * p1[0] +
3 * mt * t * t * p2[0] +
t * t * t * p3[0];
const y =
mt * mt * mt * p0[1] +
3 * mt * mt * t * p1[1] +
3 * mt * t * t * p2[1] +
t * t * t * p3[1];
out.push([x, y]);
}
}
/** Signed area of a polygon; positive means counter-clockwise. */
function signedArea(points: number[][]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const [x1, y1] = points[i]!;
const [x2, y2] = points[(i + 1) % points.length]!;
area += x1! * y2! - x2! * y1!;
}
return area / 2;
}
/** Ray-casting point-in-polygon test. */
function pointInPolygon(p: [number, number], polygon: number[][]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i]!;
const [xj, yj] = polygon[j]!;
const intersects =
yi! > p[1] !== yj! > p[1] &&
p[0] < ((xj! - xi!) * (p[1] - yi!)) / (yj! - yi!) + xi!;
if (intersects) inside = !inside;
}
return inside;
}