import { Point } from '@iosevka/geometry/point'; // Segments are [start, end] lines or [start, control, end] quadratics of plain {x, y} points. const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }); const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y }); const dot = (a, b) => a.x * b.x + a.y * b.y; const cross = (a, b) => a.x * b.y - a.y * b.x; const norm = v => Math.hypot(v.x, v.y); function split(segment, t) { const a = lerp(segment[0], segment[1], t); if (segment.length === 2) return [[segment[0], a], [a, segment[1]]]; const b = lerp(segment[1], segment[2], t), m = lerp(a, b, t); return [[segment[0], a, m], [m, b, segment[2]]]; } // Closed form from fontTools' calcQuadraticArcLength. function length(segment) { if (segment.length === 2) return norm(sub(segment[1], segment[0])); const [p0, p1, p2] = segment; const d0 = sub(p1, p0), d1 = sub(p2, p1), d = sub(d1, d0); const n = { x: -d.y, y: d.x }, scale = norm(n); if (scale === 0) return norm(sub(p2, p0)); const origin = dot(n, d0); if (Math.abs(origin) < 1e-10) { if (dot(d0, d1) >= 0) return norm(sub(p2, p0)); const a = norm(d0), b = norm(d1); return (a * a + b * b) / (a + b); } const integral = x => x * Math.sqrt(x * x + 1) / 2 + Math.asinh(x) / 2; const x0 = dot(d, d0) / origin, x1 = dot(d, d1) / origin; return Math.abs(2 * (integral(x1) - integral(x0)) * origin / (scale * (x1 - x0))); } function parameterAtDistance(segment, distance) { let lo = 0, hi = 1; for (let i = 0; i < 18; i++) { const t = (lo + hi) / 2; if (length(split(segment, t)[0]) < distance) lo = t; else hi = t; } return (lo + hi) / 2; } function segmentsOf(contour) { const on = z => z.type !== Point.Type.Quadratic; const first = contour.findIndex(on); // TrueType implies an on-curve point between consecutive off-curve points. const points = first < 0 ? [{ ...lerp(contour.at(-1), contour[0], .5), type: Point.Type.Corner }, ...contour] : [...contour.slice(first), ...contour.slice(0, first)]; const segments = []; let current = { x: points[0].x, y: points[0].y }, control = null; for (const z of [...points.slice(1), points[0]]) { const p = { x: z.x, y: z.y }; if (!on(z)) { if (control) { const implied = lerp(control, p, .5); segments.push([current, control, implied]); current = implied; } control = p; continue; } if (control) segments.push([current, control, p]); else if (p.x !== current.x || p.y !== current.y) segments.push([current, p]); current = p; control = null; } return segments; } function pointsOf(segments) { const points = [Point.corner(segments[0][0].x, segments[0][0].y)]; for (const segment of segments) { if (segment.length === 3) points.push(new Point(Point.Type.Quadratic, segment[1].x, segment[1].y)); points.push(Point.corner(segment.at(-1).x, segment.at(-1).y)); } const last = points.at(-1); if (last.x === points[0].x && last.y === points[0].y) points.pop(); return points; } function segmentBounds(segment) { const values = axis => { const v = segment.map(p => p[axis]); if (v.length === 3) { const t = (v[0] - v[1]) / (v[0] - 2 * v[1] + v[2]); if (t > 0 && t < 1) v.push((1 - t) * (1 - t) * v[0] + 2 * t * (1 - t) * v[1] + t * t * v[2]); v.splice(1, 1); } return v; }; const xs = values('x'), ys = values('y'); return { xMin: Math.min(...xs), yMin: Math.min(...ys), xMax: Math.max(...xs), yMax: Math.max(...ys) }; } function boundsOfSegments(segments) { const boxes = segments.map(segmentBounds); if (!boxes.length) return null; return { xMin: Math.min(...boxes.map(b => b.xMin)), yMin: Math.min(...boxes.map(b => b.yMin)), xMax: Math.max(...boxes.map(b => b.xMax)), yMax: Math.max(...boxes.map(b => b.yMax)), }; } export function bounds(contours) { return boundsOfSegments(contours.flatMap(segmentsOf)); } function area(contours) { let total = 0; for (const segment of contours.flat()) { const [p0, p1] = [segment[0], segment.at(-1)]; if (segment.length === 3) total -= cross(sub(p1, p0), sub(segment[1], p0)) / 3; total -= (p1.x - p0.x) * (p1.y + p0.y) / 2; } return total; } function roundedContour(segments, radius) { if (segments.length < 2 || radius < .25) return segments; const n = segments.length; const corners = new Map(); segments.forEach((prev, i) => { const next = segments[(i + 1) % n]; const incoming = sub(prev.at(-1), prev.at(-2)), outgoing = sub(next[1], next[0]); const scale = norm(incoming) * norm(outgoing); if (norm(incoming) < 1e-6 || norm(outgoing) < 1e-6) return; const turn = { cos: dot(incoming, outgoing) / scale, sin: cross(incoming, outgoing) / scale }; if (Math.abs(turn.sin) > .15 || turn.cos < 0) corners.set((i + 1) % n, turn); }); if (!corners.size) return segments; // Work across smooth segment boundaries: a terminal may start with a tiny // straight stub that must not constrain the radius of the whole corner. const starts = [...corners.keys()].sort((a, b) => a - b); const runs = starts.map((start, i) => { const count = ((starts[(i + 1) % starts.length] - start) % n + n) % n || n; return Array.from({ length: count }, (_, j) => segments[(start + j) % n]); }); const lengths = runs.map(run => run.reduce((sum, s) => sum + length(s), 0)); const trims = starts.map((start, i) => { const turn = corners.get(start); // TrueType filled contours run clockwise; preserve concave joins. if (turn.sin >= -.15) return 0; const angle = Math.acos(Math.max(-1, Math.min(1, turn.cos))); return Math.min(radius * Math.tan(angle / 2), lengths.at(i - 1) * .4, lengths[i] * .4); }); const pieces = runs.map((run, i) => { const lo = trims[i], hi = lengths[i] - trims[(i + 1) % runs.length]; const kept = []; let cursor = 0; for (const segment of run) { const size = length(segment); const a = Math.max(0, lo - cursor), b = Math.min(size, hi - cursor); cursor += size; if (b <= a) continue; const t0 = a ? parameterAtDistance(segment, a) : 0; const t1 = b < size ? parameterAtDistance(segment, b) : 1; const piece = t1 < 1 ? split(segment, t1)[0] : segment; kept.push(t0 ? split(piece, t0 / t1)[1] : piece); } return kept; }); const result = []; for (const [i, run] of pieces.entries()) { result.push(...run); if (!trims[(i + 1) % runs.length]) continue; const prev = run.at(-1), next = pieces[(i + 1) % pieces.length][0]; const a = prev.at(-1), b = next[0]; const u = sub(a, prev.at(-2)), v = sub(next[1], b); const det = cross(u, v); // Tight curved features can turn past the tangent intersection. // Back off rather than introducing a loop or a reversed fillet. if (det >= -1e-9) return roundedContour(segments, radius / 2); const k = cross(sub(b, a), v) / det; const control = { x: a.x + u.x * k, y: a.y + u.y * k }; if (dot(sub(control, a), u) <= 0 || dot(sub(b, control), v) <= 0) return roundedContour(segments, radius / 2); result.push([a, control, b]); } const before = boundsOfSegments(segments), after = boundsOfSegments(result); if (after.xMin < before.xMin - .01 || after.yMin < before.yMin - .01 || after.xMax > before.xMax + .01 || after.yMax > before.yMax + .01) { return roundedContour(segments, radius / 2); } return result; } // Round convex corners without rounding counters or terminal widths. export function roundCorners(contours, radius) { const original = contours.map(segmentsOf).filter(segments => segments.length); const minimum = Math.abs(area(original)) * .9; for (let trial = radius; trial >= .25; trial /= 2) { const rounded = original.map(segments => roundedContour(segments, trial)); if (Math.abs(area(rounded)) >= minimum) return rounded.map(pointsOf); } return contours; }