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 `` 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(/ 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(); 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; }