Files
houseplan-card/docs/specs/482-pdf-export-polish.md
T
Matysh 15b060ac38 fix: polish PDF export
Issue: #482
User-Visible: yes
2026-09-07 11:06:28 +03:00

491 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #482 — Доводка экспорта пространства в PDF
- **Issue:** https://github.com/Matysh/houseplan-card/issues/482
- **Тип / приоритет:** bug + polish / P1
- **Трек:** полный; меняются геометрия размеров, PDF-примитивы, компоновка
страницы, адаптивность View-диалога и golden-эталон
- **Оценка:** пользовательская ценность 8/10; ценность для разработки 7/10;
сложность 7/10; риск 7/10
- **Связано:** #52, #53; `docs/specs/052-view-dimensions.md`,
`docs/specs/053-pdf-export.md`, `docs/PDF-EXPORT.md`,
`docs/TOUCH-SUPPORT.md`
## 1. Сценарий
Персона — Home admin из `docs/SCOPE.md`. Поверхность — полная карточка House
Plan в режиме View на desktop либо touch-устройстве. Момент — пользователь
открывает «Сохранение в PDF», выбирает состав архитектурного чертежа и сохраняет
текущее пространство для печати, передачи строителю или обсуждения планировки.
В исходном плане могут одновременно присутствовать внешние и внутренние стены,
проёмы, перегородки, колонны, ступенчатые и вогнутые комнаты, декор, растровая
подложка и заданное направление севера.
## 2. Что человек увидит до и после
**До:** узкий диалог может получить горизонтальный scroll; противоположные
стороны дублируют одинаковые размеры; почти совпадающие вершины иногда создают
ложную диагональ; подписи прилипают к стенам; стены не похожи на стены плана;
чертёж плохо заполняет лист; север показан упрощённой стрелкой; внизу печатается
техническая легенда `wall · door · window`.
**После:** диалог помещается от 320 CSS px, размерные цепочки остаются только у
реальных осевых граней и не повторяют локальную противоположную пару, подписи
расположены равномерно и не пересекают стены, физические стены имеют фон
`#7f7f7f` и штриховку, полный чертёж оптимально вписан и центрирован, север
обозначен векторным компасом, а технической легенды нет.
## 3. Подтверждённые причины
1. `compactRing()` удаляет коллинеарные точки до схлопывания соседних точных и
почти точных дублей. В ступенчатом контуре это соединяет несмежные стороны
хордой. На приложенном примере ложные `3.39 m` и `3.93 m` совпадают с
диагоналями между реальными ортогональными участками; стены под углом 72° в
исходном плане нет.
2. Размеры формируются для каждой пригодной стороны независимо: понятия
локальной пары противоположных граней нет.
3. Внутренние подписи используют фиксированные 2 mm без учёта bbox текста и
тела стены; fallback сдвигает только текст вдоль стены. Внешняя shelf также
может двигаться отдельно от своей размерной линии.
4. Ориентация выбирается по сырому bbox архитектуры, до построения размеров и
выносок. Дополнительно всегда резервируются приближённые 30/48 mm, после чего
центрируется только архитектура. Поэтому масштаб и свободные поля не
соответствуют фактической печатной сцене.
5. PDF writer умеет заливать и обводить path, но не умеет even-odd clipping для
штриховки и заполненный составной vector path для нового компаса.
6. Общий двухзнаковый formatter PDF превратит `127 / 255` в `0.5`, то есть в
`#808080`. Для точного `#7f7f7f` цвету нужна отдельная точность.
7. В CSS диалога padding прибавляется к `min-width`, flex-подписи не могут
сжиматься, а footer запрещает перенос.
## 4. Изменяемый контракт
Это ТЗ заменяет только противоречащие ему части #53:
- §7.1: цвет и вид физических стен;
- §7.3: правило «все четыре стороны» и размещение размеров;
- §7.5: упрощённую стрелку севера и архитектурную легенду;
- §8.1–8.2: выбор ориентации, масштаба и центрирование;
- связанные AC4, AC6 и golden-ожидания.
Остальные гарантии #53 сохраняются: один лист A4, стандартный масштаб,
генерация на клиенте, Unicode-шрифт, детерминированные байты, отсутствие
маркеров и live-state, опциональные названия/размеры/декор/подложка и ленивый
PDF chunk.
## 5. Скоуп
1. Сделать диалог PDF безопасным на узкой View-поверхности.
2. Исправить нормализацию контуров до генерации размеров.
3. Ограничить размеры горизонтальными и вертикальными гранями с каноническим
near-axis допуском.
4. Убирать только одну из локальной пары геометрически противоположных равных
граней.
5. Перестроить размещение внутренних и внешних размеров в согласованные полосы.
6. Добавить физическим стенам, перегородкам и колоннам точный фон и штриховку с
clipping по их реальной геометрии и проёмам.
7. Выбирать ориентацию и стандартный масштаб по bbox фактически построенной
сцены и центрировать эту сцену в печатном поле.
8. Заменить стрелку севера на переданный векторный компас.
9. Удалить архитектурную легенду, сохранив дату, версию и масштабную линейку.
10. Обновить тесты, golden, документацию и лицензионное уведомление.
## 6. Не входит
- изменение модели пространства, wall segments, openings, decor или PDF
настроек в `localStorage`;
- новые настройки экспорта либо изменение значений существующих галочек;
- размеры диагональных стен, дуг, мебели, проёмов, перегородок или колонн;
- многостраничный PDF, другой формат бумаги или ручной выбор масштаба;
- ручное перетаскивание размеров и автоматические строительные размерные цепи;
- изменение вида стен на интерактивном плане;
- изменение семантики `north_deg` либо способа задания севера;
- печать устройств, их состояний или подключение внешних PDF-сервисов;
- общий рефакторинг writer за пределами нужных заполненных vector path,
clipping и точной сериализации цвета.
Если исправление потребует нового config-поля, изменения пользовательских
галочек или поддержки размеров настоящих диагональных стен, задача возвращается
в `S3-spec`.
## 7. Диалог на узкой поверхности
- Поддерживаемая минимальная ширина viewport — 320 CSS px.
- Surface, body и footer используют `box-sizing:border-box`, `min-width:0` и не
шире доступной области. Padding входит в вычисленную ширину.
- Текст внутри label может переноситься; checkbox/radio/input не сжимается и не
отделяется от начала своей подписи.
- При ширине, на которой две footer-кнопки с локализованными подписями не
помещаются, footer становится одной колонкой: «Отмена» сверху, основное
действие «Сохранить PDF» снизу; обе кнопки занимают доступную ширину.
- На большей ширине сохраняется действующий горизонтальный footer.
- Горизонтальный scroll запрещён для документа, dialog surface, body и footer.
Вертикальный scroll допустим; все элементы остаются доступны.
- Интерактивные цели сохраняют минимум 44×44 CSS px, focus ring и существующий
keyboard contract.
- Контракт проверяется во всех четырёх локалях EN/RU/DE/FR, а не только на самой
короткой строке.
## 8. Нормализация контуров и допустимые грани
### 8.1 Порядок очистки ring
Перед поиском коллинеарных вершин каждый конечный ring нормализуется так:
1. удалить повтор последней точки, если он замыкает первую;
2. схлопнуть соседние точные либо близкие точки, включая пару на seam
«последняя → первая»;
3. только после этого итеративно удалять промежуточную точку действительно
прямого участка;
4. повторять шаги 2–3 до fixed point либо пока проход ничего не изменил.
Близость точек — не более 1 mm в физических координатах плана, переведённых в
render coordinates через действующий `cell_cm`. Это только фильтр печатной
геометрии: исходный config не мутируется. Точки с нечисловыми координатами не
создают размер.
Точка считается промежуточной только когда оба соседних вектора ненулевые,
коллинеарны в пределах канонического допуска и их скалярное произведение
положительно. Поэтому разворот, настоящий угол и короткая грань между разными
точками не превращаются в хорду. Результат не содержит нулевых рёбер.
### 8.2 Только осевые размеры
Размер допускается только для:
- строго горизонтальной/вертикальной грани; либо
- near-axis грани, отклонённой не более чем на канонические 0.25° из
`src/near-axis.ts` (`NEAR_AXIS_MAX_DEGREES` / `NEAR_AXIS_MAX_SLOPE`).
Для near-axis шума печатная размерная грань выравнивается по major axis через
её midpoint и использует длину major projection. Исходная геометрия не
изменяется. Грань за пределом допуска полностью исключается: запрещено строить
проекцию, хорду или соединять её соседние концы.
Каждый кандидат сохраняет стабильную ссылку на исходные ring и edge index до
компактации. Стабильность нужна для дедупликации, layout и детерминированного
порядка, но не экспортируется в PDF.
## 9. Локальная дедупликация противоположных размеров
Равный отформатированный текст сам по себе никогда не является основанием для
удаления. Дедупликация выполняется отдельно:
- внутри одного room inner contour; либо
- внутри одного связного external outer ring.
Два кандидата образуют пару только если одновременно:
1. они имеют одну ось и противоположные нормали;
2. их tangent projection intervals совпадают с допуском 1 mm;
3. их projected lengths совпадают с допуском 1 mm;
4. midpoint соединяющего их перпендикуляра лежит внутри соответствующего
простого ring;
5. обе грани остаются допустимыми после §8.
Для external ring проверяется внутренняя область самого ring; внутренние holes
не связывают несвязанные здания. Один кандидат участвует максимум в одной паре.
Если возможны несколько пар, выбирается ближайшая по перпендикуляру, затем по
стабильному edge key.
Из пары остаётся сторона с лучшим placement score: меньше hard collisions с
телом архитектуры/границей печатного поля/уже размещёнными более приоритетными
аннотациями, затем больше свободного пространства по нормали. Полная ничья
разрешается минимальным стабильным ключом `(axis, tangentStart, normalSign,
ringIndex, sourceEdgeIndex)`.
Следствия:
- прямоугольная комната получает один горизонтальный и один вертикальный
внутренний размер, а не четыре;
- одинаковые значения у разных комнат, разных connected rings либо на разных
осях сохраняются;
- равные, но не противоположные участки L/ступенчатого контура сохраняются;
- общий горизонтальный и вертикальный габарит одинаковой длины не конфликтуют.
## 10. Размещение размеров
Все вычисления clearance выполняются в mm страницы после выбора масштаба и по
фактическому rotated bbox текста.
### 10.1 Общие правила
- Центр текста проецируется в midpoint измеряемой грани.
- Между bbox текста и ближайшей гранью тела стены остаётся минимум 1 mm.
- Текст, основная размерная линия и shelf не пересекают тело стены. Extension
внешнего размера может начинаться на самой внешней грани стены, но не входит
внутрь её тела.
- Для вогнутого ring внутренняя/внешняя нормаль определяется пробами
point-in-ring по обе стороны грани, а не только направлением к centroid.
- Нельзя исправлять collision индивидуальным tangent-jitter текста.
### 10.2 Внутренние размеры
Внутренний размер сохраняет действующий лаконичный text-only вид. Его базовая
полоса находится внутрь комнаты на расстоянии, равном половине normal extent
текста плюс 1 mm от реальной внутренней грани стены.
Размеры одного room contour с общей стороной/осью/нормалью относятся к одной
полосе. Если любой элемент полосы конфликтует с архитектурой либо уже принятым
более приоритетным элементом, вся полоса смещается внутрь шагами 3 mm до первого
валидного положения. Если валидного положения нет, сохраняется существующая
выноска для непрямоугольной комнаты; произвольный сдвиг текста вдоль стены не
допускается.
### 10.3 Внешние размеры
Базовая размерная линия проходит на 6 mm наружу от внешней грани тела стены.
Текст расположен снаружи линии с зазором 1 mm. Extension lines начинаются на
грани тела стены и заканчиваются на своей размерной линии.
Коллинеарные внешние размеры с общей осью и внешней нормалью образуют одну
полосу. При collision вся полоса — line, extensions, shelf и text — смещается
наружу шагами 4 mm. Shelf используется только когда текст шире доступного
участка и остаётся частью той же полосы.
Базовое расстояние одинаково для всех размеров своего класса. Дополнительные
полосы разрешены только как детерминированный ответ на collision; отдельная
подпись не может «прилипнуть» к стене или уехать независимо.
## 11. Вид физических стен
В PDF следующие объекты получают одинаковый material:
- физические room/external/shared стены ненулевой толщины;
- перегородки ненулевой толщины;
- квадратные и круглые колонны ненулевого размера.
Material состоит из:
1. фон RGB `127 127 127`, то есть точный `#7f7f7f`;
2. одна семья диагональных линий 45° к странице, шаг 3 mm, толщина 0.18 mm,
цвет основной печатной краски;
3. существующий контур толщиной 0.25 mm поверх fill и hatch.
Фаза штриховки привязана к началу страницы, поэтому соседние стены не начинают
узор заново и не дают заметных швов. Hatch строится в paper coordinates и
обрезается even-odd clip path (`W* n` или эквивалентом) фактического тела
каждого объекта. Проёмы остаются чистыми holes; hatch/fill не попадает в tunnel.
Нулевая стена сохраняет действующий line contract и не получает ни серый фон,
ни hatch. Decor и растровая подложка не меняются.
Цвет сериализуется отдельным formatter с достаточной точностью (минимум пять
знаков после точки для `127/255`). Координатный formatter writer не меняется:
это сохраняет детерминизм и размер файла.
## 12. Компоновка листа
### 12.1 Кандидаты
Для обеих ориентаций A4 — portrait и landscape — строится фактическая сцена с:
- архитектурой, включёнными decor/подложкой;
- реально оставшимися dimension lines, extensions, shelves и text;
- фактически понадобившимися выносками;
- line widths и rotated text bbox.
Приближённые безусловные резервы 30/48 mm удаляются. Header и footer не входят
в plan scene и заранее вычитаются из доступного поля.
Для каждой ориентации проверяется действующий стандартный ряд масштабов от
наиболее крупного к мелкому. Кандидат валиден, только если полный scene bbox с
допуском 0.5 mm находится внутри печатного поля.
Для экстремального плана после 1:500 denominator продолжается с шагом 50 через
конечный bracket/refinement. Заведомо невместимая фиксированная аннотация
завершает экспорт `pdf.failed`; обрезанный best-effort PDF не возвращается.
### 12.2 Выбор и центрирование
Побеждает:
1. минимальный denominator, то есть наиболее крупный стандартный масштаб;
2. при равном масштабе — большая доля площади печатного поля, занятая полным
scene bbox;
3. при полной ничьей — portrait.
После выбора переводится весь scene целиком: paths, raster, размеры, текст и
выноски. Центр полного bbox совпадает с центром доступного plan field с допуском
0.5 mm. Ни один элемент не пересекает header/footer или поля страницы.
Подпись масштаба и scale bar вычисляются из выбранного denominator; нельзя
визуально увеличить чертёж, оставив старое значение масштаба.
## 13. Векторный компас
Если у пространства задан `north_deg`, существующее место северного указателя
занимает `compass-line` из VMware Clarity Assets (`viewBox 0 0 36 36`) — обе
канонические filled path, без растрирования и без внешних ссылок.
Provenance проверен перед реализацией по неизменяемым первичным материалам:
- точный upstream commit —
https://github.com/vmware-archive/clarity-assets/commit/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c;
- SVG в этом commit —
https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/icons/travel/compass-line.svg;
- MIT license и copyright VMware 2018 в том же commit —
https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/LICENSE.
Переданный владельцем файл имеет SHA-256
`461EBD20E65DF8F017CADCCFEF4F58E7A7E5CB3A8877B4797E6C81C011E41F0B`.
Он не совпадает с upstream побайтово: SVG Repo добавил XML/comment и заменил
display size `36×36` на `800×800`. При этом `viewBox` и значения обеих `d`
path совпадают с точным upstream blob. Реализация вендорит path из указанного
upstream commit, а не оболочку SVG Repo, и сохраняет полный MIT license и
copyright рядом с ассетом либо в third-party notices.
Компас:
- сохраняет квадратный aspect ratio и основной печатный цвет;
- остаётся vector content PDF;
- вращается по прежней семантике: `0°` — северная стрелка вверх страницы,
`90°` — вправо, `180°` — вниз, `270°` — влево;
- использует действующий resolver `north_deg` и подпись `pdf.north`;
- отсутствует, если направление севера не задано.
PDF writer получает минимально необходимый filled vector primitive с явно
заданными fill rule/fill/stroke. Он не становится универсальным SVG renderer.
## 14. Footer и i18n
Архитектурная легенда полностью удаляется: в PDF нет `wall · door · window`, её
переводов либо разделителей. Неиспользуемые `pdf.legend.*` удаляются из EN/RU/
DE/FR словарей.
В footer сохраняются локализованная дата, `House Plan v…`, действующие
разделители и масштабная линейка. Новых пользовательских строк нет. Изменённые
описания в документации обновляются на EN/RU; существующие строки диалога
проверяются во всех четырёх локалях.
## 15. Модель, совместимость и rollback
Config schema, `config_version`, модель пространства и ключи сохранённых PDF
опций не меняются. Миграции и downgrade converter не нужны. Экспорт читает
исходную модель, создаёт нормализованную временную геометрию и не записывает её
обратно.
Rollback — возврат frontend/PDF lazy chunk к предыдущей версии. Сохранённые
планы и пользовательские настройки остаются совместимы в обе стороны.
## 16. Touch, доступность, безопасность и производительность
- PDF-диалог относится к View, поэтому полностью поддерживается на touch, а не
по best-effort правилу редакторов. На 320 px он остаётся читаемым и usable.
- Клавиатура, Escape, focus trap, focus return и screen-reader names действуют
как в #53; перестройка footer не меняет DOM-порядок действий.
- Генерация остаётся полностью локальной. Новый ассет вендорится, runtime
network request, пользовательский HTML и новые данные HA не появляются.
- Lazy boundary сохраняется: до открытия экспорта PDF writer, compass и шрифт
не загружаются в initial bundle.
- Детерминированность байтов при одинаковых `now`, config и options сохраняется.
- Бюджет построения сцены из теста #53 остаётся `<200 ms` для его large-house
fixture на референсной Node-среде. Проверка двух ориентаций не должна
выполнять повторный разбор модели или декодирование растра.
## 17. Acceptance criteria и доказательства
| AC | Критерий | Обязательное доказательство |
|---|---|---|
| AC1 | При viewport 320×760 в EN/RU/DE/FR ни document, ни surface/body/footer диалога не имеют горизонтального overflow; кнопки полностью доступны и не меньше 44×44 px | targeted browser smoke с `scrollWidth <= clientWidth`, bbox кнопок и фактическим export |
| AC2 | Точные/почти точные соседние вершины схлопываются до удаления коллинеарных точек; каждый реальный поворот сохраняется; нулевых рёбер и ложных `3.39/3.93` нет | unit fixture регрессии + mutation witness, отключающий duplicate normalization |
| AC3 | Размер получают только H/V и near-axis ≤0.25°; грань за порогом не создаёт проекцию/хорду | boundary unit на обе стороны допуска + scene test + mutation witness без axis filter |
| AC4 | Удаляется одна локальная противоположная пара, но сохраняются одинаковые значения разных комнат/rings/осей и непарные равные грани L-контура | unit topology matrix + scene counts + mutation witness глобальной дедупликации |
| AC5 | Подпись центрирована, clearance ≥1 mm, базовая полоса едина; collision перемещает целиком полосу, включая line/extensions/shelf | unit bbox/concave tests + focused golden + mutation witness старого tangent-jitter |
| AC6 | Физические стены/перегородки/обе колонны имеют точный `#7f7f7f` и page-anchored hatch; openings чисты; zero wall не заштрихована | writer operators/color unit + PDF raster semantic probes/golden + mutation witness без even-odd clip |
| AC7 | Обе A4 orientation оцениваются по полному фактическому bbox; выбран максимальный стандартный масштаб, сцена целиком внутри поля и центрирована ≤0.5 mm | scene unit с portrait/landscape и реальными callouts + focused golden + mutation witness старого raw-aspect выбора |
| AC8 | Компас содержит обе канонические path, остаётся vector и верно ориентирован при 0/90/180/270°; без north отсутствует; license сохранена | parser/unit + writer PDF parse + raster probes + license check + mutation witness старой стрелки |
| AC9 | Архитектурная легенда и `pdf.legend.*` отсутствуют, дата/версия/масштаб сохраняются | extracted-text unit/smoke и i18n consistency + mutation witness возврата легенды |
| AC10 | Сохраняются один A4, стандартный scale, все четыре content-toggle, Unicode, отсутствие devices/live-state, deterministic bytes и lazy loading | актуализированные существующие unit/smoke тесты #53 |
| AC11 | Построение large-house scene укладывается в `<200 ms`; initial bundle не получает PDF-код | perf unit + bundle/lazy manifest check |
| AC12 | Пользовательская и техническая документация соответствует новому результату, а golden/docs fingerprints актуальны | `check-docs`, Linux golden verify и полный Linux docs artifact review |
## 18. Тест-план
### Unit / component
- `pdf-dimensions`: exact/near duplicate, seam duplicate, turn retention,
fixed-point compact, zero edge, 0.25° boundary, true diagonal, rectangle pair,
L-contour false pair, equal dimensions across rooms/rings/axes.
- `pdf-scene`: retained counts, midpoint, clearance, whole-lane shift, concave
normal, exact full bbox, both orientations, scale bar, wall/partition/column
material, opening holes, zero wall, compass cardinals, footer text and perf.
- `pdf-writer`: high-precision color, filled compound vector, even-odd clip,
parseability and deterministic bytes after new operators.
- отдельный compass unit: обе path разбираются в конечные координаты, имеют
квадратный bbox и детерминированно трансформируются.
### Browser smoke
- текущий полный экспорт и отказ lazy chunk;
- 320×760 и четыре локали с измерением overflow;
- stacked footer сохраняет cancel/save behavior и download;
- извлечённый текст не содержит легенду.
### Visual / golden
Обновить `pdf-export-geometry-light` и добавить focused scenario с почти
квадратным планом, near-duplicate углом, локальной парой, одинаковыми размерами
двух комнат, проёмом, zero wall, перегородкой, обеими колоннами и `north_deg`.
Semantic guard проверяет:
- ненулевой architectural ink и минимальное заполнение plan field;
- серый участок стены с контрастными hatch-полосами;
- чистую область проёма;
- vector/raster ink в области компаса;
- отсутствие содержимого за полями и приблизительное центрирование.
На Windows разрешены unit/typecheck/build/browser smoke и диагностический
`golden:verify`. Канонические golden и десять docs screenshots переснимаются в
Linux CI/закреплённом контейнере: DirectWrite не является принимаемой заменой
Linux FreeType.
Backend и HA API не меняются, поэтому backend pytest не является целевым
доказательством этой задачи.
## 19. Документация и release artifacts
Обновить:
- `docs/PDF-EXPORT.md` — размеры, hatch, layout, compass, отсутствие легенды;
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md` — вид результата;
- `docs/TESTING.md` — структурные и visual PDF gates;
- `docs/specs/053-pdf-export.md` — явные ссылки на заменённый контракт #482;
- `docs/specs/README.md`, `docs/STATUS.md`, `docs/ARCHITECTURE.md` при изменении
перечисленных модулей;
- screenshot диалога PDF и оба changelog.
Коммиты реализации получают `User-Visible: yes`. Changelog EN/RU коротко
описывает исправленный экспорт и ссылается на #482. Release body по общему
процессу содержит только значимое пользовательское изменение и ссылки на оба
changelog.
## 20. Ожидаемая карта изменений
- `src/pdf/pdf-dimensions.ts` — нормализация, axis filter, кандидаты и пары;
- `src/pdf/pdf-collision.ts` — точные box/segment collision-предикаты для полос;
- `src/pdf/pdf-scene.ts` — dimension lanes, material, full-scene candidates;
- `src/pdf/pdf-writer.ts` — clipping, filled vector и точный цвет;
- `src/pdf/pdf-compass.ts` и лицензионный notice — канонический compass asset;
- `src/pdf/hp-pdf-dialog.ts` — narrow responsive contract;
- `src/i18n/{en,ru,de,fr}.json` — удаление мёртвых legend keys;
- targeted unit/smoke, golden matrix/guards/fixtures и документы §19.
Карта не является разрешением на сопутствующий refactoring. Любой новый
runtime dependency, config migration или выход за PDF/View dialog требует
возврата в `S3-spec`.
## 21. Риски
| Риск | Снижение |
|---|---|
| Локальная дедупликация удалит два разных размера с одинаковым числом | topology pair требует общий ring, ось, interval, противоположные normals и inside probe; negative matrix AC4 |
| Очистка дублей срежет настоящий короткий участок | физический epsilon 1 mm, forward-dot и сохранение каждого turn; boundary tests AC2 |
| Штриховка закроет проём либо даст швы | page-anchored pattern + even-odd clip + raster probes AC6 |
| `#7f7f7f` округлится до `#808080` | отдельный high-precision color formatter и operator/raster test |
| Более точный layout станет медленным | model parse/raster decode один раз, perf budget AC11 |
| Полный bbox всё ещё пропустит rotated text/line width | bbox строится после фактических annotations и включает stroke/text extents; adversarial tests AC7 |
| Compass нарушит лицензию или раздует runtime | две вендоренные path, MIT notice, без общего SVG runtime и в lazy chunk |
| Narrow fix сломает focus либо действия | DOM-order неизменен, browser keyboard/download smoke AC1/AC10 |