diff --git a/apps/proxy/package.json b/apps/proxy/package.json
index 6750f5a..c71ecf2 100644
--- a/apps/proxy/package.json
+++ b/apps/proxy/package.json
@@ -16,7 +16,11 @@
"@hono/node-server": "^1.13.7",
"@tts/shared": "workspace:*",
"@tts/tts": "workspace:*",
+ "@visioncortex/vtracer": "1.0.0-alpha.3",
+ "bson": "^6.10.4",
"hono": "^4.6.14",
+ "sharp": "^0.35.3",
+ "svgpath": "^2.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts
index d6f6998..686d134 100644
--- a/apps/proxy/src/index.ts
+++ b/apps/proxy/src/index.ts
@@ -6,6 +6,7 @@ import asset from './routes/asset.js';
import health from './routes/health.js';
import items from './routes/items.js';
import search from './routes/search.js';
+import trace from './routes/trace.js';
const env = loadEnv();
@@ -16,6 +17,7 @@ app.route('/health', health);
app.route('/search', search);
app.route('/items', items);
app.route('/asset', asset);
+app.route('/trace', trace);
serve(
{
diff --git a/apps/proxy/src/routes/svgShape.test.ts b/apps/proxy/src/routes/svgShape.test.ts
new file mode 100644
index 0000000..bb77c47
--- /dev/null
+++ b/apps/proxy/src/routes/svgShape.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { parseSvgShape } from './svgShape.js';
+
+describe('parseSvgShape', () => {
+ it('parses a simple closed outline', () => {
+ const svg =
+ '';
+ const shape = parseSvgShape(svg);
+ expect(shape.outline.length).toBeGreaterThanOrEqual(4);
+ expect(shape.holes).toBeUndefined();
+ });
+
+ it('assigns a clockwise subpath as a hole', () => {
+ // Outer ring CCW, inner ring CW (hole).
+ const svg =
+ '';
+ const shape = parseSvgShape(svg);
+ expect(shape.outline.length).toBeGreaterThanOrEqual(4);
+ expect(shape.holes).toBeDefined();
+ expect(shape.holes!.length).toBe(1);
+ });
+
+ it('flattens cubic curves into multiple points', () => {
+ const svg =
+ '';
+ const shape = parseSvgShape(svg);
+ // Start point + 12 curve samples + closing point, minus duplicate.
+ expect(shape.outline.length).toBeGreaterThan(3);
+ });
+
+ it('returns an empty outline for no paths', () => {
+ const shape = parseSvgShape('');
+ expect(shape.outline).toEqual([]);
+ });
+});
\ No newline at end of file
diff --git a/apps/proxy/src/routes/svgShape.ts b/apps/proxy/src/routes/svgShape.ts
new file mode 100644
index 0000000..663937b
--- /dev/null
+++ b/apps/proxy/src/routes/svgShape.ts
@@ -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 `` 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(/ 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();
+ 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;
+}
\ No newline at end of file
diff --git a/apps/proxy/src/routes/trace.test.ts b/apps/proxy/src/routes/trace.test.ts
new file mode 100644
index 0000000..d575da5
--- /dev/null
+++ b/apps/proxy/src/routes/trace.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { deserialize } from 'bson';
+import sharp from 'sharp';
+import trace from './trace.js';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+/** Build a small PNG with an opaque circle in the center on a transparent bg. */
+async function makePng(size = 20): Promise {
+ const rgba = Buffer.alloc(size * size * 4);
+ const cx = size / 2;
+ const cy = size / 2;
+ const r = size / 3;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ if (Math.hypot(x - cx, y - cy) <= r) {
+ const i = (y * size + x) * 4;
+ rgba[i] = 255;
+ rgba[i + 1] = 255;
+ rgba[i + 2] = 255;
+ rgba[i + 3] = 255;
+ }
+ }
+ }
+ return sharp(rgba, { raw: { width: size, height: size, channels: 4 } })
+ .png()
+ .toBuffer();
+}
+
+function stubFetch(body: Buffer) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ new Response(new Uint8Array(body), {
+ headers: { 'content-type': 'image/png' },
+ }),
+ ),
+ );
+}
+
+describe('trace route', () => {
+ it('rejects a missing url', async () => {
+ const res = await trace.request('/');
+ expect(res.status).toBe(400);
+ expect(await res.json()).toEqual({ error: 'Required' });
+ });
+
+ it('rejects a non-http url', async () => {
+ const res = await trace.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 502 when the upstream fetch fails', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
+ );
+ const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
+ expect(res.status).toBe(502);
+ });
+
+ it('returns a BSON shape by default', async () => {
+ stubFetch(await makePng());
+ const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toBe('application/octet-stream');
+
+ const result = deserialize(new Uint8Array(await res.arrayBuffer()));
+ expect(result.format).toBe('shape');
+ expect(result.mode).toBe('alpha');
+ expect(result.width).toBe(20);
+ expect(result.height).toBe(20);
+ expect(typeof result.svg).toBe('string');
+ expect(result.svg).toContain('