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:
2026-08-08 15:37:27 +08:00
parent c7f4bd03fd
commit 9094ad58e0
12 changed files with 284 additions and 17 deletions
+96 -1
View File
@@ -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('<svg></svg>');
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 });
});
});
+86
View File
@@ -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[][][] = [];
+44
View File
@@ -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();
});
});
+9 -3
View File
@@ -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)), {