diff --git a/README.md b/README.md index 9eb4ff5..47b8b6d 100644 --- a/README.md +++ b/README.md @@ -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 | `/items/:id` | Full parsed `TTSMod` (BSON save) | | 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 diff --git a/apps/proxy/package.json b/apps/proxy/package.json index c71ecf2..8201c78 100644 --- a/apps/proxy/package.json +++ b/apps/proxy/package.json @@ -18,12 +18,14 @@ "@tts/tts": "workspace:*", "@visioncortex/vtracer": "1.0.0-alpha.3", "bson": "^6.10.4", + "clipper-lib": "^6.4.2", "hono": "^4.6.14", "sharp": "^0.35.3", "svgpath": "^2.6.0", "zod": "^3.24.1" }, "devDependencies": { + "@types/clipper-lib": "^6.4.0", "@types/node": "^22.10.2", "tsx": "^4.19.2", "typescript": "^5.7.2" diff --git a/apps/proxy/src/routes/svgShape.test.ts b/apps/proxy/src/routes/svgShape.test.ts index bb77c47..4838fae 100644 --- a/apps/proxy/src/routes/svgShape.test.ts +++ b/apps/proxy/src/routes/svgShape.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseSvgShape } from './svgShape.js'; +import { offsetShape, parseSvgShape } from './svgShape.js'; describe('parseSvgShape', () => { it('parses a simple closed outline', () => { @@ -32,4 +32,99 @@ describe('parseSvgShape', () => { const shape = parseSvgShape(''); 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 }); + }); }); \ No newline at end of file diff --git a/apps/proxy/src/routes/svgShape.ts b/apps/proxy/src/routes/svgShape.ts index 663937b..0e1fd7d 100644 --- a/apps/proxy/src/routes/svgShape.ts +++ b/apps/proxy/src/routes/svgShape.ts @@ -1,3 +1,4 @@ +import * as ClipperLib from 'clipper-lib'; import svgpath from 'svgpath'; 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. */ function parseRings(d: string): number[][][] { const rings: number[][][] = []; diff --git a/apps/proxy/src/routes/trace.test.ts b/apps/proxy/src/routes/trace.test.ts index d575da5..fc6f62f 100644 --- a/apps/proxy/src/routes/trace.test.ts +++ b/apps/proxy/src/routes/trace.test.ts @@ -100,4 +100,48 @@ describe('trace route', () => { expect(result.mode).toBe('bw'); 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(); + }); }); \ No newline at end of file diff --git a/apps/proxy/src/routes/trace.ts b/apps/proxy/src/routes/trace.ts index 5faa094..ada1c02 100644 --- a/apps/proxy/src/routes/trace.ts +++ b/apps/proxy/src/routes/trace.ts @@ -3,7 +3,7 @@ import { serialize } from 'bson'; import sharp from 'sharp'; import { createRequire } from 'module'; 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 // with the correct `__dirname`. @@ -36,7 +36,8 @@ app.get('/', async (c) => { if (!parsed.success) { 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. const parsedUrl = new URL(url); @@ -95,7 +96,12 @@ app.get('/', async (c) => { svg, }; 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)), { diff --git a/docs/architecture.md b/docs/architecture.md index a123325..2a84c91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared | `@hono/cors` | CORS middleware | `apps/proxy` | | `bson` | BSON deserialization of TTS save files | `packages/tts` | | `@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` | | `svgpath` | SVG path parsing for traced shapes | `apps/proxy` | | `cheerio` | Workshop browse page scraping | `apps/proxy` | @@ -101,9 +102,9 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared must stay isomorphic. - **`bson` is Node-only** — used by the fetcher and the trace route, not the analysis layer. -- **`@visioncortex/vtracer`, `sharp`, `svgpath` are backend-only** — the trace - route lives in `apps/proxy`; they never appear in `packages/extract`, which - must stay isomorphic. +- **`@visioncortex/vtracer`, `sharp`, `svgpath`, `clipper-lib` are + backend-only** — the trace route lives in `apps/proxy`; they never appear in + `packages/extract`, which must stay isomorphic. - **`packages/extract` has zero external runtime deps** — it relies only on platform `fetch` / `Blob`, keeping it portable to a future frontend. diff --git a/docs/decisions.md b/docs/decisions.md index 3cf169e..ef59d8c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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, 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 -derived, and `format` (`shape` default, `svg`) selects the response. The -`shape` format returns a parsed `{ outline, holes }` polygon matching -`@tts/mesh`'s `Shape` interface. +derived, `format` (`shape` default, `svg`) selects the response, and `offset` +(optional, in pixels) insets (negative) or outsets (positive) the resulting +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 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 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 -tracing (the default) is the common case for token/tile art. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index cd1b835..fe04eae 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -241,16 +241,23 @@ Hono server exposing search + fetch. headers, which would block three.js loaders in the browser; routing through the proxy makes those assets loadable. Only `http(s)` URLs are allowed. - `routes/trace.ts` - - `GET /trace?url=...&mode=alpha&threshold=128&format=shape` — fetch an - image, trace it into a vector shape, and return the result BSON-encoded. - `mode` is `alpha` (default), `bw`, or `color`; `format` is `shape` - (default) or `svg`. `shape` returns a parsed `{ outline, holes }` polygon - matching `@tts/mesh`'s `Shape` interface, ready to extrude. + - `GET /trace?url=...&mode=alpha&threshold=128&format=shape&offset=...` — + fetch an image, trace it into a vector shape, and return the result + BSON-encoded. `mode` is `alpha` (default), `bw`, or `color`; `format` is + `shape` (default) or `svg`; `offset` (optional) insets (negative) or + 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` - `parseSvgShape(svg)` — parse a vtracer SVG into a `TracedShape` (`{ outline, holes }`): flatten beziers to polylines, split subpaths into rings, classify by winding (CCW outline / CW hole), and assign holes to 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` - `GET /health` — liveness. - `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/file` | Raw save bytes, filename from header | key* | | 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` query param is supplied. diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index dfa6f55..8796231 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -65,4 +65,6 @@ export const traceRequestSchema = z.object({ format: traceFormatSchema.default('shape'), simplify: z.coerce.number().min(0).optional(), maxColors: z.coerce.number().int().min(1).optional(), + /** Inset (negative) or outset (positive) the traced shape, in pixels. */ + offset: z.coerce.number().optional(), }); \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8cf7b33..8886b4a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -138,4 +138,6 @@ export interface TraceResult { svg: string; /** Parsed shape, present when `format` is `shape`. */ shape?: TracedShape; + /** Inset (negative) or outset (positive) applied to the shape, in pixels. */ + offset?: number; } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68386f8..7d117cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: bson: specifier: ^6.10.4 version: 6.10.4 + clipper-lib: + specifier: ^6.4.2 + version: 6.4.2 hono: specifier: ^4.6.14 version: 4.13.1 @@ -45,6 +48,9 @@ importers: specifier: ^3.24.1 version: 3.25.76 devDependencies: + '@types/clipper-lib': + specifier: ^6.4.0 + version: 6.4.0 '@types/node': specifier: ^22.10.2 version: 22.20.1 @@ -802,6 +808,9 @@ packages: '@types/chai@5.2.3': 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': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -927,6 +936,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + clipper-lib@6.4.2: + resolution: {integrity: sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2004,6 +2016,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/clipper-lib@6.4.0': {} + '@types/deep-eql@4.0.2': {} '@types/draco3d@1.4.10': {} @@ -2121,6 +2135,8 @@ snapshots: chai@6.2.2: {} + clipper-lib@6.4.2: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {}