import { BooleanGeometry } from '@iosevka/geometry'; import * as TypoGeom from 'typo-geom'; import { arc, capsule, outline, reverseContour } from './iocgn-shapes.mjs'; const PI = Math.PI; function rect(l, b, r, t) { return [[l, b], [l, t], [r, t], [r, b]]; } function strokeSegment(a, b, stroke) { const length = Math.hypot(b[0] - a[0], b[1] - a[1]); const ux = (b[0] - a[0]) / length, uy = (b[1] - a[1]) / length; const nx = -uy * stroke / 2, ny = ux * stroke / 2; // Overlap tangent joins slightly; exact touching can leave zero-area contours. a = [a[0] - ux, a[1] - uy]; b = [b[0] + ux, b[1] + uy]; return [[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny], [b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny]]; } function roundedPolygon(points, radius) { const contour = []; for (let i = 0; i < points.length; i++) { const p = points[i], prev = points[(i + points.length - 1) % points.length]; const next = points[(i + 1) % points.length]; const a = Math.hypot(prev[0] - p[0], prev[1] - p[1]); const b = Math.hypot(next[0] - p[0], next[1] - p[1]); const u = [(prev[0] - p[0]) / a, (prev[1] - p[1]) / a]; const v = [(next[0] - p[0]) / b, (next[1] - p[1]) / b]; const half = Math.acos(u[0] * v[0] + u[1] * v[1]) / 2; const trim = Math.min(radius / Math.tan(half), a * .49, b * .49); const r = trim * Math.tan(half), length = Math.hypot(u[0] + v[0], u[1] + v[1]); const cx = p[0] + (u[0] + v[0]) / length * r / Math.sin(half); const cy = p[1] + (u[1] + v[1]) / length * r / Math.sin(half); const entry = [p[0] + u[0] * trim, p[1] + u[1] * trim]; const from = Math.atan2(entry[1] - cy, entry[0] - cx); const to = Math.atan2(p[1] + v[1] * trim - cy, p[0] + v[0] * trim - cx); const turn = Math.atan2(Math.sin(to - from), Math.cos(to - from)); contour.push(entry, ...arc(cx, cy, r, from, from + turn)); } return contour; } // Dimensions belong to the 500-cell proof, not the proportional reference's advances. export function asciiContours(width, s) { const weight = (s - 75) / 30; const mix = (a, b) => a + (b - a) * weight; const cap = 735, bottom = -120, mid = Math.round((cap + bottom) / 2); const shapes = {}; { const w = mix(357, 378), l = 40 - w / 2, r = l + w; const arm = r - mix(23, 31), bar = cap / 2; shapes.F = [[[l, 0], [l, cap], [r, cap], [r, cap - s], [l + s, cap - s], [l + s, bar + s / 2], [arm, bar + s / 2], [arm, bar - s / 2], [l + s, bar - s / 2], [l + s, 0]]]; } { const radius = mix(156.5, 178.5), cx = -15, cy = radius + mix(-12, -10); const l = cx - radius, r = cx + radius, barLeft = l + mix(22, 42); const end = mix(178, 220); shapes.J = [[[r, cap], [r, cy], ...arc(cx, cy, radius, 0, -PI), [l, end], [l + s, end], [l + s, cy], ...arc(cx, cy, radius - s, PI, 2 * PI), [r - s, cap - s], [barLeft, cap - s], [barLeft, cap]]]; } { const w = Math.min(472, mix(363, 441)), l = 12 - s / 2, r = l + s; const tip = mix(-190, -214), flag = .94 * s * Math.SQRT2; shapes['1'] = [rect(-w / 2, 0, w / 2, s), rect(l, 0, r, cap), [[-24, cap], [tip, cap + tip + 24], [tip + flag / 2, cap + tip + 24 - flag / 2], [l, cap + l + 24 - flag], [r, cap]].reverse()]; } { const r = mix(200.5, 204.5), foot = mix(-54, -35), inner = foot - s; shapes['7'] = [[[-r, cap], [r, cap], [r, cap - s], [r, 570, r - 65, 480, r - 120, 385], [r - 185, 272, foot + 23, 143, foot, 0], [inner, 0], [inner + 23, 150, inner + 78, 300, r - 180, 405], [r - 115, 517, r - s - 2, 610, r - s, cap - s], [-r, cap - s]]]; } // Slashes retain Airport's Regular weight as well as its angle and vertical extent. { const slope = .419; const stroke = s < 75 ? 67.17 * s / 75 : s > 105 ? 105.07 * s / 105 : mix(67.17, 105.07); const half = stroke * Math.hypot(1, slope) / 2, run = slope * (cap + 60) / 2; shapes['/'] = [roundedPolygon([[-run - half, -60], [run - half, cap], [run + half, cap], [-run + half, -60]], Math.max(stroke * .16, mix(11.25, 31.25)))]; } { const slope = .165, stroke = .9 * s * Math.hypot(1, slope), spacing = mix(94, 91); const low = mix(256, 262.5), high = cap - low; const halfBar = (472 - slope * (high - low)) / 2; shapes['#'] = []; for (const x of [-spacing, spacing]) { const b = x - slope * cap / 2, t = x + slope * cap / 2; shapes['#'].push([[b - stroke / 2, 0], [t - stroke / 2, cap], [t + stroke / 2, cap], [b + stroke / 2, 0]]); } for (const y of [low, high]) { const x = slope * (y - cap / 2); shapes['#'].push(rect(x - halfBar, y - s / 2, x + halfBar, y + s / 2)); } } { const stroke = s < 75 ? 48 * s / 75 : mix(48, 76); const l = -236, r = 236, top = 650, b = mix(-130, -116); const radius = 224, tc = top - radius, bc = b + radius, end = -62 * PI / 180; const shift = mix(20, 10), aBottom = mix(30, 40); const aLeft = mix(-136, -122) + shift, aRight = mix(80, 108) + shift; const aTop = mix(490, 502), aRadius = mix(96, 100); const stemTop = aTop - Math.max(24, mix(24, 60)); const hook = (r - aRight + stroke) / 2, hx = r - hook, hy = aBottom + hook; shapes['@'] = [ [[aRight, stemTop], [aRight, hy], ...arc(hx, hy, hook - stroke, PI, 2 * PI), [r - stroke, tc], ...arc(12, tc, radius - stroke, 0, PI / 2), [-12, top - stroke], ...arc(-12, tc, radius - stroke, PI / 2, PI), [l + stroke, bc], ...arc(-12, bc, radius - stroke, PI, 3 * PI / 2), [12, b + stroke], ...arc(12, bc, radius - stroke, -PI / 2, end), [12 + radius * Math.cos(end), bc + radius * Math.sin(end)], ...arc(12, bc, radius, end, -PI / 2), [-12, b], ...arc(-12, bc, radius, 3 * PI / 2, PI), [l, tc], ...arc(-12, tc, radius, PI, PI / 2), [12, top], ...arc(12, tc, radius, PI / 2, 0), [r, hy], ...arc(hx, hy, hook, 0, -PI), [aRight - stroke, stemTop]], capsule(aLeft, aRight, aBottom, aTop, aRadius), capsule(aLeft + stroke, aRight - stroke, aBottom + stroke, aTop - stroke, aRadius - stroke, true), ]; } { const stroke = mix(52, 62), run = .419 * cap / 2, half = stroke * Math.hypot(1, .419) / 2; shapes['%'] = [roundedPolygon([[-run - half, 0], [run - half, cap], [run + half, cap], [-run + half, 0]], Math.max(stroke * .16, mix(12, 20)))]; for (const [l, b, r, t] of [[-236, 374, -50, 724], [50, 0, 236, 350]]) { shapes['%'].push(capsule(l, r, b, t, 93), capsule(l + stroke, r - stroke, b + stroke, t - stroke, 93 - stroke, true)); } } { const radius = mix(201, 210), terminal = mix(34, 52.5), w = radius + terminal; const l = -w / 2, r = w / 2, cx = l + radius; shapes['('] = [[[r, cap], [r, cap - s], [cx, cap - s], ...arc(cx, cap - radius, radius - s, PI / 2, PI), [l + s, bottom + radius], ...arc(cx, bottom + radius, radius - s, PI, 3 * PI / 2), [r, bottom + s], [r, bottom], [cx, bottom], ...arc(cx, bottom + radius, radius, 3 * PI / 2, PI), [l, cap - radius], ...arc(cx, cap - radius, radius, PI, PI / 2)]]; } { const w = mix(223.5, 252), l = -w / 2, r = w / 2; shapes['['] = [[[l, bottom], [l, cap], [r, cap], [r, cap - s], [l + s, cap - s], [l + s, bottom + s], [r, bottom + s], [r, bottom]]]; } { const w = mix(346.5, 367.5), l = -w / 2, r = w / 2; const radius = mix(201, 210), cx = r - mix(34, 52.5), stem = cx - radius; const waist = (cap + bottom) / 2, upper = waist + mix(133, 120.9), lower = 2 * waist - upper; const turn = upper - waist - s * mix(1, .9) / 2, tx = stem - turn; const angle = Math.asin((upper - waist) / (turn + s)); shapes['{'] = [[[r, cap], [r, cap - s], [cx, cap - s], ...arc(cx, cap - radius, radius - s, PI / 2, PI), [stem + s, upper], ...arc(tx, upper, turn + s, 0, -angle), ...arc(tx, lower, turn + s, angle, 0), [stem + s, bottom + radius], ...arc(cx, bottom + radius, radius - s, PI, 3 * PI / 2), [r, bottom + s], [r, bottom], [cx, bottom], ...arc(cx, bottom + radius, radius, 3 * PI / 2, PI), [stem, lower], ...arc(tx, lower, turn, 0, PI / 2), [l, waist - s * mix(1, .9) / 2], [l, waist + s * mix(1, .9) / 2], [tx, waist + s * mix(1, .9) / 2], ...arc(tx, upper, turn, -PI / 2, 0), [stem, cap - radius], ...arc(cx, cap - radius, radius, PI, PI / 2)]]; } { const l = mix(-149, -156), half = mix(234, 231), t = mid + half, b = mid - half; const inner = l + s * mix(.985, 1.068), right = inner + half / .95; const outer = l + (half - s / 2) / .95; shapes['<'] = [[[l, mid - s / 2], [l, mid + s / 2], [outer, t], [right, t], [inner, mid], [right, b], [outer, b]]]; } { const cx = -60, lowerRadius = Math.min(210, mix(200, 210)), cy = lowerRadius - mix(12, 10); const headRadius = mix(145, 157.5), headCy = mix(746, 745) - headRadius; // The rising diagonal must meet both bowls tangentially. const turnCy = cy + (lowerRadius + headRadius - s) * Math.SQRT2; const theta = PI + Math.atan(.8), diagonal = 3 * PI / 4; const headOuter = [cx + headRadius * Math.cos(theta), turnCy + headRadius * Math.sin(theta)]; const down = [cx + (headRadius - s / 2) * Math.cos(theta), turnCy + (headRadius - s / 2) * Math.sin(theta)]; const endY = -s / 2 * Math.sin(theta); const upperJoin = [cx - (lowerRadius - s / 2) / Math.SQRT2, cy + (lowerRadius - s / 2) / Math.SQRT2]; const headJoin = [cx + (headRadius - s / 2) / Math.SQRT2, turnCy - (headRadius - s / 2) / Math.SQRT2]; const lowerJoin = [cx + (lowerRadius - s / 2) / Math.SQRT2, cy - (lowerRadius - s / 2) / Math.SQRT2]; const armY = mix(251, 270); shapes['&'] = [ [headOuter, ...arc(cx, turnCy, headRadius, theta, PI), [cx - headRadius, headCy], ...arc(cx, headCy, headRadius, PI, 0), [cx + headRadius, turnCy], ...arc(cx, turnCy, headRadius, 0, -PI / 4), [cx + (headRadius - s) / Math.SQRT2, turnCy - (headRadius - s) / Math.SQRT2], ...arc(cx, turnCy, headRadius - s, -PI / 4, 0), [cx + headRadius - s, headCy], ...arc(cx, headCy, headRadius - s, 0, PI), [cx - headRadius + s, turnCy], ...arc(cx, turnCy, headRadius - s, PI, theta)], [[cx + lowerRadius / Math.SQRT2, cy - lowerRadius / Math.SQRT2], ...arc(cx, cy, lowerRadius, 7 * PI / 4, diagonal), [cx - (lowerRadius - s) / Math.SQRT2, cy + (lowerRadius - s) / Math.SQRT2], ...arc(cx, cy, lowerRadius - s, diagonal, 7 * PI / 4)], strokeSegment(upperJoin, headJoin, s), strokeSegment(lowerJoin, [lowerJoin[0] + armY - lowerJoin[1], armY], s), strokeSegment(down, [down[0] + .8 * (down[1] - endY), endY], s), ].map(c => c.map(p => p.map((v, i) => i % 2 ? v : v * .875))); } for (const [left, right] of [['/', '\\'], ['(', ')'], ['[', ']'], ['{', '}'], ['<', '>']]) { shapes[right] = shapes[left].map(c => reverseContour(c.map(p => p.map((v, i) => i % 2 ? v : -v)))); } const dot = mix(111, 147), colonTop = mix(490, 504); { const stroke = mix(97, 132), height = mix(269, 294), offset = mix(94.5, 105); const tip = stroke * .88 / 2; const quote = [[-tip, cap - height], [-stroke / 2, cap], [stroke / 2, cap], [tip, cap - height]]; shapes["'"] = [quote]; shapes['"'] = [-offset, offset].map(x => quote.map(([px, y]) => [px + x, y])); const graveHeight = mix(209, 235), graveStroke = mix(93, 126); const radius = Math.max(graveStroke * .16, mix(16, 31)); const angle = mix(51, 48) * PI / 180, c = Math.cos(angle), d = Math.sin(angle); // Account for the rotated fillets so the visible top stays at cap height. const length = (graveHeight - graveStroke * c + 2 * radius * (c + d - 1)) / d; shapes['`'] = [roundedPolygon(rect(-length / 2, -graveStroke / 2, length / 2, graveStroke / 2) .map(([x, y]) => [x * c + y * d, cap - graveHeight / 2 - x * d + y * c]), radius)]; } const dotContour = rect(-dot / 2, 0, dot / 2, dot); shapes['.'] = [dotContour]; shapes[':'] = [dotContour, rect(-dot / 2, colonTop - dot, dot / 2, colonTop)]; shapes['!'] = [dotContour, rect(-s / 2, mix(212, 210), s / 2, cap)]; { const radius = mix(190, 199.5), top = mix(746, 745), cy = top - radius; const cx = 11, l = -cx - radius, r = cx + radius, neck = -22; const nl = neck - s / 2, nr = neck + s / 2; shapes['?'] = [[[l, cy], ...arc(-cx, cy, radius, PI, PI / 2), [cx, top], ...arc(cx, cy, radius, PI / 2, 0), [r, 400, nr, 390, nr, 244], [nr, 211], [nl, 211], [nl, 244], [nl, 400, r - s, 440, r - s, cy], ...arc(cx, cy, radius - s, 0, PI / 2), [-cx, top - s], ...arc(-cx, cy, radius - s, PI / 2, PI), [l + s, cy - 12], [l, cy - 12]], rect(neck - dot / 2, 0, neck + dot / 2, dot)]; const bar = mix(56, 84) / 2, overhang = mix(89, 84); shapes['$'] = [rect(-bar, -overhang, bar, 30), rect(-bar, cap - 30, bar, cap + overhang)]; } { const radius = Math.min(dot * .95, mix(100, 115)), stroke = mix(56, 73), cx = dot / 2 - radius; const comma = [[-dot / 2, dot], [dot / 2, dot], [dot / 2, 0], ...arc(cx, 0, radius, 0, -PI / 2), [-dot / 2, -radius], [-dot / 2, -radius + stroke], [cx, -radius + stroke], ...arc(cx, 0, radius - stroke, -PI / 2, 0), [-dot / 2, 0]]; shapes[','] = [comma]; shapes[';'] = [comma, shapes[':'][1]]; } shapes['|'] = [rect(-s / 2, bottom, s / 2, cap)]; { const w = mix(445, 462), half = w / 2; shapes['+'] = [rect(-half, mid - s / 2, half, mid + s / 2), rect(-s / 2, mid - half, s / 2, mid + half)]; shapes['*'] = []; for (const angle of [0, PI / 3, 2 * PI / 3]) { const c = Math.cos(angle), d = Math.sin(angle); shapes['*'].push(rect(-234, -s / 2, 234, s / 2).map(([x, y]) => [x * c - y * d, mid + x * d + y * c])); } const equalWidth = mix(403, 441), distance = mix(89, 100); shapes['='] = [rect(-equalWidth / 2, mid - distance - s / 2, equalWidth / 2, mid - distance + s / 2), rect(-equalWidth / 2, mid + distance - s / 2, equalWidth / 2, mid + distance + s / 2)]; const dash = mix(334, 315), underscore = Math.min(472, mix(468, 462)); shapes['-'] = [rect(-dash / 2, mid - s / 2, dash / 2, mid + s / 2)]; shapes['_'] = [rect(-underscore / 2, -mix(56, 84), underscore / 2, 0)]; } { const w = mix(383, 435), b = mix(334, 315), slope = (cap - b) / (w / 2); const thick = s * Math.hypot(1, slope) / slope; shapes['^'] = [[[-w / 2, b], [-thick / 2, cap], [thick / 2, cap], [w / 2, b], [w / 2 - thick, b], [0, cap - thick * slope / 2], [-w / 2 + thick, b]]]; } { const w = mix(212.5, 220.5), h = s / 2, cy = mix(278, 273); shapes['~'] = [[[-w, cy - 35 + h], [-w * .61, cy + 85 + h, -w * .31, cy + 35 + h, 0, cy + h], [w * .31, cy - 35 + h, w * .61, cy - 85 + h, w, cy + 35 + h], [w, cy + 35 - h], [w * .61, cy - 85 - h, w * .31, cy - 35 - h, 0, cy - h], [-w * .31, cy + 35 - h, -w * .61, cy + 85 - h, -w, cy - 35 - h]]]; } return Object.fromEntries(Object.entries(shapes).map(([char, contours]) => [char, contours.map(c => c.map(p => p.map((v, i) => i % 2 ? v : v + width / 2)))])); } export function refineAscii(store, metrics, para) { for (const [char, contours] of Object.entries(asciiContours(metrics.Width, para.stroke))) { const glyph = store.queryByUnicode(char.codePointAt(0)); if (!glyph) continue; glyph.clearGeometry(); if (char === '$') glyph.includeGlyph(store.queryByNameEnsured('S/dollarKernelStd')); outline(contours).applyToGlyph(glyph); // ContourSetGeometry alone is assumed simple; merge touching primitives before rounding. glyph.geometry = new BooleanGeometry(TypoGeom.Boolean.ClipType.ctUnion, [glyph.geometry]); } }