The compiled server runs Node's native ESM loader, where the CommonJS namespace object nests the real exports under 'default', so 'import * as ClipperLib' left JoinType undefined and crashed with offset. Match the existing vtracer pattern and require() the package instead.
276 lines
9.1 KiB
TypeScript
276 lines
9.1 KiB
TypeScript
import { createRequire } from 'module';
|
|
import svgpath from 'svgpath';
|
|
import type { TracedShape } from '@tts/shared';
|
|
|
|
// clipper-lib is a CommonJS package; load it via require so the enums and
|
|
// classes resolve under Node's native ESM loader without a `default` unwrap.
|
|
const require = createRequire(import.meta.url);
|
|
const ClipperLib = require('clipper-lib') as typeof import('clipper-lib');
|
|
|
|
/** Number of samples per cubic bezier when flattening curves to polylines. */
|
|
const CURVE_STEPS = 12;
|
|
|
|
/**
|
|
* Parse an SVG produced by vtracer into a `TracedShape` (outline + holes),
|
|
* matching the `Shape` interface in `@tts/mesh` so the result can be extruded
|
|
* directly. vtracer emits one `<path>` per traced region; a region with holes
|
|
* is a single path containing multiple subpaths (one per `M` command).
|
|
*
|
|
* Subpaths are classified by winding: counter-clockwise rings become the
|
|
* outline, clockwise rings become holes. Holes are assigned to the smallest
|
|
* outline that contains them (point-in-polygon on the first vertex).
|
|
*/
|
|
export function parseSvgShape(svg: string): TracedShape {
|
|
const paths = [...svg.matchAll(/<path\s+d="([^"]*)"/g)].map((m) => m[1]!);
|
|
|
|
const rings = paths.flatMap(parseRings);
|
|
if (rings.length === 0) {
|
|
return { outline: [] };
|
|
}
|
|
|
|
const outlines = rings.filter((r) => signedArea(r) > 0);
|
|
const holes = rings.filter((r) => signedArea(r) <= 0);
|
|
|
|
// Assign each hole to the smallest outline that contains its first vertex.
|
|
const assigned = new Map<number, number[][][]>();
|
|
for (const hole of holes) {
|
|
const p = hole[0]! as [number, number];
|
|
let best: number | null = null;
|
|
let bestArea = Infinity;
|
|
for (let i = 0; i < outlines.length; i++) {
|
|
const area = Math.abs(signedArea(outlines[i]!));
|
|
if (area < bestArea && pointInPolygon(p, outlines[i]!)) {
|
|
best = i;
|
|
bestArea = area;
|
|
}
|
|
}
|
|
if (best !== null) {
|
|
const list = assigned.get(best) ?? [];
|
|
list.push(hole);
|
|
assigned.set(best, list);
|
|
}
|
|
}
|
|
|
|
// The largest outline is the outer boundary; any remaining outlines are
|
|
// treated as additional (disjoint) regions, which vtracer emits as separate
|
|
// paths anyway. Keep the first/largest as the primary outline.
|
|
const primary = outlines.reduce((a, b) =>
|
|
Math.abs(signedArea(a)) >= Math.abs(signedArea(b)) ? a : b,
|
|
);
|
|
|
|
return {
|
|
outline: primary,
|
|
holes: assigned.get(outlines.indexOf(primary)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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[][][] = [];
|
|
let cur: number[][] | null = null;
|
|
let pen: [number, number] = [0, 0];
|
|
let ctrl: [number, number] | null = null;
|
|
|
|
svgpath(d)
|
|
.abs()
|
|
.iterate((seg) => {
|
|
const cmd = seg[0];
|
|
switch (cmd) {
|
|
case 'M':
|
|
if (cur && cur.length) rings.push(cur);
|
|
cur = [[seg[1]!, seg[2]!]];
|
|
pen = [seg[1]!, seg[2]!];
|
|
ctrl = null;
|
|
break;
|
|
case 'L':
|
|
cur?.push([seg[1]!, seg[2]!]);
|
|
pen = [seg[1]!, seg[2]!];
|
|
ctrl = null;
|
|
break;
|
|
case 'C': {
|
|
const p1: [number, number] = [seg[1]!, seg[2]!];
|
|
const p2: [number, number] = [seg[3]!, seg[4]!];
|
|
const p3: [number, number] = [seg[5]!, seg[6]!];
|
|
flattenCubic(pen, p1, p2, p3, cur!);
|
|
pen = p3;
|
|
ctrl = p2;
|
|
break;
|
|
}
|
|
case 'Q': {
|
|
// Approximate a quadratic bezier with a cubic.
|
|
const q1: [number, number] = [seg[1]!, seg[2]!];
|
|
const q2: [number, number] = [seg[3]!, seg[4]!];
|
|
const p1: [number, number] = [
|
|
pen[0] + (2 / 3) * (q1[0] - pen[0]),
|
|
pen[1] + (2 / 3) * (q1[1] - pen[1]),
|
|
];
|
|
const p2: [number, number] = [
|
|
q2[0] + (2 / 3) * (q1[0] - q2[0]),
|
|
q2[1] + (2 / 3) * (q1[1] - q2[1]),
|
|
];
|
|
flattenCubic(pen, p1, p2, q2, cur!);
|
|
pen = q2;
|
|
ctrl = q1;
|
|
break;
|
|
}
|
|
case 'Z':
|
|
case 'z':
|
|
cur?.push([pen[0], pen[1]]);
|
|
ctrl = null;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
if (cur !== null) rings.push(cur);
|
|
|
|
// Drop the duplicate closing vertex (first == last).
|
|
for (const ring of rings) {
|
|
const first = ring[0]!;
|
|
const last = ring[ring.length - 1]!;
|
|
if (
|
|
Math.abs(first[0]! - last[0]!) < 1e-6 &&
|
|
Math.abs(first[1]! - last[1]!) < 1e-6
|
|
) {
|
|
ring.pop();
|
|
}
|
|
}
|
|
|
|
return rings;
|
|
}
|
|
|
|
/** Sample a cubic bezier into `CURVE_STEPS` points appended to `out`. */
|
|
function flattenCubic(
|
|
p0: [number, number],
|
|
p1: [number, number],
|
|
p2: [number, number],
|
|
p3: [number, number],
|
|
out: number[][],
|
|
): void {
|
|
for (let i = 1; i <= CURVE_STEPS; i++) {
|
|
const t = i / CURVE_STEPS;
|
|
const mt = 1 - t;
|
|
const x =
|
|
mt * mt * mt * p0[0] +
|
|
3 * mt * mt * t * p1[0] +
|
|
3 * mt * t * t * p2[0] +
|
|
t * t * t * p3[0];
|
|
const y =
|
|
mt * mt * mt * p0[1] +
|
|
3 * mt * mt * t * p1[1] +
|
|
3 * mt * t * t * p2[1] +
|
|
t * t * t * p3[1];
|
|
out.push([x, y]);
|
|
}
|
|
}
|
|
|
|
/** Signed area of a polygon; positive means counter-clockwise. */
|
|
function signedArea(points: number[][]): number {
|
|
let area = 0;
|
|
for (let i = 0; i < points.length; i++) {
|
|
const [x1, y1] = points[i]!;
|
|
const [x2, y2] = points[(i + 1) % points.length]!;
|
|
area += x1! * y2! - x2! * y1!;
|
|
}
|
|
return area / 2;
|
|
}
|
|
|
|
/** Ray-casting point-in-polygon test. */
|
|
function pointInPolygon(p: [number, number], polygon: number[][]): boolean {
|
|
let inside = false;
|
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
const [xi, yi] = polygon[i]!;
|
|
const [xj, yj] = polygon[j]!;
|
|
const intersects =
|
|
yi! > p[1] !== yj! > p[1] &&
|
|
p[0] < ((xj! - xi!) * (p[1] - yi!)) / (yj! - yi!) + xi!;
|
|
if (intersects) inside = !inside;
|
|
}
|
|
return inside;
|
|
} |