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:
@@ -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[][][] = [];
|
||||
|
||||
Reference in New Issue
Block a user