char/iocgn-mono

iosevka variant inspired by Simple Köln-Bonn

git clone https://git.t4t.associates/char/iocgn-mono

Charlotte Somsimplify pipeline even more (less python!! :D)2ba32a9

main
8.3 KiB202 linesraw
1import { Point } from '@iosevka/geometry/point';
2
3// Segments are [start, end] lines or [start, control, end] quadratics of plain {x, y} points.
4
5const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
6const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
7const dot = (a, b) => a.x * b.x + a.y * b.y;
8const cross = (a, b) => a.x * b.y - a.y * b.x;
9const norm = v => Math.hypot(v.x, v.y);
10
11function split(segment, t) {
12    const a = lerp(segment[0], segment[1], t);
13    if (segment.length === 2) return [[segment[0], a], [a, segment[1]]];
14    const b = lerp(segment[1], segment[2], t), m = lerp(a, b, t);
15    return [[segment[0], a, m], [m, b, segment[2]]];
16}
17
18// Closed form from fontTools' calcQuadraticArcLength.
19function length(segment) {
20    if (segment.length === 2) return norm(sub(segment[1], segment[0]));
21    const [p0, p1, p2] = segment;
22    const d0 = sub(p1, p0), d1 = sub(p2, p1), d = sub(d1, d0);
23    const n = { x: -d.y, y: d.x }, scale = norm(n);
24    if (scale === 0) return norm(sub(p2, p0));
25    const origin = dot(n, d0);
26    if (Math.abs(origin) < 1e-10) {
27        if (dot(d0, d1) >= 0) return norm(sub(p2, p0));
28        const a = norm(d0), b = norm(d1);
29        return (a * a + b * b) / (a + b);
30    }
31    const integral = x => x * Math.sqrt(x * x + 1) / 2 + Math.asinh(x) / 2;
32    const x0 = dot(d, d0) / origin, x1 = dot(d, d1) / origin;
33    return Math.abs(2 * (integral(x1) - integral(x0)) * origin / (scale * (x1 - x0)));
34}
35
36function parameterAtDistance(segment, distance) {
37    let lo = 0, hi = 1;
38    for (let i = 0; i < 18; i++) {
39        const t = (lo + hi) / 2;
40        if (length(split(segment, t)[0]) < distance) lo = t;
41        else hi = t;
42    }
43    return (lo + hi) / 2;
44}
45
46function segmentsOf(contour) {
47    const on = z => z.type !== Point.Type.Quadratic;
48    const first = contour.findIndex(on);
49    // TrueType implies an on-curve point between consecutive off-curve points.
50    const points = first < 0
51        ? [{ ...lerp(contour.at(-1), contour[0], .5), type: Point.Type.Corner }, ...contour]
52        : [...contour.slice(first), ...contour.slice(0, first)];
53    const segments = [];
54    let current = { x: points[0].x, y: points[0].y }, control = null;
55    for (const z of [...points.slice(1), points[0]]) {
56        const p = { x: z.x, y: z.y };
57        if (!on(z)) {
58            if (control) {
59                const implied = lerp(control, p, .5);
60                segments.push([current, control, implied]);
61                current = implied;
62            }
63            control = p;
64            continue;
65        }
66        if (control) segments.push([current, control, p]);
67        else if (p.x !== current.x || p.y !== current.y) segments.push([current, p]);
68        current = p;
69        control = null;
70    }
71    return segments;
72}
73
74function pointsOf(segments) {
75    const points = [Point.corner(segments[0][0].x, segments[0][0].y)];
76    for (const segment of segments) {
77        if (segment.length === 3) points.push(new Point(Point.Type.Quadratic, segment[1].x, segment[1].y));
78        points.push(Point.corner(segment.at(-1).x, segment.at(-1).y));
79    }
80    const last = points.at(-1);
81    if (last.x === points[0].x && last.y === points[0].y) points.pop();
82    return points;
83}
84
85function segmentBounds(segment) {
86    const values = axis => {
87        const v = segment.map(p => p[axis]);
88        if (v.length === 3) {
89            const t = (v[0] - v[1]) / (v[0] - 2 * v[1] + v[2]);
90            if (t > 0 && t < 1) v.push((1 - t) * (1 - t) * v[0] + 2 * t * (1 - t) * v[1] + t * t * v[2]);
91            v.splice(1, 1);
92        }
93        return v;
94    };
95    const xs = values('x'), ys = values('y');
96    return { xMin: Math.min(...xs), yMin: Math.min(...ys), xMax: Math.max(...xs), yMax: Math.max(...ys) };
97}
98
99function boundsOfSegments(segments) {
100    const boxes = segments.map(segmentBounds);
101    if (!boxes.length) return null;
102    return {
103        xMin: Math.min(...boxes.map(b => b.xMin)), yMin: Math.min(...boxes.map(b => b.yMin)),
104        xMax: Math.max(...boxes.map(b => b.xMax)), yMax: Math.max(...boxes.map(b => b.yMax)),
105    };
106}
107
108export function bounds(contours) {
109    return boundsOfSegments(contours.flatMap(segmentsOf));
110}
111
112function area(contours) {
113    let total = 0;
114    for (const segment of contours.flat()) {
115        const [p0, p1] = [segment[0], segment.at(-1)];
116        if (segment.length === 3) total -= cross(sub(p1, p0), sub(segment[1], p0)) / 3;
117        total -= (p1.x - p0.x) * (p1.y + p0.y) / 2;
118    }
119    return total;
120}
121
122function roundedContour(segments, radius) {
123    if (segments.length < 2 || radius < .25) return segments;
124    const n = segments.length;
125    const corners = new Map();
126    segments.forEach((prev, i) => {
127        const next = segments[(i + 1) % n];
128        const incoming = sub(prev.at(-1), prev.at(-2)), outgoing = sub(next[1], next[0]);
129        const scale = norm(incoming) * norm(outgoing);
130        if (norm(incoming) < 1e-6 || norm(outgoing) < 1e-6) return;
131        const turn = { cos: dot(incoming, outgoing) / scale, sin: cross(incoming, outgoing) / scale };
132        if (Math.abs(turn.sin) > .15 || turn.cos < 0) corners.set((i + 1) % n, turn);
133    });
134    if (!corners.size) return segments;
135
136    // Work across smooth segment boundaries: a terminal may start with a tiny
137    // straight stub that must not constrain the radius of the whole corner.
138    const starts = [...corners.keys()].sort((a, b) => a - b);
139    const runs = starts.map((start, i) => {
140        const count = ((starts[(i + 1) % starts.length] - start) % n + n) % n || n;
141        return Array.from({ length: count }, (_, j) => segments[(start + j) % n]);
142    });
143    const lengths = runs.map(run => run.reduce((sum, s) => sum + length(s), 0));
144    const trims = starts.map((start, i) => {
145        const turn = corners.get(start);
146        // TrueType filled contours run clockwise; preserve concave joins.
147        if (turn.sin >= -.15) return 0;
148        const angle = Math.acos(Math.max(-1, Math.min(1, turn.cos)));
149        return Math.min(radius * Math.tan(angle / 2), lengths.at(i - 1) * .4, lengths[i] * .4);
150    });
151
152    const pieces = runs.map((run, i) => {
153        const lo = trims[i], hi = lengths[i] - trims[(i + 1) % runs.length];
154        const kept = [];
155        let cursor = 0;
156        for (const segment of run) {
157            const size = length(segment);
158            const a = Math.max(0, lo - cursor), b = Math.min(size, hi - cursor);
159            cursor += size;
160            if (b <= a) continue;
161            const t0 = a ? parameterAtDistance(segment, a) : 0;
162            const t1 = b < size ? parameterAtDistance(segment, b) : 1;
163            const piece = t1 < 1 ? split(segment, t1)[0] : segment;
164            kept.push(t0 ? split(piece, t0 / t1)[1] : piece);
165        }
166        return kept;
167    });
168
169    const result = [];
170    for (const [i, run] of pieces.entries()) {
171        result.push(...run);
172        if (!trims[(i + 1) % runs.length]) continue;
173        const prev = run.at(-1), next = pieces[(i + 1) % pieces.length][0];
174        const a = prev.at(-1), b = next[0];
175        const u = sub(a, prev.at(-2)), v = sub(next[1], b);
176        const det = cross(u, v);
177        // Tight curved features can turn past the tangent intersection.
178        // Back off rather than introducing a loop or a reversed fillet.
179        if (det >= -1e-9) return roundedContour(segments, radius / 2);
180        const k = cross(sub(b, a), v) / det;
181        const control = { x: a.x + u.x * k, y: a.y + u.y * k };
182        if (dot(sub(control, a), u) <= 0 || dot(sub(b, control), v) <= 0) return roundedContour(segments, radius / 2);
183        result.push([a, control, b]);
184    }
185    const before = boundsOfSegments(segments), after = boundsOfSegments(result);
186    if (after.xMin < before.xMin - .01 || after.yMin < before.yMin - .01 ||
187        after.xMax > before.xMax + .01 || after.yMax > before.yMax + .01) {
188        return roundedContour(segments, radius / 2);
189    }
190    return result;
191}
192
193// Round convex corners without rounding counters or terminal widths.
194export function roundCorners(contours, radius) {
195    const original = contours.map(segmentsOf).filter(segments => segments.length);
196    const minimum = Math.abs(area(original)) * .9;
197    for (let trial = radius; trial >= .25; trial /= 2) {
198        const rounded = original.map(segments => roundedContour(segments, trial));
199        if (Math.abs(area(rounded)) >= minimum) return rounded.map(pointsOf);
200    }
201    return contours;
202}