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
16.6 KiB304 linesraw
1import { BooleanGeometry } from '@iosevka/geometry';
2import * as TypoGeom from 'typo-geom';
3import { arc, capsule, outline, reverseContour } from './iocgn-shapes.mjs';
4
5const PI = Math.PI;
6
7function rect(l, b, r, t) {
8    return [[l, b], [l, t], [r, t], [r, b]];
9}
10
11function strokeSegment(a, b, stroke) {
12    const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
13    const ux = (b[0] - a[0]) / length, uy = (b[1] - a[1]) / length;
14    const nx = -uy * stroke / 2, ny = ux * stroke / 2;
15    // Overlap tangent joins slightly; exact touching can leave zero-area contours.
16    a = [a[0] - ux, a[1] - uy];
17    b = [b[0] + ux, b[1] + uy];
18    return [[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
19        [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]];
20}
21
22function roundedPolygon(points, radius) {
23    const contour = [];
24    for (let i = 0; i < points.length; i++) {
25        const p = points[i], prev = points[(i + points.length - 1) % points.length];
26        const next = points[(i + 1) % points.length];
27        const a = Math.hypot(prev[0] - p[0], prev[1] - p[1]);
28        const b = Math.hypot(next[0] - p[0], next[1] - p[1]);
29        const u = [(prev[0] - p[0]) / a, (prev[1] - p[1]) / a];
30        const v = [(next[0] - p[0]) / b, (next[1] - p[1]) / b];
31        const half = Math.acos(u[0] * v[0] + u[1] * v[1]) / 2;
32        const trim = Math.min(radius / Math.tan(half), a * .49, b * .49);
33        const r = trim * Math.tan(half), length = Math.hypot(u[0] + v[0], u[1] + v[1]);
34        const cx = p[0] + (u[0] + v[0]) / length * r / Math.sin(half);
35        const cy = p[1] + (u[1] + v[1]) / length * r / Math.sin(half);
36        const entry = [p[0] + u[0] * trim, p[1] + u[1] * trim];
37        const from = Math.atan2(entry[1] - cy, entry[0] - cx);
38        const to = Math.atan2(p[1] + v[1] * trim - cy, p[0] + v[0] * trim - cx);
39        const turn = Math.atan2(Math.sin(to - from), Math.cos(to - from));
40        contour.push(entry, ...arc(cx, cy, r, from, from + turn));
41    }
42    return contour;
43}
44
45// Dimensions belong to the 500-cell proof, not the proportional reference's advances.
46export function asciiContours(width, s) {
47    const weight = (s - 75) / 30;
48    const mix = (a, b) => a + (b - a) * weight;
49    const cap = 735, bottom = -120, mid = Math.round((cap + bottom) / 2);
50    const shapes = {};
51
52    {
53        const w = mix(357, 378), l = 40 - w / 2, r = l + w;
54        const arm = r - mix(23, 31), bar = cap / 2;
55        shapes.F = [[[l, 0], [l, cap], [r, cap], [r, cap - s],
56            [l + s, cap - s], [l + s, bar + s / 2], [arm, bar + s / 2],
57            [arm, bar - s / 2], [l + s, bar - s / 2], [l + s, 0]]];
58    }
59    {
60        const radius = mix(156.5, 178.5), cx = -15, cy = radius + mix(-12, -10);
61        const l = cx - radius, r = cx + radius, barLeft = l + mix(22, 42);
62        const end = mix(178, 220);
63        shapes.J = [[[r, cap], [r, cy], ...arc(cx, cy, radius, 0, -PI),
64            [l, end], [l + s, end], [l + s, cy], ...arc(cx, cy, radius - s, PI, 2 * PI),
65            [r - s, cap - s], [barLeft, cap - s], [barLeft, cap]]];
66    }
67    {
68        const w = Math.min(472, mix(363, 441)), l = 12 - s / 2, r = l + s;
69        const tip = mix(-190, -214), flag = .94 * s * Math.SQRT2;
70        shapes['1'] = [rect(-w / 2, 0, w / 2, s), rect(l, 0, r, cap),
71            [[-24, cap], [tip, cap + tip + 24], [tip + flag / 2, cap + tip + 24 - flag / 2],
72                [l, cap + l + 24 - flag], [r, cap]].reverse()];
73    }
74    {
75        const r = mix(200.5, 204.5), foot = mix(-54, -35), inner = foot - s;
76        shapes['7'] = [[[-r, cap], [r, cap], [r, cap - s],
77            [r, 570, r - 65, 480, r - 120, 385],
78            [r - 185, 272, foot + 23, 143, foot, 0], [inner, 0],
79            [inner + 23, 150, inner + 78, 300, r - 180, 405],
80            [r - 115, 517, r - s - 2, 610, r - s, cap - s], [-r, cap - s]]];
81    }
82
83    // Slashes retain Airport's Regular weight as well as its angle and vertical extent.
84    {
85        const slope = .419;
86        const stroke = s < 75 ? 67.17 * s / 75 : s > 105 ? 105.07 * s / 105 : mix(67.17, 105.07);
87        const half = stroke * Math.hypot(1, slope) / 2, run = slope * (cap + 60) / 2;
88        shapes['/'] = [roundedPolygon([[-run - half, -60], [run - half, cap],
89            [run + half, cap], [-run + half, -60]], Math.max(stroke * .16, mix(11.25, 31.25)))];
90    }
91    {
92        const slope = .165, stroke = .9 * s * Math.hypot(1, slope), spacing = mix(94, 91);
93        const low = mix(256, 262.5), high = cap - low;
94        const halfBar = (472 - slope * (high - low)) / 2;
95        shapes['#'] = [];
96        for (const x of [-spacing, spacing]) {
97            const b = x - slope * cap / 2, t = x + slope * cap / 2;
98            shapes['#'].push([[b - stroke / 2, 0], [t - stroke / 2, cap],
99                [t + stroke / 2, cap], [b + stroke / 2, 0]]);
100        }
101        for (const y of [low, high]) {
102            const x = slope * (y - cap / 2);
103            shapes['#'].push(rect(x - halfBar, y - s / 2, x + halfBar, y + s / 2));
104        }
105    }
106    {
107        const stroke = s < 75 ? 48 * s / 75 : mix(48, 76);
108        const l = -236, r = 236, top = 650, b = mix(-130, -116);
109        const radius = 224, tc = top - radius, bc = b + radius, end = -62 * PI / 180;
110        const shift = mix(20, 10), aBottom = mix(30, 40);
111        const aLeft = mix(-136, -122) + shift, aRight = mix(80, 108) + shift;
112        const aTop = mix(490, 502), aRadius = mix(96, 100);
113        const stemTop = aTop - Math.max(24, mix(24, 60));
114        const hook = (r - aRight + stroke) / 2, hx = r - hook, hy = aBottom + hook;
115        shapes['@'] = [
116            [[aRight, stemTop], [aRight, hy], ...arc(hx, hy, hook - stroke, PI, 2 * PI),
117                [r - stroke, tc], ...arc(12, tc, radius - stroke, 0, PI / 2),
118                [-12, top - stroke], ...arc(-12, tc, radius - stroke, PI / 2, PI),
119                [l + stroke, bc], ...arc(-12, bc, radius - stroke, PI, 3 * PI / 2),
120                [12, b + stroke], ...arc(12, bc, radius - stroke, -PI / 2, end),
121                [12 + radius * Math.cos(end), bc + radius * Math.sin(end)],
122                ...arc(12, bc, radius, end, -PI / 2), [-12, b],
123                ...arc(-12, bc, radius, 3 * PI / 2, PI), [l, tc],
124                ...arc(-12, tc, radius, PI, PI / 2), [12, top],
125                ...arc(12, tc, radius, PI / 2, 0), [r, hy],
126                ...arc(hx, hy, hook, 0, -PI), [aRight - stroke, stemTop]],
127            capsule(aLeft, aRight, aBottom, aTop, aRadius),
128            capsule(aLeft + stroke, aRight - stroke, aBottom + stroke, aTop - stroke,
129                aRadius - stroke, true),
130        ];
131    }
132    {
133        const stroke = mix(52, 62), run = .419 * cap / 2, half = stroke * Math.hypot(1, .419) / 2;
134        shapes['%'] = [roundedPolygon([[-run - half, 0], [run - half, cap],
135            [run + half, cap], [-run + half, 0]], Math.max(stroke * .16, mix(12, 20)))];
136        for (const [l, b, r, t] of [[-236, 374, -50, 724], [50, 0, 236, 350]]) {
137            shapes['%'].push(capsule(l, r, b, t, 93),
138                capsule(l + stroke, r - stroke, b + stroke, t - stroke, 93 - stroke, true));
139        }
140    }
141
142    {
143        const radius = mix(201, 210), terminal = mix(34, 52.5), w = radius + terminal;
144        const l = -w / 2, r = w / 2, cx = l + radius;
145        shapes['('] = [[[r, cap], [r, cap - s], [cx, cap - s],
146            ...arc(cx, cap - radius, radius - s, PI / 2, PI), [l + s, bottom + radius],
147            ...arc(cx, bottom + radius, radius - s, PI, 3 * PI / 2),
148            [r, bottom + s], [r, bottom], [cx, bottom],
149            ...arc(cx, bottom + radius, radius, 3 * PI / 2, PI), [l, cap - radius],
150            ...arc(cx, cap - radius, radius, PI, PI / 2)]];
151    }
152    {
153        const w = mix(223.5, 252), l = -w / 2, r = w / 2;
154        shapes['['] = [[[l, bottom], [l, cap], [r, cap], [r, cap - s],
155            [l + s, cap - s], [l + s, bottom + s], [r, bottom + s], [r, bottom]]];
156    }
157    {
158        const w = mix(346.5, 367.5), l = -w / 2, r = w / 2;
159        const radius = mix(201, 210), cx = r - mix(34, 52.5), stem = cx - radius;
160        const waist = (cap + bottom) / 2, upper = waist + mix(133, 120.9), lower = 2 * waist - upper;
161        const turn = upper - waist - s * mix(1, .9) / 2, tx = stem - turn;
162        const angle = Math.asin((upper - waist) / (turn + s));
163        shapes['{'] = [[[r, cap], [r, cap - s], [cx, cap - s],
164            ...arc(cx, cap - radius, radius - s, PI / 2, PI), [stem + s, upper],
165            ...arc(tx, upper, turn + s, 0, -angle), ...arc(tx, lower, turn + s, angle, 0),
166            [stem + s, bottom + radius], ...arc(cx, bottom + radius, radius - s, PI, 3 * PI / 2),
167            [r, bottom + s], [r, bottom], [cx, bottom],
168            ...arc(cx, bottom + radius, radius, 3 * PI / 2, PI), [stem, lower],
169            ...arc(tx, lower, turn, 0, PI / 2), [l, waist - s * mix(1, .9) / 2],
170            [l, waist + s * mix(1, .9) / 2], [tx, waist + s * mix(1, .9) / 2],
171            ...arc(tx, upper, turn, -PI / 2, 0), [stem, cap - radius],
172            ...arc(cx, cap - radius, radius, PI, PI / 2)]];
173    }
174    {
175        const l = mix(-149, -156), half = mix(234, 231), t = mid + half, b = mid - half;
176        const inner = l + s * mix(.985, 1.068), right = inner + half / .95;
177        const outer = l + (half - s / 2) / .95;
178        shapes['<'] = [[[l, mid - s / 2], [l, mid + s / 2], [outer, t], [right, t],
179            [inner, mid], [right, b], [outer, b]]];
180    }
181    {
182        const cx = -60, lowerRadius = Math.min(210, mix(200, 210)), cy = lowerRadius - mix(12, 10);
183        const headRadius = mix(145, 157.5), headCy = mix(746, 745) - headRadius;
184        // The rising diagonal must meet both bowls tangentially.
185        const turnCy = cy + (lowerRadius + headRadius - s) * Math.SQRT2;
186        const theta = PI + Math.atan(.8), diagonal = 3 * PI / 4;
187        const headOuter = [cx + headRadius * Math.cos(theta), turnCy + headRadius * Math.sin(theta)];
188        const down = [cx + (headRadius - s / 2) * Math.cos(theta), turnCy + (headRadius - s / 2) * Math.sin(theta)];
189        const endY = -s / 2 * Math.sin(theta);
190        const upperJoin = [cx - (lowerRadius - s / 2) / Math.SQRT2, cy + (lowerRadius - s / 2) / Math.SQRT2];
191        const headJoin = [cx + (headRadius - s / 2) / Math.SQRT2, turnCy - (headRadius - s / 2) / Math.SQRT2];
192        const lowerJoin = [cx + (lowerRadius - s / 2) / Math.SQRT2, cy - (lowerRadius - s / 2) / Math.SQRT2];
193        const armY = mix(251, 270);
194        shapes['&'] = [
195            [headOuter, ...arc(cx, turnCy, headRadius, theta, PI), [cx - headRadius, headCy],
196                ...arc(cx, headCy, headRadius, PI, 0), [cx + headRadius, turnCy],
197                ...arc(cx, turnCy, headRadius, 0, -PI / 4),
198                [cx + (headRadius - s) / Math.SQRT2, turnCy - (headRadius - s) / Math.SQRT2],
199                ...arc(cx, turnCy, headRadius - s, -PI / 4, 0), [cx + headRadius - s, headCy],
200                ...arc(cx, headCy, headRadius - s, 0, PI), [cx - headRadius + s, turnCy],
201                ...arc(cx, turnCy, headRadius - s, PI, theta)],
202            [[cx + lowerRadius / Math.SQRT2, cy - lowerRadius / Math.SQRT2],
203                ...arc(cx, cy, lowerRadius, 7 * PI / 4, diagonal),
204                [cx - (lowerRadius - s) / Math.SQRT2, cy + (lowerRadius - s) / Math.SQRT2],
205                ...arc(cx, cy, lowerRadius - s, diagonal, 7 * PI / 4)],
206            strokeSegment(upperJoin, headJoin, s),
207            strokeSegment(lowerJoin, [lowerJoin[0] + armY - lowerJoin[1], armY], s),
208            strokeSegment(down, [down[0] + .8 * (down[1] - endY), endY], s),
209        ].map(c => c.map(p => p.map((v, i) => i % 2 ? v : v * .875)));
210    }
211    for (const [left, right] of [['/', '\\'], ['(', ')'], ['[', ']'], ['{', '}'], ['<', '>']]) {
212        shapes[right] = shapes[left].map(c => reverseContour(c.map(p => p.map((v, i) => i % 2 ? v : -v))));
213    }
214
215    const dot = mix(111, 147), colonTop = mix(490, 504);
216    {
217        const stroke = mix(97, 132), height = mix(269, 294), offset = mix(94.5, 105);
218        const tip = stroke * .88 / 2;
219        const quote = [[-tip, cap - height], [-stroke / 2, cap],
220            [stroke / 2, cap], [tip, cap - height]];
221        shapes["'"] = [quote];
222        shapes['"'] = [-offset, offset].map(x => quote.map(([px, y]) => [px + x, y]));
223        const graveHeight = mix(209, 235), graveStroke = mix(93, 126);
224        const radius = Math.max(graveStroke * .16, mix(16, 31));
225        const angle = mix(51, 48) * PI / 180, c = Math.cos(angle), d = Math.sin(angle);
226        // Account for the rotated fillets so the visible top stays at cap height.
227        const length = (graveHeight - graveStroke * c + 2 * radius * (c + d - 1)) / d;
228        shapes['`'] = [roundedPolygon(rect(-length / 2, -graveStroke / 2, length / 2, graveStroke / 2)
229            .map(([x, y]) => [x * c + y * d, cap - graveHeight / 2 - x * d + y * c]), radius)];
230    }
231    const dotContour = rect(-dot / 2, 0, dot / 2, dot);
232    shapes['.'] = [dotContour];
233    shapes[':'] = [dotContour, rect(-dot / 2, colonTop - dot, dot / 2, colonTop)];
234    shapes['!'] = [dotContour, rect(-s / 2, mix(212, 210), s / 2, cap)];
235    {
236        const radius = mix(190, 199.5), top = mix(746, 745), cy = top - radius;
237        const cx = 11, l = -cx - radius, r = cx + radius, neck = -22;
238        const nl = neck - s / 2, nr = neck + s / 2;
239        shapes['?'] = [[[l, cy], ...arc(-cx, cy, radius, PI, PI / 2), [cx, top],
240            ...arc(cx, cy, radius, PI / 2, 0), [r, 400, nr, 390, nr, 244],
241            [nr, 211], [nl, 211], [nl, 244], [nl, 400, r - s, 440, r - s, cy],
242            ...arc(cx, cy, radius - s, 0, PI / 2), [-cx, top - s],
243            ...arc(-cx, cy, radius - s, PI / 2, PI), [l + s, cy - 12], [l, cy - 12]],
244            rect(neck - dot / 2, 0, neck + dot / 2, dot)];
245        const bar = mix(56, 84) / 2, overhang = mix(89, 84);
246        shapes['$'] = [rect(-bar, -overhang, bar, 30), rect(-bar, cap - 30, bar, cap + overhang)];
247    }
248    {
249        const radius = Math.min(dot * .95, mix(100, 115)), stroke = mix(56, 73), cx = dot / 2 - radius;
250        const comma = [[-dot / 2, dot], [dot / 2, dot], [dot / 2, 0],
251            ...arc(cx, 0, radius, 0, -PI / 2), [-dot / 2, -radius],
252            [-dot / 2, -radius + stroke], [cx, -radius + stroke],
253            ...arc(cx, 0, radius - stroke, -PI / 2, 0), [-dot / 2, 0]];
254        shapes[','] = [comma];
255        shapes[';'] = [comma, shapes[':'][1]];
256    }
257    shapes['|'] = [rect(-s / 2, bottom, s / 2, cap)];
258    {
259        const w = mix(445, 462), half = w / 2;
260        shapes['+'] = [rect(-half, mid - s / 2, half, mid + s / 2),
261            rect(-s / 2, mid - half, s / 2, mid + half)];
262        shapes['*'] = [];
263        for (const angle of [0, PI / 3, 2 * PI / 3]) {
264            const c = Math.cos(angle), d = Math.sin(angle);
265            shapes['*'].push(rect(-234, -s / 2, 234, s / 2).map(([x, y]) =>
266                [x * c - y * d, mid + x * d + y * c]));
267        }
268        const equalWidth = mix(403, 441), distance = mix(89, 100);
269        shapes['='] = [rect(-equalWidth / 2, mid - distance - s / 2, equalWidth / 2, mid - distance + s / 2),
270            rect(-equalWidth / 2, mid + distance - s / 2, equalWidth / 2, mid + distance + s / 2)];
271        const dash = mix(334, 315), underscore = Math.min(472, mix(468, 462));
272        shapes['-'] = [rect(-dash / 2, mid - s / 2, dash / 2, mid + s / 2)];
273        shapes['_'] = [rect(-underscore / 2, -mix(56, 84), underscore / 2, 0)];
274    }
275    {
276        const w = mix(383, 435), b = mix(334, 315), slope = (cap - b) / (w / 2);
277        const thick = s * Math.hypot(1, slope) / slope;
278        shapes['^'] = [[[-w / 2, b], [-thick / 2, cap], [thick / 2, cap], [w / 2, b],
279            [w / 2 - thick, b], [0, cap - thick * slope / 2], [-w / 2 + thick, b]]];
280    }
281    {
282        const w = mix(212.5, 220.5), h = s / 2, cy = mix(278, 273);
283        shapes['~'] = [[[-w, cy - 35 + h],
284            [-w * .61, cy + 85 + h, -w * .31, cy + 35 + h, 0, cy + h],
285            [w * .31, cy - 35 + h, w * .61, cy - 85 + h, w, cy + 35 + h],
286            [w, cy + 35 - h],
287            [w * .61, cy - 85 - h, w * .31, cy - 35 - h, 0, cy - h],
288            [-w * .31, cy + 35 - h, -w * .61, cy + 85 - h, -w, cy - 35 - h]]];
289    }
290    return Object.fromEntries(Object.entries(shapes).map(([char, contours]) => [char,
291        contours.map(c => c.map(p => p.map((v, i) => i % 2 ? v : v + width / 2)))]));
292}
293
294export function refineAscii(store, metrics, para) {
295    for (const [char, contours] of Object.entries(asciiContours(metrics.Width, para.stroke))) {
296        const glyph = store.queryByUnicode(char.codePointAt(0));
297        if (!glyph) continue;
298        glyph.clearGeometry();
299        if (char === '$') glyph.includeGlyph(store.queryByNameEnsured('S/dollarKernelStd'));
300        outline(contours).applyToGlyph(glyph);
301        // ContourSetGeometry alone is assumed simple; merge touching primitives before rounding.
302        glyph.geometry = new BooleanGeometry(TypoGeom.Boolean.ClipType.ctUnion, [glyph.geometry]);
303    }
304}