// 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 => ``
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())
}