Радиус, который вычислен, а не подобран на глаз
Дуга знака выведена из хорды и стрелки прогиба, все растровые ассеты собираются из одного источника, а дублирование геометрии в скрипте сборки не убрано, а поставлено под проверку.
Знак студии держится на одном каламбуре: ODIN337 читается как 0DIN337 — буква O становится нулём. Один отдал глаз за знание, поэтому нуль становится этим глазом. Один символ несёт букву, цифру и миф одновременно.
Дальше начинается инженерия, потому что «нуль, который читается как глаз» — это конкретная геометрия с конкретными требованиями: работать как цифра в 16 пикселях и как глаз в двухстах.
Почему это нельзя подобрать ручками
Силуэт — vesica piscis: две круговые дуги, встречающиеся в точке сверху и снизу. Если тянуть за манипуляторы в редакторе, получаются три проблемы, и только первая заметна.
Заметная — излом. Дуги, чьи радиусы не согласованы с хордой, встречаются в вершине с крошечным перегибом, и на большом размере глаз его находит, даже когда не может назвать.
Вторая — невоспроизводимость. Через год понадобится вариант знака на другой пропорции, и восстановить логику построения будет уже нельзя: чисел, из которых оно выведено, не существует.
Третья — размножение копий. Каждый экспортированный PNG — это копия геометрии, потерявшая связь с оригиналом. Именно так фавиконка перестаёт совпадать с логотипом на странице, и никто не может назвать момент, когда это случилось.
Арифметика
Геометрия задана на сетке 64×64. Известны хорда (вертикальная ось знака) и стрелка прогиба (насколько дуга отходит от неё). Радиус выводится:
d + h = R // центр дуги отстоит от прогиба на h
d² + (c/2)² = R² // Пифагор на половине хорды
(R - h)² + (c/2)² = R²
R² - 2Rh + h² + (c/2)² = R²
R = (h² + (c/2)²) / 2h
c = 56 (y от 4 до 60)
h = 24 (x уходит от 32 к 8 и к 56)
R = (576 + 784) / 48 = 1360 / 48 = 28,333
Отсюда в SVG идёт A28.333 28.333, и дуги встречаются точно в тангенциальных точках. Излома нет не потому, что его подправили, а потому что он невозможен.
Получается знак 48×56 в коробке 64: по 8 единиц слева и справа против 4 сверху и снизу. Асимметрия намеренная — она даёт оптическое центрирование там, где коробка действительно является рамкой, то есть в фавиконке и монограмме. Промежуточный вариант со стрелкой прогиба 20 давал 40×56: миндалина, которая упиралась в верхний и нижний края и читалась как лист, а не как нуль.
Компромисс, который надо называть
При R = 28,333 угол при вершине считается так:
sin(a) = (c/2) / R = 28 / 28,333 = 0,98824 → a = 81,2°
угол при вершине = 2a = 162,4°
162° — это почти развёрнутый угол. То есть острия vesica здесь приглушены, и силуэт ближе к овалу, чем к линзе. Это надо сказать вслух, а не надеяться, что никто не измерит.
Истинная vesica — та, где вершины 120° — требует другого радиуса:
a = 60° → R = (c/2) / sin 60° = 28 / 0,866 = 32,33
h = R - sqrt(R² - (c/2)²) = 32,33 - 16,16 = 16,17
ширина линзы = 2h = 32,33 (у классической vesica ширина равна радиусу)
32 на 56 — отношение 0,58 против 0,86 у того, что поехало в прод. Узкая остроконечная линза читается как лист или как рыба, чем vesica исторически и является. А ведущее требование здесь другое: знак обязан читаться как 0 в 337. Прочтение «глаз» несёт зрачок, который выживает на любом размере, поэтому им можно было заплатить за ширину. Остроугольное прочтение никуда не делось — оно вынесено в направление B, где тот же силуэт вырезан четырьмя прямыми, как руну вырубили бы в камне.
Один источник для всего растра
Фавиконка, apple-icon, icon.svg и файлы пресс-кита собираются скриптом из тех же констант. Ничего не рисуется руками — значит, расхождение с логотипом на странице невозможно по построению, а не по внимательности.
Но есть шов, который убрать не получилось. Скрипт — обычный Node без TSX-загрузчика, импортировать .tsx он не может, поэтому пути и константы в нём продублированы. Правильный ответ на дублирование, которое не убрать, — не притворяться, что его нет, а сделать его громким:
const missing = required.filter((needle) => !source.includes(needle));
if (missing.length > 0) throw new Error(...)
assertInSync() читает Eye.tsx и проверяет наличие каждой строки пути, обеих строк радужки и зрачка, а также самого EYE_VIEWBOX. Правка знака без правки скрипта роняет сборку и печатает список того, чего не нашлось. Дублирование осталось; молчаливым оно быть перестало.
ICO без зависимости
sharp не пишет .ico, а тянуть отдельный кодировщик ради сорока строк хорошо описанного формата незачем:
6 байт заголовок: reserved=0, type=1, count
16 байт на запись: w, h, palette, reserved, planes, bpp, byteLength, offset
далее PNG-нагрузки подряд
w = h = 0 кодирует 256 — поле однобайтовое
Внутри каждой записи лежит полноценный PNG: контейнер допускает их с Vista, и все браузеры в матрице поддержки их читают. Единственная ловушка — что 256 кодируется нулём, и это ровно та деталь, из-за которой самописный энкодер обычно ломается на самой большой иконке.
Safe area, которую пришлось измерить
Здесь самая полезная часть. Чернила знака занимают по вертикали 1,5–62,5 из 64: силуэт высотой 56 плюс половина пятиединичного штриха с каждой стороны. Это 2% свободного поля.
На странице так и должно быть — логотип обязан заполнять свою коробку. В обрамлённой иконке это неверно, и увидеть это удалось только измерением по готовому .ico: чернила попали на нулевую и пятнадцатую строки 16-пиксельной записи. Знак срезан сверху и снизу и вдавлен в скруглённые углы icon.svg.
чернила по y: 1,5 ... 62,5 (силуэт 4...60 плюс половина штриха 5)
масштаб 0,84 вокруг y = 32:
32 + (1,5 - 32) × 0,84 = 6,38
32 + (62,5 - 32) × 0,84 = 57,62
на 16px: 6,38 / 64 × 16 = 1,6px поля
высота чернил: 51,2 / 64 = 80% коробки
80% — примерно то соотношение содержимого, которое просит гайдлайн иконок Apple, и это совпадение приятное, но не оно было целью: целью были 1,6 пикселя, которых не хватало.
Важно, что этот отступ включается только для иконок. Для файлов пресс-кита он выключен: поле, запечённое в логотип, — это неудобство для того, кто будет его размещать. Одна геометрия, два разных правила для двух разных контекстов — и оба правила выражены флагом, а не отдельным файлом.
Суперсэмплинг вместо растеризации в размер
density 384 → 384 / 72 = 5,33× → растр 341px, уменьшается до 16
density 720 → 720 / 72 = 10× → растр 640px, уменьшается до 180
SVG растеризуется на большом разрешении и уменьшается, а не рисуется сразу в целевой размер. Разница видна на дуге в 16 пикселей: прямая растеризация даёт рваный край, уменьшение даёт сглаженный. Стоит это ноль — скрипт запускается руками, его результат закоммичен, и ни CI, ни сборка образа его не выполняют.
Ещё два решения того же рода. Фавиконка собирается в упрощённом виде, без радужки: ниже примерно 24 пикселей кольцо смыкается в грязь. И на непрозрачном фоне, потому что прозрачная фавиконка нечитаема на тёмной теме браузера. Цвет везде currentColor — один знак, окрашиваемый тем, во что он вставлен, и никаких вариантов «для светлого» и «для тёмного», которые надо синхронизировать.
Итог
Дело не в том, что арифметика красивее, чем подобранная на глаз кривая. Дело в том, что у вычисленного значения есть причина, и причина живёт дольше, чем файл. Через три года кто-то спросит, почему поля сверху и снизу вдвое меньше боковых. На это есть ответ. На вопрос «почему эта ручка стоит именно здесь» ответа не бывает никогда.