// Renox as a planet: the word's glyph outlines are wrapped onto a sphere as raised // continents, then lit, given an atmosphere, clouds, an ice cap and a ring. import opentype from 'opentype.js' import { Resvg } from '@resvg/resvg-js' import { readFileSync, writeFileSync } from 'fs' const at = p => new URL(p, import.meta.url) const fb = readFileSync(at('../../node_modules/@fontsource/unbounded/files/unbounded-latin-900-normal.woff')) const font = opentype.parse(fb.buffer.slice(fb.byteOffset, fb.byteOffset + fb.byteLength)) const { sin, cos, hypot, ceil, min, max, PI } = Math const D = PI / 180, S = 1200, C = S / 2, R = 300 const T = 18 * D // camera elevation above the equator const SPAN = 66 * D // half the longitude the word wraps around const LAT = T // word latitude, puts it dead centre of the disc const RISE = .03 // continent height as a fraction of R const TRACK = 4 // letter spacing in em/100 const L = (v => v.map(c => c / hypot(...v)))([-.55, .62, .56]) // light, view space const RINGS = [[1.3, 1.37, .25], [1.39, 1.58, .7], [1.61, 1.64, .3], [1.67, 1.8, .5], [1.82, 1.85, .22]] const n = v => +v.toFixed(1) const rnd = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(7) // lon/lat on a sphere of radius r -> screen [x, y, depth] (depth > 0 faces the camera) const P = (lon, lat, r = 1) => { const x = cos(lat) * sin(lon), y = sin(lat), z = cos(lat) * cos(lon) return [C + R * r * x, C - R * r * (y * cos(T) - z * sin(T)), y * sin(T) + z * cos(T)] } const poly = pts => 'M' + pts.map(([x, y]) => n(x) + ' ' + n(y)).join('L') // lay out the word, then flatten every outline into dense polylines so straight // strokes bend properly once they are wrapped round the sphere const cmds = [] const gs = font.stringToGlyphs('RENOX') gs.reduce((x, g, i) => { cmds.push(...g.getPath(x, 0, 100).commands) return x + (g.advanceWidth + (gs[i + 1] ? font.getKerningValue(g, gs[i + 1]) : 0)) / font.unitsPerEm * 100 + TRACK }, 0) const bez = (q, t) => q.length < 2 ? q[0] : bez(q.slice(1).map((v, i) => [q[i][0] + (v[0] - q[i][0]) * t, q[i][1] + (v[1] - q[i][1]) * t]), t) const outlines = [] let cur, p for (const c of cmds) { if (c.type == 'Z') continue const q = [p, c.x1 != null && [c.x1, c.y1], c.x2 != null && [c.x2, c.y2], [c.x, c.y]].filter(Boolean) if (c.type == 'M') outlines.push(cur = [[c.x, c.y]]) else for (let i = 1, k = max(2, ceil(q.slice(1).reduce((s, v, j) => s + hypot(v[0] - q[j][0], v[1] - q[j][1]), 0))); i <= k; i++) cur.push(bez(q, i / k)) p = [c.x, c.y] } const xs = outlines.flat().map(v => v[0]), ys = outlines.flat().map(v => v[1]) const xm = (min(...xs) + max(...xs)) / 2, ym = (min(...ys) + max(...ys)) / 2 const k = SPAN / ((max(...xs) - min(...xs)) / 2) const word = r => outlines.map(o => poly(o.map(([x, y]) => P((x - xm) * k, LAT + (ym - y) * k * cos(LAT), r))) + 'Z').join('') + islands(r) // clouds: soft streaks drifting along latitudes, kept clear of the word const clouds = Array.from({ length: 16 }, () => { const lat = (rnd() < .5 ? -46 + rnd() * 32 : 40 + rnd() * 20) * D, lon = (rnd() * 240 - 120) * D, len = (18 + rnd() * 36) * D const amp = (rnd() - .5) * 5 * D, w = n(4 + rnd() * 8), o = .2 + rnd() * .25 const pts = Array.from({ length: 48 }, (_, i) => P(lon + len * i / 47, lat + amp * sin(i / 47 * PI), 1 + RISE * 1.2)).filter(v => v[2] > .1) return pts.length > 3 ? `` : '' }).join('') // islands: a small archipelago so the letters read as continents const isles = [[-50, 45, 2.4], [44, 47, 1.7], [-38, -31, 3], [-24, -37, 1.3], [34, -34, 2.2], [50, -27, 1.2]] .map(([lon, lat, rad]) => [lon * D, lat * D, rad * D, rnd() * 9]) const islands = r => isles.map(([lon, lat, rad, ph]) => poly(Array.from({ length: 48 }, (_, i) => { const a = i / 48 * 2 * PI, q = rad * (1 + .2 * sin(3 * a + ph) + .1 * sin(5 * a + ph * 2)) return P(lon + q * cos(a) / cos(lat), lat + q * sin(a), r) })) + 'Z').join('') // relief: stack shells from the sea floor up to the plateau, darkest first const mix = (a, b, t) => '#' + [0, 2, 4].map(i => n(parseInt(a.slice(i + 1, i + 3), 16) * (1 - t) + parseInt(b.slice(i + 1, i + 3), 16) * t) | 0).map(v => v.toString(16).padStart(2, '0')).join('') const relief = Array.from({ length: 9 }, (_, i) => ``).join('') const cap = poly(Array.from({ length: 180 }, (_, i) => P(i * 2 * D, (75 + 1.6 * sin(i * 2 * D * 5) + .9 * sin(i * 2 * D * 13 + 1)) * D, 1 + RISE * .6))) + 'Z' const ring = ` ${RINGS.flatMap(([a, b, o], _, __, m = ceil((b - a) / .005)) => Array.from({ length: m }, (_, i) => { const r = a + (b - a) * (i + .5) / m return `` })).join('')} ` const stars = Array.from({ length: 170 }, () => { const x = n(rnd() * S), y = n(rnd() * S), r = rnd() ** 3 * 2.2 + .5, o = n(.25 + rnd() * .75) return r > 2.1 ? `` : `` }).join('') const lx = n(C + L[0] * R * .62), ly = n(C - L[1] * R * .62) const svg = bg => ` ${bg ? ` ${stars}` : ''} ${relief} ${clouds} ${ring} ` for (const [name, bg] of [['logo', 1], ['logo-transparent', 0]]) { const s = svg(bg) writeFileSync(at(name + '.svg'), s) writeFileSync(at(name + '.png'), new Resvg(s, { fitTo: { mode: 'width', value: 2048 }, background: bg ? '#030210' : undefined }).render().asPng()) }