feat(proxy): add offset param to inset or outset traced shapes
Trace results can now be inset (negative) or outset (positive) by a pixel amount. Offset the outline and holes in opposite directions with clipper-lib's miter joins, then recombine with a boolean difference so holes grow on inset and shrink on outset. Collapsed shapes return an empty outline; a split outline keeps the largest ring. Document the parameter and the new dependency.
This commit is contained in:
@@ -38,6 +38,7 @@ To run the frontend alongside the proxy, open a second terminal and run
|
|||||||
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
||||||
| GET | `/items/:id/file` | Raw save bytes, filename from header |
|
| GET | `/items/:id/file` | Raw save bytes, filename from header |
|
||||||
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
"@tts/tts": "workspace:*",
|
"@tts/tts": "workspace:*",
|
||||||
"@visioncortex/vtracer": "1.0.0-alpha.3",
|
"@visioncortex/vtracer": "1.0.0-alpha.3",
|
||||||
"bson": "^6.10.4",
|
"bson": "^6.10.4",
|
||||||
|
"clipper-lib": "^6.4.2",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"svgpath": "^2.6.0",
|
"svgpath": "^2.6.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/clipper-lib": "^6.4.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseSvgShape } from './svgShape.js';
|
import { offsetShape, parseSvgShape } from './svgShape.js';
|
||||||
|
|
||||||
describe('parseSvgShape', () => {
|
describe('parseSvgShape', () => {
|
||||||
it('parses a simple closed outline', () => {
|
it('parses a simple closed outline', () => {
|
||||||
@@ -32,4 +32,99 @@ describe('parseSvgShape', () => {
|
|||||||
const shape = parseSvgShape('<svg></svg>');
|
const shape = parseSvgShape('<svg></svg>');
|
||||||
expect(shape.outline).toEqual([]);
|
expect(shape.outline).toEqual([]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offsetShape', () => {
|
||||||
|
const square: number[][] = [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[10, 10],
|
||||||
|
[0, 10],
|
||||||
|
];
|
||||||
|
|
||||||
|
it('outsets a square by a positive delta', () => {
|
||||||
|
const out = offsetShape({ outline: square }, 1).outline;
|
||||||
|
expect(Math.min(...out.map((p) => p[0]!))).toBeLessThan(0);
|
||||||
|
expect(Math.min(...out.map((p) => p[1]!))).toBeLessThan(0);
|
||||||
|
expect(Math.max(...out.map((p) => p[0]!))).toBeGreaterThan(10);
|
||||||
|
expect(Math.max(...out.map((p) => p[1]!))).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insets a square by a negative delta', () => {
|
||||||
|
const out = offsetShape({ outline: square }, -1).outline;
|
||||||
|
expect(Math.min(...out.map((p) => p[0]!))).toBeGreaterThan(0);
|
||||||
|
expect(Math.min(...out.map((p) => p[1]!))).toBeGreaterThan(0);
|
||||||
|
expect(Math.max(...out.map((p) => p[0]!))).toBeLessThan(10);
|
||||||
|
expect(Math.max(...out.map((p) => p[1]!))).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses an inset that is too large', () => {
|
||||||
|
const out = offsetShape({ outline: square }, -10);
|
||||||
|
expect(out.outline).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offsets concave outlines without self-intersection', () => {
|
||||||
|
const concave = [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[5, 5],
|
||||||
|
[10, 10],
|
||||||
|
[0, 10],
|
||||||
|
];
|
||||||
|
const out = offsetShape({ outline: concave }, -1).outline;
|
||||||
|
// The result must stay inside the original bounding box (no flipped
|
||||||
|
// vertices), and the outline must be strictly smaller in extent.
|
||||||
|
expect(Math.min(...out.map((p) => p[0]!))).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Math.min(...out.map((p) => p[1]!))).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Math.max(...out.map((p) => p[0]!))).toBeLessThanOrEqual(10);
|
||||||
|
expect(Math.max(...out.map((p) => p[1]!))).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows holes when insetting and shrinks them when outsetting', () => {
|
||||||
|
const shape = {
|
||||||
|
outline: [
|
||||||
|
[0, 0],
|
||||||
|
[20, 0],
|
||||||
|
[20, 20],
|
||||||
|
[0, 20],
|
||||||
|
],
|
||||||
|
holes: [[[8, 8], [12, 8], [12, 12], [8, 12]]],
|
||||||
|
};
|
||||||
|
const inset = offsetShape(shape, -2);
|
||||||
|
expect(inset.holes).toBeDefined();
|
||||||
|
// Hole grows from 8..12 to 6..14 on inset.
|
||||||
|
expect(Math.min(...inset.holes![0]!.map((p) => p[0]!))).toBeCloseTo(6);
|
||||||
|
|
||||||
|
// A smaller hole outgrows entirely on a moderate outset.
|
||||||
|
const tiny = {
|
||||||
|
outline: [
|
||||||
|
[0, 0],
|
||||||
|
[20, 0],
|
||||||
|
[20, 20],
|
||||||
|
[0, 20],
|
||||||
|
],
|
||||||
|
holes: [[[8, 8], [10, 8], [10, 10], [8, 10]]],
|
||||||
|
};
|
||||||
|
const outset = offsetShape(tiny, 2);
|
||||||
|
expect(outset.holes).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a hole that is outgrown by an outset', () => {
|
||||||
|
const shape = {
|
||||||
|
outline: [
|
||||||
|
[0, 0],
|
||||||
|
[0, 20],
|
||||||
|
[20, 20],
|
||||||
|
[20, 0],
|
||||||
|
],
|
||||||
|
holes: [[[8, 8], [8, 12], [12, 12], [12, 8]]],
|
||||||
|
};
|
||||||
|
const out = offsetShape(shape, 6);
|
||||||
|
expect(out.holes).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same shape for a zero delta', () => {
|
||||||
|
const out = offsetShape({ outline: square }, 0);
|
||||||
|
expect(out).toEqual({ outline: square });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as ClipperLib from 'clipper-lib';
|
||||||
import svgpath from 'svgpath';
|
import svgpath from 'svgpath';
|
||||||
import type { TracedShape } from '@tts/shared';
|
import type { TracedShape } from '@tts/shared';
|
||||||
|
|
||||||
@@ -58,6 +59,91 @@ export function parseSvgShape(svg: string): TracedShape {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset a traced shape's outline and holes by `delta` pixels. Positive values
|
||||||
|
* expand the shape (outset); negative values shrink it (inset). Holes move
|
||||||
|
* opposite to the outline, so outsetting shrinks holes and insetting grows
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* Uses Clipper's offsetting algorithm (Angus Johnson's Clipper ported to JS),
|
||||||
|
* which handles concave shapes and degenerate cases: an inset that collapses
|
||||||
|
* the outline yields an empty shape, a hole that is outgrown disappears, and
|
||||||
|
* a deep inset may split the outline into multiple rings (the largest is
|
||||||
|
* kept, since `TracedShape` supports a single outline).
|
||||||
|
*
|
||||||
|
* Each contour is offset independently, so the outline and holes are offset
|
||||||
|
* in opposite directions and then recombined with a boolean difference; a
|
||||||
|
* single `ClipperOffset` call would shrink holes on an inset instead of
|
||||||
|
* growing them.
|
||||||
|
*
|
||||||
|
* `SCALE` converts float coordinates to the fixed-point integers Clipper
|
||||||
|
* expects; coordinates are rounded to `1/SCALE` pixel.
|
||||||
|
*/
|
||||||
|
const SCALE = 100;
|
||||||
|
|
||||||
|
export function offsetShape(shape: TracedShape, delta: number): TracedShape {
|
||||||
|
if (delta === 0) return shape;
|
||||||
|
|
||||||
|
const outerOffset = offsetContour(shape.outline, delta);
|
||||||
|
if (outerOffset.length === 0) return { outline: [] };
|
||||||
|
|
||||||
|
// Subtract the (oppositely offset) holes from the offset outline so holes
|
||||||
|
// grow on inset and shrink on outset, as a whole-shape offset should.
|
||||||
|
const clipper = new ClipperLib.Clipper();
|
||||||
|
clipper.AddPaths(outerOffset, ClipperLib.PolyType.ptSubject, true);
|
||||||
|
for (const hole of shape.holes ?? []) {
|
||||||
|
clipper.AddPaths(offsetContour(hole, -delta), ClipperLib.PolyType.ptClip, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const solution: ClipperLib.Path[] = [];
|
||||||
|
clipper.Execute(
|
||||||
|
ClipperLib.ClipType.ctDifference,
|
||||||
|
solution,
|
||||||
|
ClipperLib.PolyFillType.pftNonZero,
|
||||||
|
ClipperLib.PolyFillType.pftNonZero,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (solution.length === 0) return { outline: [] };
|
||||||
|
|
||||||
|
// Clipper output rings are closed (first point repeated). Drop the
|
||||||
|
// duplicated point and keep the largest ring as the outline; the rest are
|
||||||
|
// holes. Clipper orients outer rings CCW and holes CW, which matches the
|
||||||
|
// `TracedShape` convention for `@tts/mesh`.
|
||||||
|
const rings = solution
|
||||||
|
.map(uncloseRing)
|
||||||
|
.sort((a, b) => Math.abs(signedArea(b)) - Math.abs(signedArea(a)));
|
||||||
|
const outline = rings[0]!;
|
||||||
|
const holes = rings.length > 1 ? rings.slice(1) : undefined;
|
||||||
|
|
||||||
|
return { outline, holes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset a single contour by `delta` scaled units. */
|
||||||
|
function offsetContour(ring: number[][], delta: number): ClipperLib.Paths {
|
||||||
|
if (ring.length < 3) return [];
|
||||||
|
const co = new ClipperLib.ClipperOffset(
|
||||||
|
ClipperLib.JoinType.jtMiter,
|
||||||
|
2, // miter limit: sharp corners are beveled beyond this ratio
|
||||||
|
);
|
||||||
|
co.AddPath(
|
||||||
|
ring.map((p) => ({ X: p[0]! * SCALE, Y: p[1]! * SCALE })),
|
||||||
|
ClipperLib.JoinType.jtMiter,
|
||||||
|
ClipperLib.EndType.etClosedPolygon,
|
||||||
|
);
|
||||||
|
const solution: ClipperLib.Path[] = [];
|
||||||
|
co.Execute(solution, delta * SCALE);
|
||||||
|
return solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the duplicated closing point of a closed ring. */
|
||||||
|
function uncloseRing(points: ClipperLib.Path): number[][] {
|
||||||
|
const out = points.map((p) => [p.X / SCALE, p.Y / SCALE]);
|
||||||
|
const first = out[0]!;
|
||||||
|
const last = out[out.length - 1]!;
|
||||||
|
if (first[0] === last[0] && first[1] === last[1]) out.pop();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Split a path's `d` attribute into rings, one per `M` subpath. */
|
/** Split a path's `d` attribute into rings, one per `M` subpath. */
|
||||||
function parseRings(d: string): number[][][] {
|
function parseRings(d: string): number[][][] {
|
||||||
const rings: number[][][] = [];
|
const rings: number[][][] = [];
|
||||||
|
|||||||
@@ -100,4 +100,48 @@ describe('trace route', () => {
|
|||||||
expect(result.mode).toBe('bw');
|
expect(result.mode).toBe('bw');
|
||||||
expect(result.shape.outline.length).toBeGreaterThan(3);
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies an inset offset to the shape', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request(
|
||||||
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=-2',
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.offset).toBe(-2);
|
||||||
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||||
|
// An inset of 2px on a 20px circle must be visibly smaller.
|
||||||
|
const extent = (pts: number[][]) => [
|
||||||
|
Math.min(...pts.map((p) => p[0]!)),
|
||||||
|
Math.max(...pts.map((p) => p[0]!)),
|
||||||
|
];
|
||||||
|
const [minX, maxX] = extent(result.shape.outline);
|
||||||
|
expect((maxX ?? 0) - (minX ?? 0)).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an outset offset to the shape', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request(
|
||||||
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=2',
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.offset).toBe(2);
|
||||||
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-numeric offset', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request(
|
||||||
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=abc',
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits offset when not requested', async () => {
|
||||||
|
stubFetch(await makePng());
|
||||||
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
||||||
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
expect(result.offset).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,7 +3,7 @@ import { serialize } from 'bson';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
import { traceRequestSchema, type TraceResult } from '@tts/shared';
|
||||||
import { parseSvgShape } from './svgShape.js';
|
import { offsetShape, parseSvgShape } from './svgShape.js';
|
||||||
|
|
||||||
// vtracer is a CommonJS package; load it via require so the wasm initializes
|
// vtracer is a CommonJS package; load it via require so the wasm initializes
|
||||||
// with the correct `__dirname`.
|
// with the correct `__dirname`.
|
||||||
@@ -36,7 +36,8 @@ app.get('/', async (c) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return c.json({ error: parsed.error.issues[0]?.message }, 400);
|
return c.json({ error: parsed.error.issues[0]?.message }, 400);
|
||||||
}
|
}
|
||||||
const { url, mode, threshold, format, simplify, maxColors } = parsed.data;
|
const { url, mode, threshold, format, simplify, maxColors, offset } =
|
||||||
|
parsed.data;
|
||||||
|
|
||||||
// Only allow http(s) to avoid SSRF via file://, etc.
|
// Only allow http(s) to avoid SSRF via file://, etc.
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
@@ -95,7 +96,12 @@ app.get('/', async (c) => {
|
|||||||
svg,
|
svg,
|
||||||
};
|
};
|
||||||
if (format === 'shape') {
|
if (format === 'shape') {
|
||||||
result.shape = parseSvgShape(svg);
|
let shape = parseSvgShape(svg);
|
||||||
|
if (offset !== undefined && offset !== 0) {
|
||||||
|
shape = offsetShape(shape, offset);
|
||||||
|
result.offset = offset;
|
||||||
|
}
|
||||||
|
result.shape = shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(Buffer.from(serialize(result)), {
|
return new Response(Buffer.from(serialize(result)), {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
|||||||
| `@hono/cors` | CORS middleware | `apps/proxy` |
|
| `@hono/cors` | CORS middleware | `apps/proxy` |
|
||||||
| `bson` | BSON deserialization of TTS save files | `packages/tts` |
|
| `bson` | BSON deserialization of TTS save files | `packages/tts` |
|
||||||
| `@visioncortex/vtracer` | Raster-to-SVG vectorization (wasm) | `apps/proxy` |
|
| `@visioncortex/vtracer` | Raster-to-SVG vectorization (wasm) | `apps/proxy` |
|
||||||
|
| `clipper-lib` | Polygon offsetting (inset/outset) for traced shapes | `apps/proxy` |
|
||||||
| `sharp` | Image decoding to RGBA | `apps/proxy` |
|
| `sharp` | Image decoding to RGBA | `apps/proxy` |
|
||||||
| `svgpath` | SVG path parsing for traced shapes | `apps/proxy` |
|
| `svgpath` | SVG path parsing for traced shapes | `apps/proxy` |
|
||||||
| `cheerio` | Workshop browse page scraping | `apps/proxy` |
|
| `cheerio` | Workshop browse page scraping | `apps/proxy` |
|
||||||
@@ -101,9 +102,9 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
|||||||
must stay isomorphic.
|
must stay isomorphic.
|
||||||
- **`bson` is Node-only** — used by the fetcher and the trace route, not the
|
- **`bson` is Node-only** — used by the fetcher and the trace route, not the
|
||||||
analysis layer.
|
analysis layer.
|
||||||
- **`@visioncortex/vtracer`, `sharp`, `svgpath` are backend-only** — the trace
|
- **`@visioncortex/vtracer`, `sharp`, `svgpath`, `clipper-lib` are
|
||||||
route lives in `apps/proxy`; they never appear in `packages/extract`, which
|
backend-only** — the trace route lives in `apps/proxy`; they never appear in
|
||||||
must stay isomorphic.
|
`packages/extract`, which must stay isomorphic.
|
||||||
- **`packages/extract` has zero external runtime deps** — it relies only on
|
- **`packages/extract` has zero external runtime deps** — it relies only on
|
||||||
platform `fetch` / `Blob`, keeping it portable to a future frontend.
|
platform `fetch` / `Blob`, keeping it portable to a future frontend.
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -164,9 +164,11 @@ CORS failures on common Workshop hosts would break the viewers.
|
|||||||
**Decision:** The proxy exposes `GET /trace?url=...`, which fetches an image,
|
**Decision:** The proxy exposes `GET /trace?url=...`, which fetches an image,
|
||||||
traces it into a vector shape, and returns the result BSON-encoded. Tracing is
|
traces it into a vector shape, and returns the result BSON-encoded. Tracing is
|
||||||
configurable: `mode` (`alpha` default, `bw`, `color`) selects how the region is
|
configurable: `mode` (`alpha` default, `bw`, `color`) selects how the region is
|
||||||
derived, and `format` (`shape` default, `svg`) selects the response. The
|
derived, `format` (`shape` default, `svg`) selects the response, and `offset`
|
||||||
`shape` format returns a parsed `{ outline, holes }` polygon matching
|
(optional, in pixels) insets (negative) or outsets (positive) the resulting
|
||||||
`@tts/mesh`'s `Shape` interface.
|
shape — used by the token viewer to shave the anti-aliased fringe off a traced
|
||||||
|
silhouette. The `shape` format returns a parsed `{ outline, holes }` polygon
|
||||||
|
matching `@tts/mesh`'s `Shape` interface.
|
||||||
|
|
||||||
**Context:** The user wants to build a mesh from an image (e.g. a token or tile
|
**Context:** The user wants to build a mesh from an image (e.g. a token or tile
|
||||||
art) using `@tts/mesh`. vtracer only returns SVG, but the mesh package consumes
|
art) using `@tts/mesh`. vtracer only returns SVG, but the mesh package consumes
|
||||||
@@ -178,4 +180,7 @@ feeding vtracer's `convertPixels`.
|
|||||||
**Alternatives considered:** Returning only the raw SVG and parsing in the web
|
**Alternatives considered:** Returning only the raw SVG and parsing in the web
|
||||||
app near `@tts/mesh`; tracing by color only (no alpha). Rejected — server-side
|
app near `@tts/mesh`; tracing by color only (no alpha). Rejected — server-side
|
||||||
parsing yields a shape the mesh package can consume directly, and alpha-based
|
parsing yields a shape the mesh package can consume directly, and alpha-based
|
||||||
tracing (the default) is the common case for token/tile art.
|
tracing (the default) is the common case for token/tile art. For shape
|
||||||
|
inset/outset, `clipper-lib` (Angus Johnson's Clipper ported to JS) was chosen
|
||||||
|
over the `polygon-offset` package because the latter crashes on degenerate
|
||||||
|
cases (collapse, hole closure) via a bug in its pinned Martinez dependency.
|
||||||
@@ -241,16 +241,23 @@ Hono server exposing search + fetch.
|
|||||||
headers, which would block three.js loaders in the browser; routing through
|
headers, which would block three.js loaders in the browser; routing through
|
||||||
the proxy makes those assets loadable. Only `http(s)` URLs are allowed.
|
the proxy makes those assets loadable. Only `http(s)` URLs are allowed.
|
||||||
- `routes/trace.ts`
|
- `routes/trace.ts`
|
||||||
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape` — fetch an
|
- `GET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...` —
|
||||||
image, trace it into a vector shape, and return the result BSON-encoded.
|
fetch an image, trace it into a vector shape, and return the result
|
||||||
`mode` is `alpha` (default), `bw`, or `color`; `format` is `shape`
|
BSON-encoded. `mode` is `alpha` (default), `bw`, or `color`; `format` is
|
||||||
(default) or `svg`. `shape` returns a parsed `{ outline, holes }` polygon
|
`shape` (default) or `svg`; `offset` (optional) insets (negative) or
|
||||||
matching `@tts/mesh`'s `Shape` interface, ready to extrude.
|
outsets (positive) the shape in pixels. `shape` returns a parsed
|
||||||
|
`{ outline, holes }` polygon matching `@tts/mesh`'s `Shape` interface,
|
||||||
|
ready to extrude.
|
||||||
- `routes/svgShape.ts`
|
- `routes/svgShape.ts`
|
||||||
- `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape`
|
- `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape`
|
||||||
(`{ outline, holes }`): flatten beziers to polylines, split subpaths into
|
(`{ outline, holes }`): flatten beziers to polylines, split subpaths into
|
||||||
rings, classify by winding (CCW outline / CW hole), and assign holes to
|
rings, classify by winding (CCW outline / CW hole), and assign holes to
|
||||||
their containing outline.
|
their containing outline.
|
||||||
|
- `offsetShape(shape, delta)` — inset/outset a `TracedShape` via
|
||||||
|
`clipper-lib` (Clipper miter joins); outline and holes offset in opposite
|
||||||
|
directions and are recombined with a boolean difference, so holes grow on
|
||||||
|
inset and shrink on outset. Collapsed shapes return an empty outline; a
|
||||||
|
split outline keeps the largest ring.
|
||||||
- `routes/health.ts`
|
- `routes/health.ts`
|
||||||
- `GET /health` — liveness.
|
- `GET /health` — liveness.
|
||||||
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
|
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
|
||||||
@@ -309,7 +316,7 @@ proxy API and `packages/extract` directly for analysis.
|
|||||||
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
||||||
| GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
|
| GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
|
||||||
| GET | `/asset?url=` | CORS-safe proxy for external assets | — |
|
| GET | `/asset?url=` | CORS-safe proxy for external assets | — |
|
||||||
| GET | `/trace?url=&mode=&format=` | Trace an image into a vector shape (BSON) | — |
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
|
||||||
|
|
||||||
\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl`
|
\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl`
|
||||||
query param is supplied.
|
query param is supplied.
|
||||||
|
|||||||
@@ -65,4 +65,6 @@ export const traceRequestSchema = z.object({
|
|||||||
format: traceFormatSchema.default('shape'),
|
format: traceFormatSchema.default('shape'),
|
||||||
simplify: z.coerce.number().min(0).optional(),
|
simplify: z.coerce.number().min(0).optional(),
|
||||||
maxColors: z.coerce.number().int().min(1).optional(),
|
maxColors: z.coerce.number().int().min(1).optional(),
|
||||||
|
/** Inset (negative) or outset (positive) the traced shape, in pixels. */
|
||||||
|
offset: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -138,4 +138,6 @@ export interface TraceResult {
|
|||||||
svg: string;
|
svg: string;
|
||||||
/** Parsed shape, present when `format` is `shape`. */
|
/** Parsed shape, present when `format` is `shape`. */
|
||||||
shape?: TracedShape;
|
shape?: TracedShape;
|
||||||
|
/** Inset (negative) or outset (positive) applied to the shape, in pixels. */
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
Generated
+16
@@ -32,6 +32,9 @@ importers:
|
|||||||
bson:
|
bson:
|
||||||
specifier: ^6.10.4
|
specifier: ^6.10.4
|
||||||
version: 6.10.4
|
version: 6.10.4
|
||||||
|
clipper-lib:
|
||||||
|
specifier: ^6.4.2
|
||||||
|
version: 6.4.2
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.6.14
|
specifier: ^4.6.14
|
||||||
version: 4.13.1
|
version: 4.13.1
|
||||||
@@ -45,6 +48,9 @@ importers:
|
|||||||
specifier: ^3.24.1
|
specifier: ^3.24.1
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/clipper-lib':
|
||||||
|
specifier: ^6.4.0
|
||||||
|
version: 6.4.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.2
|
specifier: ^22.10.2
|
||||||
version: 22.20.1
|
version: 22.20.1
|
||||||
@@ -802,6 +808,9 @@ packages:
|
|||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/clipper-lib@6.4.0':
|
||||||
|
resolution: {integrity: sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng==}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2':
|
'@types/deep-eql@4.0.2':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -927,6 +936,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
clipper-lib@6.4.2:
|
||||||
|
resolution: {integrity: sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ==}
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -2004,6 +2016,8 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/clipper-lib@6.4.0': {}
|
||||||
|
|
||||||
'@types/deep-eql@4.0.2': {}
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/draco3d@1.4.10': {}
|
'@types/draco3d@1.4.10': {}
|
||||||
@@ -2121,6 +2135,8 @@ snapshots:
|
|||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
|
|
||||||
|
clipper-lib@6.4.2: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user