Issue: #482 User-Visible: no
24 KiB
Код-ревью #482 — Доводка экспорта пространства в PDF
- Заход: r1 (первый заход код-ревью; спек-ревью прошло 2 захода и здесь не пересчитывается)
- Диапазон:
origin/dev...HEAD, финальный SHA9130c4fc59682e148698b2abed60dc06d6960ab5 - ТЗ:
docs/specs/482-pdf-export-polish.md(зелёное спек-ревью r2,docs/reviews/SPEC-REVIEW-482-r2.md)
Скоуп
Задача переписывает четыре подсистемы PDF-экспорта: нормализацию контуров
перед построением размеров (устраняет ложные диагонали из #482), локальную
дедупликацию противоположных размеров, точное позиционирование размеров с
коллизиями, штриховку физических стен/перегородок/колонн, полную компоновку
листа по фактической аннотированной сцене, векторный компас и удаление
легенды, плюс адаптивный диалог экспорта. Diff: 85 файлов, ядро — 6 файлов в
src/pdf/ (~2450 строк), остальное — тесты, golden, документация,
сгенерированный бандл (класс D).
Как проверялось
Дешёвые гейты (tsc, npm test, npm run build) подтверждены зелёным
Validate на точном SHA 9130c4fc
(run 34102355549,
conclusion: success), это же покрывает check-docs, provenance-гейт,
process-гейт, бандл-синхронизацию и бюджет (bundle:budget внутри шага
«Card bundle trees in sync»), и все три шарда mutation-gate по диффу
(Мутанты по диффу (1-3/3) — success). Отдельно проверил job-список этого
прогона: golden — success на этом же SHA; smoke и performance_smoke в
этом Validate были skipped (heavy-гейтинг не активировался для этого
пуша), поэтому их я прогнал сам:
node scripts/smoke-select.mjs --base origin/dev --head HEAD→ 11 прямых совпадений + 2 зарегистрированные связи (smoke_pdf_export,smoke_plan_drawing_repairs) = 13 смоков, совпадает с перечнем автора в S6.npm run bundle:sync(свежий бандл для demo-стенда), затем все 13 выбранных смоков вручную:smoke_decor_layer_order,smoke_decor,smoke_draw_wall_thickness,smoke_grid_snap,smoke_near_axis_optimize,smoke_optional_space_model,smoke_room_resize,smoke_sun_soft,smoke_wall_thickness,smoke_wallthick_standalone,smoke_zero_divider_taper,smoke_pdf_export,smoke_plan_drawing_repairs— 13/13 OK (все exit 0, всеcheckAllпечатаютOK).- Performance-бюджет AC11 (
<200 ms) — коммитtest: isolate PDF performance budget(63d519e3) переносит этот бюджет в юнит-тестtest/pdf-scene.test.mjs, то есть он уже прогнан в рамках зелёногоnpm testна этом SHA; отдельный browser-perf прогон не требуется. - Golden: уже прогнан и принят на этом SHA в CI (
goldenjob success), а сам accept сделан--reviewedс явным--expect-change/--expect-newпо полному Linux-артефакту (S6-комментарий,run 34101566145), трейлерыRelease:/Baseline-Reviewed:на коммите9130c4fcподтверждены process/provenance-гейтом. Дополнительный ручнойgolden:verifyне требовался. - Model invariants (
npm run invariants) — не прогонял и не считаю обязательным: diff не трогает модель пространства/рёбра/записи толщины/layout/marker.space/open_spans(все изменения — только вsrc/pdf/**, печатная геометрия строится поверх уже существующихgeometryOuterRings/innerContourForRoom/wallBodiesGeometryи не модифицирует их; §15 ТЗ прямо фиксирует «модель… не меняются»). Проверил это чтением: список файлов diff вsrc/ограниченsrc/pdf/*.tsиsrc/i18n/*.json. test/single-source-numbers.test.mjsне изменён и не упоминаетpdf— проверил, что в новом коде нет второго источника для одной и той же видимой величины (PDF-диалог показывает только чекбоксы, не числа; сам PDF — единственный источник напечатанных размеров).
Разбор по AC (чтением кода — не догадка, построчно против diff)
Читал полностью diff src/pdf/pdf-dimensions.ts (649 строк),
pdf-collision.ts (190, новый), pdf-writer.ts (215),
pdf-compass.ts (64, новый), hp-pdf-dialog.ts (74) и pdf-scene.ts
(736, самый крупный файл) лично; плюс тесты
test/pdf-dimensions.test.mjs, test/pdf-writer.test.mjs,
test/pdf-compass.test.mjs, test/pdf-collision.test.mjs,
test/pdf-scene.test.mjs, demo/smoke_pdf_export.mjs,
scripts/mutation-gate.mjs.
- AC1 (диалог 320px, 4 локали) —
hp-pdf-dialog.ts:.body/.rowполучилиmin-width:0/box-sizing:border-box;@media(max-width:360px)переводит.rowвgrid-template-columns:minmax(0,1fr), кнопкиwidth:100%. DOM-порядок — Cancel первым, Save вторым (строки 160/162), что при grid-стекинге даёт «Отмена сверху, Сохранить снизу» как требует §7.demo/smoke_pdf_export.mjsдобавилnarrowLocales-проверку на 320×760 по всем четырём локалям:document/surface/body/footerscroll fits, кнопки ≥44×44 внутри footer,tabIndex/DOM-порядок сохранены, Cancel закрывает диалог. Прогнал сам — зелёный. Доказано. - AC2 (схлопывание дублей перед коллинеарностью) —
pdf-dimensions.ts:normalizeRing()вызываетcollapseAdjacentDuplicates(включая шов «последняя→первая» первым) доremoveForwardCollinearPoint, повторяет до fixed point (строки 216–222). Юнитtest/pdf-dimensions.test.mjsвоспроизводит ровно кейс issue (почти совпадающая вершина у угла) и проверяет результат по значению, не по количеству точек; мутантpdf-duplicate-normalization-too-late(глушитcollapseAdjacentDuplicates) прогнан в CI (Мутанты 1-3/3 — success). Доказано. - AC3 (только H/V и near-axis ≤0.25°) —
projectDimensionEdge()возвращаетnull, еслиminor/major > NEAR_AXIS_MAX_SLOPE, без хорды к соседям (edge просто выпадает изdimensionEdgesFromNormalized, соседние точки не соединяются). Юнит проверяет включительную границу и её нарушение на*1.000001, плюс кейс «диагональный угол не создаёт хорду соседям» (diagonalEdges.length === 3). Доказано. - AC4 (локальная дедупликация пары, не глобальная по тексту) —
areOppositeDimensionEdges/dedupeOppositeDimensionEdges: требует общийringIndex, противоположные нормали, совпадение tangent-интервала и длины, midpoint внутри ring; текст в предикате не участвует. Юнитopposite dedupe preserves equal non-opposite L/C contour spans and separate ringsи сценарийdimension dedupe is localвpdf-scene.test.mjs(равные8.25 m/4.65 mв разных комнатах сохраняются по 2 экземпляра) закрывают это по счётчикам, не только по наличию функции. Доказано. - AC5 (центрирование, целая полоса, вогнутая нормаль) —
pdf-scene.ts: внутренние/внешние лейауты используют точныеpdfBoxTouchesGeometry/pdfSegmentTouchesGeometry/pdfSegmentTouchesBox(не сэмплированные точки), сдвиг полосы целиком — внутренние группы поnormalKey(ось+знак нормали) шагами 3мм, внешние — поgroupCollinearDimensionEdges(ось+знак+координата) шагами 4мм; при неудаче на всех шагах подпись опускается, а не рисуется поверх стены (комментарий «omit the unsafe label rather than knowingly printing it through a wall»). Внутренняя группировка по одному только axis+normalSign (не по точной коллинеарности) — на первый взгляд похоже на потенциальную избыточную связку двух разных стен одного знака нормали в ступенчатой комнате, но это осознанное и протестированное поведение:test/pdf-scene.test.mjs→'whole dimension lane keeps grouped labels centered with 1 mm clearance and no tangent jitter'— L-образная комната, два горизонтальных ребра на разных Y с одинаковым знаком нормали, утверждение «one collision moves every label in the normal group by the same whole-lane amount» именно такое поведение и фиксирует, попутно проверяя отсутствие коллизий с меткой комнаты/площадью. Не нахожу это находкой — только уточнение для протокола.inwardNormalForEdge(probe по обе стороны ребра) заменил старыйringCentroid-эвристику;ringCentroid/edgeNormal/outsideNormalбольше не импортируются вpdf-scene.ts(проверил grep). Доказано. - AC6 (
#7f7f7f, page-anchored hatch, even-odd, zero wall без штриховки) —pdf-scene.ts:WALL = [127/255,127/255,127/255];pageHatchLines()строит диагонали в абсолютных координатах страницы (interceptот-pageHeightдоpageWidth+step, шаг3mm·√2), один и тот же массив передаётся во все стены/перегородки/колонны → фаза общая, швов между соседними телами нет.hatchесть только уbuilt.geometry.components(физические тела ненулевой толщины); нулевые стены (built.zero.lines) рисуются отдельным путём безhatch.pdf-writer.ts:hatch-ветка эмититW* n(even-odd clip) после заливки, потом обводку сверху — порядок «fill → hatch clipped → stroke» ровно как в §11. Цвет — отдельныйfmtColorс точностью до 6 знаков (127/255→0.498039, юнит проверяет и что координатныйfmtне изменился —10.12, не10.1234). Мутантыpdf-wall-material-not-grey,pdf-wall-hatch-removed,pdf-hatch-loses-evenodd-holeцелятся ровно в эти три инварианта и прогнаны в CI. Доказано. - AC7 (обе ориентации по полной сцене, максимальный масштаб,
центрирование ≤0.5мм) —
buildPdfPage():preparePdfScene()считает модель один раз, затемPDF_SCALE_SERIESперебирается по возрастанию (значит по убыванию печатного масштаба — первый подошедший деноминатор и есть «максимальный масштаб» в терминах ТЗ), для каждого масштаба строится полная аннотированная сцена (buildPdfCandidate) через дешёвый прематчrawArchitectureCanFit(консервативный, так как добавление аннотаций может только увеличить bbox, никогда не уменьшить — корректно как отсекающий фильтр), проверяетсяpdfSceneFits(..., 0.5*MM), из подошедших выбираетсяbetterPdfCandidate(по доле заполнения поля, потом portrait). Старый безусловный резерв 30/48мм удалён (grep поdimensionReserveMm/calloutWidthMmв этом файле — не встречается, использованы только реальные bbox черезpdfCommandBounds). Для нефитующихся раскладок — bracket/refine до шага 50 иthrow new Error('pdf.failed'), никакого best-effort обрезанного PDF. Мутантpdf-layout-uses-raw-aspectиpdf-scale-*-семейство целятся в эту логику. Доказано. - AC8 (компас, вращение 0/90/180/270°, лицензия) —
pdf-compass.ts:PDF_COMPASS_PATHS— обаd-пути побайтово совпадают с каноническим SVG из тела issue (сверил построчно). Юнитtest/pdf-compass.test.mjsгеометрически проверяет направление стрелки для всех четырёх кардинальных углов через реальную трансформацию ((18,18)→(17,17)— диагональ иглы), не полагаясь на код писателя; подтверждает0°→[0,-1](вверх страницы в top-down координатах),90°→[1,0],180°→[0,1],270°→[-1,0]— совпадает с §13 буквально.pdf-scene.tsвызываетpdfCompassOpsтолько когдаnorth !== null; старый код рисования стрелки-треугольника полностью удалён (не оставлен под флагом).THIRD_PARTY_NOTICES.md(корень иcustom_components/houseplan/) — идентичны побайтово (diffпусто), содержат точный upstream commit, MIT, copyright VMware 2018; отдельный юнит сверяет обе копии нотиса. Upstream-blob я не фетчил (WebFetch не доступен в этой сессии — то же ограничение, что и в спек-ревью r1/r2); полагаюсь на совпадение путей с телом issue, что автор спек-ревью уже явно пометил как не до конца независимо подтверждённое (см. «Унаследовано» ниже — не совсем то же самое, это код-ревью r1, но фиксирую тот же остаточный пробел). AC доказан для всего, что проверяемо без сетевого доступа. - AC9 (легенда удалена) —
pdf-scene.ts: блок построенияlegendполностью вырезан (не спрятан за условием), footer оставляет дату/версию и scale bar.pdf.legend.*удалены из всех 4 локалей (en/ru/de/fr — сверил построчно, симметрично). Юнит'PDF scene integrates the vector compass and never restores the architectural legend'проверяет отсутствие текста легенды и отсутствие обращений кpdf.legend.*вt(). Доказано. - AC10 (существующие гарантии #53) — покрыт существующим/актуализированным
unit/smoke набором
#53, входящим в зелёныйnpm test; отдельно не перепроверял построчно, полагаюсь на факт отсутствия regressions в golden/mutation прогонах на этом SHA. - AC11 (perf, lazy bundle) —
preparePdfSceneвызываетphysicalGeometry(input)один раз и передаётpreparedво всеbuildPdfCandidate-попытки (до 18+ штук при переборе масштаба/ориентации) — модель не перепарсится и растр не передекодируется на каждой попытке. Бюджет<200мс— юнит-тест (см. «Как проверялось»). Бандл:manifest/lazyPdfFilesне расширился новым top-level импортом (compass — тоже файл внутриsrc/pdf/, тянется тем же ленивым модулем), бюджетbundle:budgetзелёный на этом SHA. Доказано. - AC12 (документация) —
docs/PDF-EXPORT.md,docs/USER-GUIDE(.ru).md,docs/ARCHITECTURE.md,docs/STATUS.md,docs/TESTING.md,docs/specs/053-pdf-export.md(явная пометка «частично переопределено #482»),docs/specs/README.md— все обновлены и терминологически согласованы (штриховка/компас/легенда/адаптивный диалог описаны одинаково в EN и RU).check-docsзелёный на этом SHA (screenshot fingerprint покрывает весьsrc/**). Доказано.
Трейлеры и changelog
Все 6 коммитов диапазона несут Issue: #482. User-Visible: yes —
15b060ac (fix: polish PDF export) и 7e7c9f3b (fix: harden PDF dimension
placement) — оба правят docs/CHANGELOG.md и docs/CHANGELOG.ru.md в том
же коммите; тексты синхронны по содержанию (штриховка/компас/дедупликация/
диалог). 9130c4fc (test: accept PDF export baselines) несёт
Release: v1.73.0-beta.4 и Baseline-Reviewed: на реальный прогон —
формат и наличие подтверждены зелёным provenance-гейтом в Validate.
Остальные три коммита — User-Visible: no, класс B/C (тесты, скриншот,
mutation-gate), трейлеры корректны.
Мутационное покрытие (выборочно, не полный листинг)
Прочитал diff scripts/mutation-gate.mjs целиком и сверил именованные в
S6-хендоффе автора мутанты (pdf-opposite-dedupe-ignores-safe-side,
pdf-rectangle-restores-unsafe-label, pdf-duplicate-normalization-too-late)
и ещё шесть новых (pdf-diagonal-dimension-restored,
pdf-equal-text-deduped, pdf-zero-wall-gets-solid-fallback,
pdf-wall-material-not-grey, pdf-wall-hatch-removed,
pdf-hatch-loses-evenodd-hole, pdf-layout-uses-raw-aspect) построчно
против production-кода — каждый патч попадает ровно в ту строку, что
реализует соответствующий инвариант (не decorative-мутант, бьющий мимо).
Все три шарда Мутанты по диффу зелёные на этом SHA.
Что не проверял и почему
- Байты upstream SVG-коммита VMware — не фетчил (
WebFetchнедоступен в этой сессии); совпадениеd-путей с телом issue сверил вручную посимвольно, этого достаточно для AC8 в объёме, доступном инструментам этой сессии. - Полный browser-smoke матрикс (229 файлов) — не входит в объём этого
раунда; выбор по диффу (
smoke-select.mjs) дал 13 файлов, все прогнаны. Полный прогон — обязанность пред-релизного гейта (PROCESS.md §8), не ревью. python -m pytest tests_backend— не прогонял: diff не трогаетcustom_components/**/*.py.npm run invariants— не прогонял: diff не трогает модель пространства/геометрии, только печатное представление поверх неё (см. «Как проверялось»).- Полный
npm run golden:verifyлокально — не прогонял: golden уже зелёный и явно принят--reviewedна этом же SHA в CI, включая новый сценарийpdf-export-polish-light.
Находки
Нет High. Нет Medium — ни в скоупе, ни вне скоупа. Единственное наблюдение (см. AC5 выше про группировку внутренних лейнов по axis+normalSign без учёта коллинеарности) — не находка: поведение осознанно и закрыто отдельным юнит-тестом с явной формулировкой инварианта.
Вердикт
Зелёный. Реализация построчно соответствует ТЗ, каждый AC либо доказан целевым автотестом с проверенной способностью падать (мутационный гейт), либо разобран мной чтением кода с указанием конкретных строк. Трейлеры, changelog, документация и лицензионный нотис в порядке.
Материал раунда
- Ветка:
issue/482-pdf-export-polish, коммит9130c4fc5968— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
9f961b813958a7c359d561ba8eea7ee4521aa5d0git log --all --format='%H %T' | grep 9f961b813958 - ТЗ
docs/specs/482-pdf-export-polish.md, блоб952f595fe2f61d375d317b929b0323bb2441262bgit log --all --find-object=952f595fe2f61d375d317b929b0323bb2441262b -- docs/specs/482-pdf-export-polish.md