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:
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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[][][] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)), {
|
||||
|
||||
Reference in New Issue
Block a user