Перейти к содержимому
Ремесло9 мин чтения

Радиус, который вычислен, а не подобран на глаз

Дуга знака выведена из хорды и стрелки прогиба, все растровые ассеты собираются из одного источника, а дублирование геометрии в скрипте сборки не убрано, а поставлено под проверку.

Знак студии держится на одном каламбуре: 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 — один знак, окрашиваемый тем, во что он вставлен, и никаких вариантов «для светлого» и «для тёмного», которые надо синхронизировать.

Итог

Дело не в том, что арифметика красивее, чем подобранная на глаз кривая. Дело в том, что у вычисленного значения есть причина, и причина живёт дольше, чем файл. Через три года кто-то спросит, почему поля сверху и снизу вдвое меньше боковых. На это есть ответ. На вопрос «почему эта ручка стоит именно здесь» ответа не бывает никогда.