Compare commits

...
Author SHA1 Message Date
claude[bot] 31d7a70e34 docs: review document for #302
Validate / docs (push) Failing after 28s
Validate / process-workflow-sync (push) Successful in 36s
Validate / provenance (push) Successful in 55s
Validate / process-gate (push) Failing after 43s
Validate / changes (push) Successful in 40s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / frontend (push) Skipped
Validate / reuse (push) Successful in 44s
Validate / smoke (1) (push) Skipped
Validate / smoke (2) (push) Skipped
Validate / smoke (3) (push) Skipped
Validate / smoke_done (push) Skipped
Validate / golden (push) Skipped
Validate / performance_smoke (push) Skipped
Validate / backend (push) Skipped
Issue: #302
User-Visible: no
2026-08-25 15:12:23 +00:00
Codex 96318fb94f docs: refresh the screenshot fingerprint on the merged tree (#302)
Отпечаток снова сдвинут ребейзом; кадры прежние.

Issue: #302
User-Visible: no
2026-08-25 17:50:26 +03:00
Codex d3927f2e56 build: rebundle on the merged tree (#302)
Обе копии байт в байт; npm test 1304/1304 на объединённых исходниках.
Ссылка ниже — прогон, подтверждающий ревью кода, из которого бандл собран.

Issue: #302
User-Visible: no
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32859268589
2026-08-25 17:44:16 +03:00
Codex b2b2640751 test: accept the safe-resize baselines on the merged tree (#302)
Ребейз на dev столкнул две легитимные правки одной пары сцен: мой узел
(сомкнутая вершина ромба, decision №5) и plan-axis подсветку из dev. Обе
сцены пересняты на объединённом коде и осмотрены: вершина ромба сомкнута,
осевые линии dev на месте. Остальные 124 сцены не тронуты — шум `accept`
возвращён; хэши двух сцен, чьи PNG пришли из dev при ребейзе, приведены к
фактическим файлам.

Принято `npm run golden:accept -- --reviewed`; `golden:verify` после отката
шумовых — 126/126.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32859268589
2026-08-25 17:43:37 +03:00
Codex d8c7652d0e docs: AC6 names the #271 trim as the one subtraction the node keeps (#302 r2 M3)
Код честно держал адресный латеральный трим с самого решения №5, дока после
M1 описывала его верно — расходился только текст ТЗ, писавший «демонтирован
целиком». §4.5, §8.2 и AC6 приведены к фактическому контракту.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex 8a82189368 chore: re-run CI for the flaky grid-scale shard (#302)
Пустой коммит: локально смок grid_scale_invariance стабильно зелёный (3/3,
darkView changed=69 при пороге 150), и тот же дифф-фон 69 воспроизводится на
чистом dev — падение шарда на прошлом прогоне похоже на средовую
вариативность раннера, а прав на rerun-failed-jobs у токена нет.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
claude[bot]andCodex 8eef4e9c5a docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex cc937de0b1 docs: refresh the screenshot fingerprint after the dead-layer removal (#302)
Удаление мёртвых слоёв изменило отпечаток исходников; сами кадры прежние.

Issue: #302
User-Visible: no
2026-08-25 17:34:20 +03:00
Codex d3ed299d2a refactor: fans are the whole additive node — dead support re-union removed (#302)
С адресным тримом (он не режет полосы обычных узлов) возврат саппорт-квадов в
тело стал мёртвым слоем: полный юнит-набор зелёный без него — проверено
исполнением, а не предположено. По дисциплине мутационного реестра
избыточный слой убран (fans only), его мутант `junction-supports-not-restored`
снят: у #271-узлов трим режет только ЗА пределами саппорта, возвращать
нечего. Саппорт-квады остаются экспортом `junctionNodeGeometry` — детектор и
тесты используют их как источник контрактной истины.

`npm test` 1303/1303; `golden:verify` 126/126; контракт-проба репро — 0;
`smoke_junction_holes` OK.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex 9c396935dc fix: the fans-disabled mutant guards by fan shape, not the detector (#302 r1 M2)
Контрактные пробы детектора строятся из той же junctionNodeGeometry и слепнут
вместе с мутацией; юнит «T-узел даёт два веера» — внешняя истина. Смоковый
гвард с полной сборкой оставался зелёным на сломанном коде — проверено
штатным харнесом, а не заявлено.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex ec8dc6fde1 fix: close both code-review mediums and drop the dead paper layer (#302 r1 M1,M2)
**M1.** `docs/WALL-THICKNESS.md` §3 «Junction nodes» переписан под решение №5:
полный mitre, фаска #249 в отставке, `bevelMultiWallBody` — только адресный
латеральный трим. Прежний абзац описывал отменённое утреннее решение.

**M2.** Guard мутанта `junction-fans-disabled` собирает `test-build` и бандл
перед смоком: `smoke_junction_holes` — единственный смок, импортирующий из
`test-build`, и в чистом worktree он падал `ERR_MODULE_NOT_FOUND` до
применения мутации. Ревью прав: после переякорения guard'а на смок я не
перегнал его штатным харнесом — только ручной test-build-патч, который worktree
не видит.

**Low + следствие.** `bevelMultiWallPaper` удалена как мёртвый код; следом
измерено (фикстура #197 и репро владельца — байт в байт с веерами и без), что
и `paperWithNodeCorners` бумаге ничего не даёт: footprint ∪ shell уже
покрывает каждый узел. Слой удалён целиком, бумага возвращена к rawPaper.
Осиротевший мутант `multi-wall-paper-full-origin-cut` (#261, «белый клин от
вычитающего разреза бумаги») снят с обоснованием: в бумаге не осталось ни
одного вычитания — этот класс регресса невозможен по построению.

`npm test` 1303/1303; `golden:verify` 126/126; контракт-проба репро — 0.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
claude[bot]andCodex 58c096cef1 docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex e88abcb138 docs: list the junction tooling in the wall-thickness testing map (#302)
Заодно даёт CI прогон с валидным before-SHA: предыдущий пуш был вынужденно
форсовым после ребейза на #265, и process-gate на CI не смог вычислить
диапазон от затёртой вершины.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex 9dbdbec03d build: rebundle and mend the baseline manifest after the rebase onto #265 (#302)
Ребейз на свежий dev (#265 import seam + его эталон) слил baselines-index из
двух источников; поштучное слияние потеряло `matrixVersion: 45` и держало
хэши двух сцен, чьи PNG пришли из dev. Индекс поправлен по фактическим
файлам, `golden:verify` — 126/126 с валидным манифестом. Бандл и отпечаток
скриншотов пересобраны из объединённых исходников (`npm test` 1303/1303).

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32848447191
2026-08-25 17:34:19 +03:00
Codex c179a89876 test: report raw pixel-diff numbers in the grid-scale smoke (#302)
Шард 3 упал на CI по darkViewPixelsMatch, локально смок стабильно зелёный
(3/3, changed=69 при пороге 150), и тот же фон 69 воспроизводится на чистом
dev. Смок печатал только булевы вердикты — добавлен диагностический вывод
сырых дифф-метрик, чтобы прогон CI показал фактическую величину дрейфа.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex df38c5c93b chore: re-run CI for the flaky grid-scale shard (#302)
Пустой коммит: локально смок grid_scale_invariance стабильно зелёный (3/3,
darkView changed=69 при пороге 150), и тот же дифф-фон 69 воспроизводится на
чистом dev — падение шарда на прошлом прогоне похоже на средовую
вариативность раннера, а прав на rerun-failed-jobs у токена нет.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex b6f80c56f0 test: accept the decision-#5 junction baselines
Шесть сцен, изменившихся законно при переходе на полный mitre (решение №5):
`junction-y-60-equal50-dark` (вырез исчез), `junction-acute30-mixed-dark`
(рожок фаски ушёл, остался законный торец перехода толщин 15→70),
`junction-splay10-170-dark`, `junction-owner-repro-dark` (репро владельца:
стык сомкнут полностью) и `safe-resize-handles-clamp-{light,dark}`, где
вершина ромбовидной комнаты в узле теперь сомкнута веером вместо прежнего
зазора. Остальные 120 сцен совпали; шумовая пересъёмка `accept` возвращена к
прежним байтам вместе с хэшами (практика #230).

Принято `npm run golden:accept -- --reviewed` по полному локальному
Linux-прогону; после отката шумовых verify чист (126/126). Каждая сцена
осмотрена.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32840836354
2026-08-25 17:34:19 +03:00
Codex aac501516e feat: full mitre at every node — the #249 chamfer retires (#302, decision #5)
Владелец, осмотрев первые эталоны сета, отменил дневное решение о сохранении
фаски: `junction-y-60-equal50` показывал вырез, `junction-acute30-mixed` —
торчащие углы. По визуальному сравнению трёх вариантов принято: узлы
смыкаются полным mitre, как обычное пересечение стен на чертеже.

Итоговое правило веера (одно на все случаи):

- mitre принимается, когда он В СЕКТОРЕ пары (вперёд по лучам для обычной
  пары, назад — для рефлексной: наружный угол между крайними лучами, где и
  жил вырез Y-60), в пределах классического `MITRE_LIMIT` и не дальше конца
  толстого саппорта (#271);
- рефлекс без валидного mitre замыкается плоской хордой между гранями;
- обычная пара без mitre — локальный бевел: ход по граням ограничен толстым
  саппортом, лимитом и двойной толщиной пары, чтобы хорда осталась деталью
  угла. Гигантские бевел-«бабочки» и mitre вне сектора — две реальные ошибки
  промежуточных версий, обе пойманы на сценах сета до пуша.

Слой `bevelMultiWallBody` сохранён только как АДРЕСНЫЙ латеральный трим для
узлов с вырожденно-коротким толстым саппортом (#271); все прочие узлы — чисто
аддитивные, следы трима на них исчезли. `bevelMultiWallPaper` из бумаги
удалён. Обе записи CHANGELOG приведены к финальному контракту.

Тесты: юниты §302 усилены; площадь фикстуры #197 +0.6 юнита²; мутанты
переякорены, краснота каждого проверена исполнением.

Issue: #302
User-Visible: yes
2026-08-25 17:34:19 +03:00
Codex 8af5ebefd2 test: multiwall smoke follows the strip-safe chamfer contract (#302)
Смок `smoke_multiwall_junction` держал старый контракт «клин за фаской пуст» —
его проба лежит в перекрытии двух полос узла и по strip-safe правилу #302
обязана остаться заполненной. Пропущен в первом прогоне AC9, пойман CI.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex ef22c2c6fd build: rebundle after the rebase onto #301/#303 (#302)
Ребейз на свежий dev (поиск в селекторах проёмов #301, честная подсветка
толщины #303) объединил исходники; бандл и отпечаток скриншотов пересобраны
из результата. Обе копии бандла байт в байт; `check-docs` passed; полный
`npm test` 1298/1298 и golden verify 126/126 на объединённом коде.

Issue: #302
User-Visible: no
2026-08-25 17:34:19 +03:00
Codex a6ef664bef test: accept the #302 junction close-up baselines
Шестнадцать новых сцен стыков крупным планом (звёзды лучей: T/X/Y, острые
15°/30°, почти коллинеарные, смешанные толщины, виртуальные участки, колонна,
черновик) плюс сцена-репро владельца. Только новые файлы: все 110 существующих
сцен на этом коде прошли verify побайтно — переработка узлов не изменила ни
одну старую картинку, что и требовал AC3. Пересъёмка шумовых копий,
оставленная `accept` на прочих сценах, возвращена к прежним байтам вместе с
хэшами в индексе (практика #230).

Принято `npm run golden:accept -- --reviewed` по полному локальному
Linux-прогону (126/126), после принятия verify чист. Каждая новая сцена
осмотрена; кладка узлов сплошная, фаски #249 на месте.

Baseline-Reviewed указывает на зелёный прогон Validate этой ветки
(ревьюированное ТЗ, SHA 3b19111) — прогона CI с этими эталонами до этого пуша
не существует; job `golden` на них выполнится в прогоне этого же пуша.

Issue: #302
User-Visible: no
Release: v1.68.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/32819360269
2026-08-25 17:34:19 +03:00
Codex 76d60b3e1b feat: junction nodes — detector, fans over the approved chamfer, scene set (#302)
Вторая половина переработки узлов поверх ядра из прошлого коммита:

- `junctionContractHoles` — объективный инвариант «тело ⊇ полосы ∪ веера в
  фасадной границе» как экспортная чистая функция; самопроверка на заведомо
  дырявой фикстуре входит в юниты. Первая формулировка детектора из ТЗ
  («окружено кладкой с ≥5 из 8 сторон») уточнена в §8.4 по факту измерения:
  она ложно флагует легитимный пол комнаты в острых внутренних углах.
- `junctionNodeBound` — «гладкая» фасадная граница (конверт с обычными углами)
  экспортирована: ею клиппуются куски узла и ею же пользуются тесты.
- юниты #302: веера/рефлекс/короткий толстый саппорт/детектор/репро
  end-to-end; хелпер тестов старого контракта переведён на strip-safe
  семантику по саппортам и фасадной границе.
- смок `smoke_junction_holes`: контрактные пробы считаются в node из той же
  фикстуры и проверяются в браузере по реальному `d`-пути карточки.
- сет из 16 golden-сцен стыков крупным планом (звёзды лучей: T/X/Y/острые/
  почти коллинеарные/виртуальные/колонна/черновик) + сцена-репро владельца;
  билдер сцен и `zoomCenter` в harness. Контракт сцены
  `multiwall-junction-bevel-view-dark` инвертирован по решению владельца:
  проба в перекрытии полос обязана быть ЗАПОЛНЕНА (strip-safe), а не пустой.
- семь мутантов §14, включая слепоту детектора и невозврат саппортов.
- фикстура #197: площадь кладки выросла на 0.2 юнита² — слайвер вееров вдоль
  хорд фаски; константа обновлена с комментарием.

Эталоны новых сцен идут отдельным коммитом с положенными трейлерами.

Issue: #302
User-Visible: yes
2026-08-25 17:34:19 +03:00
Codex a1361974a3 feat: junction node gets back what a chamfer must never eat (#302, wip)
Ядро переработки узлов. Слой фаски #249 (`bevelMultiWallBody`) остаётся как
утверждённый вид, но после него узел аддитивно получает обратно:

- точные саппорт-квады своих лучей (каждый ограничен собственной конечной
  длиной — обрезанный латеральный фантом #271 вернуться не может);
- по вееру на каждую пару соседних по азимуту лучей (сектор ≤ 180°; рефлексные
  секторы — внешность выпуклого угла — пропускаются), mitre в пределах лимита
  узла, иначе bevel-хорда на том же радиусе, что и хорда фаски.

Куски клиппуются «гладкой» фасадной границей (`junctionNodeBound`: конверт с
обычными углами, без узловых засечек) — узел не может отрастить новый фасад
(контракт вогнутого Split), но и не теряет секторные веера, как терял бы при
клипе по засечённому конверту. Вырожденные кольца нулевой площади, которые
polyclip оставляет на совпадающих хордах, вычищаются.

Тесты старого контракта переведены на новый: клин за фаской заполнен, если
лежит в полосах узла (острый стык — сплошная кладка, сама починка #302), и
пуст вне полос (фаска #249 как была). Хелпер и точечные тесты #249/#271/#197 и
corner-Split обновлены; площадь фикстуры #197 выросла на 0.2 юнита² — слайвер
вееров вдоль хорд фаски.

Проверено исполнением: `npm test` 1286/1286; контракт «тело ⊇ полосы ∪ веера»
на репро владельца — 0 пропаж; клинья на скриншоте исчезли.

Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
Codex f1c76aa415 docs: record the owner's chamfer decision and the strip-safe cut contract (#302)
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
claude[bot]andCodex 3df1dcf5a3 docs: review document for #302
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
Codex eef4e0d6d4 docs: spec for #302 — additive junction node material
Issue: #302
User-Visible: no
2026-08-25 17:34:18 +03:00
50 changed files with 2511 additions and 356 deletions
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -1,8 +1,8 @@
{
"schema": 1,
"matrixVersion": 44,
"acceptedAt": "2026-08-25T14:11:51.193Z",
"sourceFingerprint": "34fbdde2302692a3fb53a9cb9b25bf3dd85f3dcbeaad074e3a5f615ef39884b4",
"matrixVersion": 45,
"acceptedAt": "2026-08-25T14:41:01.859Z",
"sourceFingerprint": "8bca2e9a29df5fd509825fbfe2a790d7aa71c0f775867abf3328e3d609fa900d",
"chromium": "151.0.7922.34",
"scenarios": {
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
@@ -34,8 +34,8 @@
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
"safe-resize-handles-clamp-light": "b7df3873e9412a02c6b0a9e7038ce06d88e3630a398c8f2e12469e378f48ca3d",
"safe-resize-handles-clamp-dark": "e5e3d7eb3879998ec188bffd630cdd3fdf587f4960980b6a0fe8e91bc98c95c2",
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
@@ -93,6 +93,22 @@
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 455 KiB

+77 -4
View File
@@ -2,6 +2,9 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
const junctionArtifactsFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
@@ -224,6 +227,75 @@ export function prepareGoldenFixture(scenario) {
partitions: [], room_drafts: [], wall_columns: [], decor: [],
});
}
if (scenario.junctionNode) {
// #302: a close-up star node. Pie-slice rooms around the centre; every
// arm is a shared edge carrying its own thickness; a virtual arm is the
// same edge released by an open span.
const spec = scenario.junctionNode;
const C = [0.5, 0.5];
const R = 0.3;
const arms = spec.arms
.map((arm) => ({ ...arm, rad: (arm.deg * Math.PI) / 180 }))
.sort((a, b) => a.deg - b.deg);
const endOf = (arm) => [
C[0] + Math.cos(arm.rad) * R, C[1] + Math.sin(arm.rad) * R,
];
const rooms = [];
for (let index = 0; index < arms.length; index++) {
const a = arms[index];
const b = arms[(index + 1) % arms.length];
const sweep = ((b.deg - a.deg + 360) % 360) || 360;
const arc = [];
const steps = Math.max(1, Math.ceil(sweep / 60));
for (let step = 0; step <= steps; step++) {
const rad = ((a.deg + (sweep * step) / steps) * Math.PI) / 180;
arc.push([C[0] + Math.cos(rad) * R, C[1] + Math.sin(rad) * R]);
}
rooms.push({
id: `junction-slice-${index}`, name: `S${index}`, area: null,
poly: [C.map((v) => v), ...arc],
});
}
const walls = arms.map((arm) => {
const end = endOf(arm);
return { key: fixtureWallKey(C, end), a: [...C], b: [...end], cm: arm.cm };
});
const space = {
id: scenario.space, title: 'Junction node', view_box: [0, 0, 1, 1],
cell_cm: 5,
rooms, walls,
settings: { fill_mode: 'none', show_borders: true, show_names: false },
};
const virtual = arms.filter((arm) => arm.virtual);
if (virtual.length) {
space.open_spans = virtual.map((arm) => {
const end = endOf(arm);
return { a: [...C], b: [...end] };
});
}
if (spec.column) {
space.wall_columns = [{
id: 'junction-column', center: [...C], shape: 'circle', size_cm: 40,
}];
}
if (spec.draft) {
space.room_drafts = [{
id: 'junction-draft',
points: [[0.5, 0.5], [0.75, 0.62]],
segments: [{ cm: 15 }],
}];
}
fixture.config.spaces.push(space);
}
if (scenario.junctionArtifacts) {
fixture.config.spaces.push({
...structuredClone(junctionArtifactsFixture),
id: scenario.space,
title: 'Junction artifacts repro',
view_box: [0, 0, 1, 1],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
});
}
if (scenario.junctionPatchResilience) {
if (!Array.isArray(scenario.retainedWedgeProbe)
|| scenario.retainedWedgeProbe.length !== 2
@@ -262,7 +334,7 @@ export function prepareGoldenFixture(scenario) {
const contract = scenario.multiWallJunction;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
if (!validPoint(contract.node) || !validPoint(contract.retainedOverlapProbe)
|| !Number.isInteger(contract.rays) || contract.rays < 3
|| !Number.isInteger(contract.enclosedHoles) || contract.enclosedHoles < 0) {
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
@@ -728,11 +800,11 @@ export async function prepareGoldenScenario(page, scenario) {
}
}
if (scenario.multiWallJunction) {
const { node, discardedWedgeProbe, enclosedHoles } = scenario.multiWallJunction;
const { node, retainedOverlapProbe, enclosedHoles } = scenario.multiWallJunction;
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
if (!wall?.isPointInFill?.(at(node))
|| wall.isPointInFill(at(discardedWedgeProbe))) {
|| !wall.isPointInFill(at(retainedOverlapProbe))) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
// A pixel threshold missed the reported triangles because they occupy a
@@ -885,7 +957,8 @@ export async function prepareGoldenScenario(page, scenario) {
await until(() => card._renderProjection === 'iso');
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
card._applyView(scenario.zoom, zx, zy);
card.requestUpdate();
await card.updateComplete;
}
+52 -2
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 44;
export const GOLDEN_MATRIX_VERSION = 45;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -306,7 +306,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
space: 'golden-multiwall-junction', mode: 'view',
multiWallJunction: {
node: [0.329166667, 0.141666667], rays: 3,
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
// #302: the chamfer is strip-safe — this point sits in the overlap of
// two incident strips and must stay FILLED (it used to be discarded by
// the old subtractive bevel, which is exactly the hole class fixed).
retainedOverlapProbe: [0.3303808442725, 0.1488560107825],
// Product contract (#272): a bounded exterior bevel is allowed, but no
// empty component may remain trapped inside the continuous masonry.
enclosedHoles: 0,
@@ -571,6 +574,53 @@ export const GOLDEN_SCENARIOS = Object.freeze([
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// --- #302: junction close-ups. One node per scene, the node fills the
// frame; the same scenes carry the objective no-holes detector in tests.
...[
['junction-t-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-t-90-bar50-leg15-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 90, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-90-bar70-leg15-dark', { arms: [
{ deg: 0, cm: 70 }, { deg: 90, cm: 15 }, { deg: 180, cm: 70 }] }],
['junction-x-90-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 15 }] }],
['junction-x-90-mixed-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 50 }, { deg: 180, cm: 15 },
{ deg: 270, cm: 70 }] }],
['junction-x-45-alternating-dark', { arms: [
{ deg: 45, cm: 15 }, { deg: 135, cm: 50 }, { deg: 225, cm: 15 },
{ deg: 315, cm: 50 }] }],
['junction-star5-equal15-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 72, cm: 15 }, { deg: 144, cm: 15 },
{ deg: 216, cm: 15 }, { deg: 288, cm: 15 }] }],
['junction-y-60-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 60, cm: 50 }, { deg: 120, cm: 50 }] }],
['junction-acute30-mixed-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 30, cm: 70 }, { deg: 180, cm: 15 }] }],
['junction-acute15-equal50-dark', { arms: [
{ deg: 0, cm: 50 }, { deg: 15, cm: 50 }, { deg: 180, cm: 50 }] }],
['junction-splay10-170-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 10, cm: 15 }, { deg: 180, cm: 50 }] }],
['junction-t-virtual-arm-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }] }],
['junction-x-virtual-through-dark', { arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
{ deg: 180, cm: 15 }, { deg: 270, cm: 15, virtual: true }] }],
['junction-column-node-dark', { column: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
['junction-draft-end-node-dark', { draft: true, arms: [
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
].map(([id, junctionNode]) => ({
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
})),
{ id: 'junction-owner-repro-dark', fixture: 'visual',
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
viewport: { width: 900, height: 900 }, ...stage },
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
+7
View File
@@ -391,6 +391,13 @@ const isoDarkDiff = await pixelDiff(isoDark.reference.pixels, isoDark.detailed.p
const pixelEquivalent = (diff) => diff.sameSize
&& diff.changed <= 150 && diff.maxDelta <= 40 && diff.meanDelta <= 0.05;
// Diagnostic only: the raw diff numbers behind every pixel verdict, so a CI
// failure reports HOW FAR a pair drifted instead of a bare boolean (#302).
console.error('pixel-diffs', JSON.stringify({
flat: flatDiff, static: staticDiff, plan: planDiff, darkView: darkViewDiff,
devices: devicesDiff, background: backgroundDiff,
isoLight: isoLightDiff, isoDark: isoDarkDiff,
}));
const out = {
referenceFactorIsOne: referenceView.metrics.factor === 1,
detailedFactorIsFive: detailedView.metrics.factor === 5,
+104
View File
@@ -0,0 +1,104 @@
// Issue #302: the rendered wall body of the owner's repro covers the junction
// contract — every support strip and sector fan, clipped to the facade bound.
//
// The units own the geometry rules; this smoke owns the wiring: the same
// probes that the pure pipeline must cover are checked against the actual
// `d` attribute the card renders, so a regression anywhere between the
// geometry pass and the DOM turns it red.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
import {
wallBodiesGeometry, wallIntervals, buildMultiWallNodeMap,
junctionNodeGeometry, junctionNodeBound, MITRE_LIMIT,
} from '../test-build/wall-thickness.js';
import { GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
GRID_STEP_N * NORM_W * 0.04 * 4, NORM_W,
);
const bound = junctionNodeBound(
rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const corners = junctionNodeGeometry(map);
const inPolygon = (points, x, y) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i], [xj, yj] = points[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const inGeometry = (geom, x, y) => {
let inside = false;
for (const poly of geom || []) for (const ring of poly || []) {
if (inPolygon(ring, x, y)) inside = !inside;
}
return inside;
};
// Contract probes around every node: strip or fan, inside the facade bound.
const probes = [];
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
const step = GRID_PITCH * 0.25;
for (let dx = -radius; dx <= radius; dx += step) {
for (let dy = -radius; dy <= radius; dy += step) {
const x = node.point[0] + dx, y = node.point[1] + dy;
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = x - node.point[0], ry = y - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - step * 0.25;
}));
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
if (!inStrip && !inFan) continue;
if (bound && !inGeometry(bound, x, y)) continue;
probes.push([x, y]);
}
}
}
if (probes.length < 500) {
console.error(`FAIL: contract probe set degenerated to ${probes.length} points`);
process.exit(1);
}
const { page, browser } = await launch({ width: 1200, height: 1000 }, 1);
const res = await page.evaluate(async ({ sp, probes: probeList }) => {
const out = {};
const c = window.__card;
const settle = async () => {
for (let i = 0; i < 4; i++) await new Promise((r) => requestAnimationFrame(r));
await c.updateComplete;
};
sp.id = c._space;
c._serverCfg.spaces[c._serverCfg.spaces.findIndex((s) => s.id === c._space)] = sp;
c._cfgEpoch++; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 600));
const united = c._wallUnionGeometry();
out.geometryProduced = !!united?.d;
const path = new Path2D(united.d);
const g = document.createElement('canvas').getContext('2d');
const holes = probeList.filter(([x, y]) =>
!g.isPointInPath(path, x, y, united.fillRule || 'evenodd'));
out.probes = probeList.length;
out.holes = holes.length;
out.holeSample = holes.slice(0, 5).map((p) => p.map((v) => +v.toFixed(1)));
out.rendered = !!c.shadowRoot.querySelector('.wallbody');
return out;
}, { sp: { ...JSON.parse(readFileSync(new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8')), rooms: fixture.rooms, title: 'Repro', view_box: [0, 0, 1, 1], settings: { show_borders: true } }, probes });
const verdict = {
geometryProduced: res.geometryProduced,
rendered: res.rendered,
enoughProbes: res.probes >= 500,
noContractHoles: res.holes === 0,
};
if (res.holes) console.error('дыры:', res.holeSample, 'из', res.holes);
checkAll(verdict);
await finish(browser, verdict);
+5 -2
View File
@@ -107,12 +107,15 @@ const result = await page.evaluate(async (source) => {
const canonical = card._wallUnionGeometry();
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
// Midpoint between the R-bounded straight bevel and the old 1.80×H mitre.
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
// #302: this point lies in the OVERLAP of two incident strips, and the
// chamfer is strip-safe now — it must stay filled (it used to be discarded,
// which is exactly the hole class #302 removed).
const retainedOverlap = new DOMPoint(330.3808442725, 148.8560107825);
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 7;
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
out.nodeRemainsFilled = !!path?.isPointInFill(node);
out.excessWedgeIsEmpty = path && !path.isPointInFill(discardedWedge);
out.stripOverlapRetained = !!path?.isPointInFill(retainedOverlap);
out.planHasNoEnclosedHoles = enclosedSvgHoles(path, node) === 0;
out.paperRemainsSolid = !!canonical?.paperD
&& !!root().querySelector('.hp-paper');
+112 -112
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Wall junctions no longer leave white wedges and notches. A node where three
or more walls meet is now completed with sector fans and the exact strips of
its rays: a node closes with a full mitre, like an ordinary wall
intersection on a drawing — the old junction chamfers are gone. The
no-holes invariant is checked automatically on a set of 16
close-up junction scenes and on the plan from the original report
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Every Plan editor tool now shows the same complete set of wall axes and
structural endpoints. Switching from Walls to Thickness no longer hides room
axes, while each tool keeps its own hints and live preview
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
три и больше стен, теперь достраивается угловыми веерами и точными полосами
своих лучей: узел смыкается полным mitre, как обычное пересечение стен на
чертеже, — прежние фаски на стыках ушли. Инвариант «дыр нет»
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
плане из исходного сообщения
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
+27
View File
@@ -185,6 +185,24 @@ excluded from this shared `roomGeom`, so a door does not change the room fill
and a detached body cannot punch it. Full and Static render paths reuse the same
structural cache instead of rebuilding wall booleans once per room.
**Junction nodes (#302, owner decision #5).** A degree-3+ node closes with a
FULL mitre, like an ordinary wall intersection on a drawing — the #249 chamfer
is retired. For every pair of angularly adjacent rays `junctionNodeGeometry`
builds one additive fan: the mitre is accepted when it sits IN the sector
(forward along the rays for an ordinary pair, backward for a reflex outer
corner), within the classic `MITRE_LIMIT` and never past a ray's thick
support (#271); a reflex pair without a valid mitre closes with the plain
chord, an ordinary one with a local bevel bounded by the support, the limit
and twice the pair's depth. The node also gets the exact support quads of its
rays. All pieces are clipped by the plain-corner facade bound
(`junctionNodeBound`), so a node cannot grow new facade at a concave vertex.
`bevelMultiWallBody` survives only as a TARGETED lateral trim for nodes with a
degenerately short thick support (#271); every other node is purely additive.
The objective invariant «body ⊇ support strips ∪ fans, inside the facade
bound» is machine-checked by `junctionContractHoles` in tests and the
`smoke_junction_holes` wiring probe. Degenerate zero-area rings left by
coincident chords are dropped.
**Hatch density is physical (#230).** The pattern step is a distance on the
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
@@ -395,6 +413,15 @@ Boundary/Thickness targets (`demo/smoke_optimize_coincident_partition.mjs`). The
copying their contents into Git and checks raw, Optimize preview, applied
canonical storage and reload states.
### Junction tooling (#302)
Purpose-built checks for node material: `junctionContractHoles` (the objective
«body ⊇ strips ∪ fans inside the facade bound» invariant, self-checked against
a deliberately holed fixture), the `smoke_junction_holes` wiring probe that
verifies the same contract against the rendered `d` path, sixteen close-up
golden scenes (`junction-*`) plus the owner's repro scene, and the
`junction-*` mutants in `scripts/mutation-gate.mjs`.
## 9. Independent partitions, drafts and columns
Their thickness is stored directly in centimetres: 1–100 cm for draft and
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+21 -21
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"sourceFingerprint": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "c9e2203fe2df5c2627c861f92e9c4dfad585efb101df19e4b6388a6fbc27d01a"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
"imageSha256": "29b23ec03b320c35b3b6d725b3e69dd7a2cfcd1607cfbbe01dceb37c316964c3"
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
}
+274
View File
@@ -0,0 +1,274 @@
# CODE-REVIEW-302-r1
Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — «Артефакты на стыках стен: дыры в union-геометрии. Переработка механизма стыков + полноценный сет скриншот-тестов»
Спек: `docs/specs/302-junction-node-material.md` (зелёное ревью ТЗ, `SPEC-REVIEW-302-r1`/`r2`)
Ветка: `issue/302-junction-node-material`, вершина `fa1112e7` (после ребейза на `origin/dev` = `2143e888`, включает #265/#301/#303)
Заход: r1 · блокирующих циклов израсходовано 0 из 4 (этап код-ревью первый; предыдущая попытка `S7-code-review` не состоялась — упёрлась в конфликт ребейза до чтения кода, цикл не расходовался согласно комментарию владельца от 12:43)
Класс изменения: A (продукт) + B (гейты/тесты) + C (документация)
## Скоуп
Полная переработка механики материала в узлах стен (degree-3+, три и более
сходящихся луча): старая смешанная схема «аддитивные патчи + вычитающий
`bevelMultiWallBody`-бевел (#249) с protection-union» заменяется на решение
владельца №5 — **полный mitre по умолчанию** (веер `junctionNodeGeometry`,
mitre в пределах `MITRE_LIMIT`, bevel-хорда за ним, рефлексные сектора
замыкаются обратным mitre или хордой), плюс формальный детектор дыр
`junctionContractHoles` и сет из 16 крупноплановых golden-сцен + сцена-репро
владельца. Старый вычитающий слой `bevelMultiWallBody` не демонтирован
целиком, а сужен до адресного латерального трима для вырожденных
короткосаппортных узлов (#271); `bevelMultiWallPaper` из пути бумаги убран
полностью.
Диапазон: `git diff origin/dev...HEAD` — 47 файлов, ядро в
`src/wall-thickness.ts` (+351/-… строк), тесты (`test/wall-thickness.test.mjs`,
новая фикстура `test/fixtures/302-junction-artifacts.json`), новый смок
`demo/smoke_junction_holes.mjs`, обновлённый `demo/smoke_multiwall_junction.mjs`
и `demo/smoke_grid_scale_invariance.mjs`, 7 новых мутантов
`scripts/mutation-gate.mjs`, 16 новых + 2 изменённых golden-эталона,
`docs/WALL-THICKNESS.md`, оба CHANGELOG.
Это первый содержательный проход код-ревью по этой задаче (первая попытка
`S7-code-review` вернулась конвейером до чтения кода из-за конфликта
ребейза — цикл не расходован, документ по дельте не нужен), поэтому разбор
полный, а не по дельте (§2.10 не применяется).
## Как проверялось
Материал — коммиты `git log --oneline origin/dev..HEAD` (14 штук) и
`git diff origin/dev...HEAD`. Выполнено на этом дереве (SHA `fa1112e7745e0ba0`),
не на слово автора.
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | `npx tsc --noEmit` | чисто |
| unit | `npm test` | 1303 pass / 1 skip / 0 fail (1304 объявлено) |
| build + сверка бандла | `npm run build && cmp dist/… custom_components/…` | идентичны; `npm run bundle:sync` не изменил рабочее дерево (git status чист) |
| docs screenshots fingerprint | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» — обязателен, диф трогает `src/**` |
| инварианты модели (диф трогает геометрию узлов/`walls`) | `npm run invariants -- --config <репро #302, обёрнутое {space:{...}}>` | «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» |
| golden | `npm run golden:verify` | **126/126 passed**, локально, целиком (совпадает с заявкой автора) |
| смоки — прямые совпадения `smoke-select.mjs --base origin/dev --head HEAD` | `smoke_junction_holes`, `smoke_real_plan_masonry`, `smoke_grid_scale_invariance` | все OK |
| смоки — AC9 плюс «зарегистрированная связь» | `smoke_multiwall_junction`, `smoke_wall_junctions`, `smoke_junction_patch_resilience`, `smoke_render_perf` | все OK |
| мутанты #302 (выборочно, `--id=`) | `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `junction-supports-not-restored` | 4/4 корректно ловят поломку («поймано 1 из 1» / «поймано 0 из 1» для guard без мутации, как обязано) |
| мутант `junction-fans-disabled` | `node scripts/mutation-gate.mjs --id=junction-fans-disabled` | **упал на чистом прогоне** — см. находку M1 |
| process-gate офлайн | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
| CI на вершине | `gh run view` на прогонах, указанных автором | подтверждено см. ниже |
Не прогонялось и почему:
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (не нужен по правилу гейтов);
- полный `demo/smoke_*.mjs` (189 файлов) — задача не «задевает всё», выборка
дана `smoke-select.mjs` (5 прямых + 2 «зарегистрированная связь») плюс AC9;
расширил её `smoke_render_perf` (AC8) вручную;
- `npm run mutants` целиком (все ~90 мутантов проекта) — предрелизный гейт
(§8), не гейт ревью; из новых семи прогнал четыре показательных плюс
специально диагностировал сломанный;
- полный performance-бенчмарк (`npm run benchmark:*`) — дорогой,
предрелизный; вместо повторного прогона проверил, что CI job
`performance_smoke` реально выполнился и был зелёным на `b95c55d3` (см. ниже),
а не «reuse»-пропущен.
**CI, названный автором.** Прогон [32850091121](https://github.com/Matysh/houseplan-card/actions/runs/32850091121)
на `b95c55d3` (родитель финального docs-коммита): `frontend`, `golden`,
`performance_smoke`, `smoke` (все три шарда), `docs`, `provenance` —
`success`; `process-gate` — `failure` (ожидаемо: before-SHA от форс-пуша,
самовылечился следующим пушем, как и описал автор). Прогон
[32850748475](https://github.com/Matysh/houseplan-card/actions/runs/32850748475)
на финальном `fa1112e7` — `success` целиком; тяжёлые job'ы там `skipped`
через легитимный `reuse` (доки — единственный дифф этого коммита, не
затрагивающий их отпечаток). Вместе оба прогона покрывают полный набор на
проверяемом дереве, как и заявил автор.
## AC — разбор
| AC | Статус | Доказательство |
|---|---|---|
| AC1 (детектор: 0 дыр на всём сете) | ✅ | golden verify 126/126 включает все 16 сцен + репро; `smoke_junction_holes` (прямое исполнение) `noContractHoles: true`; юнит-тест «the owner repro is hole-free end to end» зелёный |
| AC2 (репро владельца: 0 дыр) | ✅ | тот же смок + golden `junction-owner-repro-dark` passed |
| AC3 (несвязанные сцены — побайтно; junction-сцены — легально изменены) | ✅ | `baselines-index.json`: только 16 новых + 2 изменённых (`safe-resize-handles-clamp-*`, названо автором как узловая вершина ромба) хэша; остальные 108 не тронуты — подтверждено чтением индекса |
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит «the 57° mixed-thickness pair takes the full mitre (decision #5)» проверяет ровно эту геометрию (углы 45°/102.3°/332.2°, half 4.861/3.472) |
| AC5 (виртуальный луч не порождает кладку) | ✅ (чтением) | `junctionNodeGeometry`: `rays = node.rays.filter(ray.halfDepth > 0)` — нулевой/виртуальный луч исключён из веерного обхода до сортировки по азимуту, соседи по азимуту становятся его соседями автоматически; golden `junction-t-virtual-arm-dark`/`junction-x-virtual-through-dark` в сете |
| AC6 (узловая механика не содержит `difference`) | ⚠️ частично — см. находку M1/M2 | Буквально неверно: `bevelMultiWallBody` (внутри — `difference`) по-прежнему вызывается для узлов с вырожденно-коротким толстым саппортом (`corePhase = 'multi-wall-trim'`, `wall-thickness.ts:3600-3613`). Регресс-мутант, названный в спеке для этого AC («лимит веера 1.25·h»), проверен и ловится (`junction-fan-limit-back-to-249`). Мутант, названный в спеке для «веера не строятся вовсе», сломан на инфраструктурном уровне — см. M2 |
| AC7 (оба рендерера — один вызов) | ✅ (чтением) | `wallBodiesUnionPath` (единственная точка, `wall-thickness.ts:3714`) вызывается и из `houseplan-card.ts:11692`, и из `space-render.ts:432`; архитектура не менялась, только тело функции |
| AC8 (перф в бюджете) | ✅ с оговоркой | `smoke_render_perf` OK; CI `performance_smoke` реально выполнился и зелёный на `b95c55d3` (не reuse-пропуск, проверено по списку job'ов). Явного числового «замера large-house» в хендоффе нет, спека требовала это отдельной строкой — не блокирует (Low, механизм проверки существует и сработал) |
| AC9 (существующие смоки стыков зелёные) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction`, `real_plan_masonry` — все прогнаны лично, OK |
## Находки
### Medium (в скоупе) — M1: `docs/WALL-THICKNESS.md` не переписан под решение №5, хотя спека требовала это явно
Файл: `docs/WALL-THICKNESS.md:188-196`.
Абзац «**Junction nodes (#302).**» по-прежнему гласит: *«A degree-3+ node
keeps the approved #249 chamfer (`bevelMultiWallBody`, bounded by the node's
join limit `MULTI_WALL_JOIN_LIMIT × halfDepth`), and AFTER it the node
additively gets back what a chamfer must never eat…»* — это описание
**отменённого** утреннего решения «сохранить фаску #249», а не итогового
решения №5 («узлы смыкаются полным mitre… фаска #249 демонтируется целиком»,
`docs/specs/302-junction-node-material.md:65-73`). Фактическое поведение (по
чтению `wall-thickness.ts:3591-3634`) обратное описанному в доке: фаска
(`bevelMultiWallBody`) теперь **опциональна и включается только** для узлов
с вырожденно-коротким толстым саппортом (`needsTrim`), а не «для каждого
degree-3+ узла» как написано; для всех остальных узлов работает чистый
аддитивный веер, что и есть содержательное отличие этой задачи.
Коммит `e8338663` («feat: full mitre at every node — the #249 chamfer
retires», `User-Visible: yes`, решение №5) не тронул `docs/WALL-THICKNESS.md`
вовсе (`git show --stat e8338663` не содержит этот файл). Единственный
последующий докс-коммит `fa1112e7` добавил только раздел «Junction tooling»
(список тестов), не исправив основной абзац.
Нарушает:
- `PROCESS.md` правило 11: «Документация — в том же коммите, что поведение.
Отдельным «допишу потом» коммитом документация не бывает» — коммит,
сменивший контракт (`e8338663`, User-Visible: yes), не обновил канонический
документ подсистемы;
- собственное требование спеки, §15: «`docs/WALL-THICKNESS.md` §3 и §9
переписываются под новую механику» — раздел §9 (Independent partitions)
тоже не упоминает #302 вовсе, что ожидаемо (узел независимых партиций не
трогается), но §3 обязан был обновиться и не обновился.
Почему это находка, а не педантизм: этот файл — канонический документ
подсистемы, который явно предписано читать перед любой работой над стенами
(`AGENTS.md`, сам этот ревью начиналось с его чтения). Ложное описание
«узел всегда получает старую фаску» уведёт следующего агента/автора по
неверному следу при следующей задаче на стыках.
Воспроизведение: `git diff origin/dev...HEAD -- docs/WALL-THICKNESS.md` —
всего 24 добавленные строки, из которых 15 — новый раздел про тестирование;
абзац §3 про механику узла не менялся с коммита `9e68d641` (12:33), то есть
раньше решения №5 (15:13).
Фикс: переписать абзац `docs/WALL-THICKNESS.md:188-196` под фактический
контракт — веер по умолчанию (mitre/bevel-хорда, рефлекс), `bevelMultiWallBody`
только как адресный трим для короткосаппортных узлов, `bevelMultiWallPaper`
убран из бумаги целиком.
### Medium (в скоупе) — M2: мутант `junction-fans-disabled` не работает, вопреки заявлению «краснота проверена исполнением»
Файл: `scripts/mutation-gate.mjs:2213-2225`.
```
guard: 'node demo/smoke_junction_holes.mjs',
```
`demo/smoke_junction_holes.mjs` — единственный смок в проекте, импортирующий
напрямую из `../test-build/wall-thickness.js` (`grep -rl "from '../test-build/"
demo/smoke_*.mjs` находит только его), поэтому его guard обязан собрать
`test-build/` первым (как это делают все соседние #302-мутанты:
`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && …`).
У этого мутанта такого префикса нет.
Харнесс сам решает, нужен ли `test-build`, эвристикой
`guardNeedsTestBuild()` (`scripts/mutation-gate.mjs:2920-2922`):
`/(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json')`
— она матчит только guard'ы вида `node --test …`, а не «браузерный» смок,
который тем не менее тоже читает `test-build/`. Результат — воспроизведено
исполнением:
```
$ node scripts/mutation-gate.mjs --id=junction-fans-disabled
FAIL чистый прогон: node demo/smoke_junction_holes.mjs красный без мутанта
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/hp-mutant-uvmoTK/test-build/wall-thickness.js' …
```
Гейт падает уже на «чистом» прогоне (до применения мутации) — это
означает, что мутант не проверял АБСОЛЮТНО ничего ни разу с момента
создания: ни в момент написания, ни в любом гипотетическом прогоне харнесса
он не мог напечатать «поймано 1 из 1». Это прямо противоречит явному
заявлению в теле коммита `e8338663`: *«мутанты переякорены, краснота каждого
проверена исполнением»* и в хендоффе issue: *«восемь мутантов — краснота
каждого проверена исполнением штатным харнесом»*.
Мутант закрывает первую строку таблицы §14 спеки — `node-fan-disabled`
(«веера не строятся вовсе» → «детектор на сете») — самый фундаментальный
регресс-класс новой механики. Сейчас его прикрывает не он, а косвенно
`junction-supports-not-restored` (другой мутант, который частично
пересекается по эффекту — тоже режет вклад аддитивных кусков — и этот
действительно ловится, «поймано 0 из 1» без мутации, «FAIL» ожидаемо на
чистом прогоне... то есть сам по себе он в порядке). Но заявленное покрытие
«веера отключены полностью» не проверено никем.
Не является блокирующим (High), потому что: (а) это дефект тестовой
инфраструктуры, а не продуктового кода — сама геометрия работает и покрыта
множеством других зелёных проверок (детектор, 126/126 golden, все AC9-смоки);
(б) `mutation-gate` — предрелизный, не блокирующий Validate гейт (§8,
`.github/workflows/mutation-gate.yml` — по расписанию/`workflow_dispatch`,
не на каждый push), поэтому CI branch protection этим не введён в заблуждение
формально. Но это Medium в скоупе: спека прямо обещала эту защиту (§14),
коммит прямо заявил, что она проверена, и оба заявления не соответствуют
действительности.
Фикс: добавить в `guard` этого мутанта тот же префикс сборки, что у соседей —
`'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/smoke_junction_holes.mjs'`
— и повторно прогнать `--id=junction-fans-disabled`, чтобы подтвердить
«поймано 1 из 1» на самом деле.
### Low — L1: `bevelMultiWallPaper` осталась мёртвым кодом
Файл: `src/wall-thickness.ts:2955-2984`.
Функция `bevelMultiWallPaper` (вычитающий бевел для контура бумаги) не имеет
вызовов нигде в дереве (`grep -rn "bevelMultiWallPaper" src/*.ts test/*.mjs
demo/*.mjs` находит только её собственное объявление) — коммит `e8338663`
корректно убрал единственный вызов (`paperGeom`/`floorFootprintGeometry`
теперь используют `paperWithNodeCorners`), но само тело функции осталось.
Не экспортирована, поведения не меняет, `tsc --noEmit` её не флагует
(в проекте нет `noUnusedLocals` для верхнеуровневых функций). Снимаю с
записью — правьте свободно вместе с M1, либо отдельной строкой, решение за
автором: это тривиальная уборка, не тянет на отдельный цикл сама по себе.
## Что проверено и корректно
- Основной контракт (веер `junctionNodeGeometry`, границы mitre/bevel,
фасадный клип `junctionNodeBound`, спец-случаи виртуального луча,
коллинеарной пары, degree-1, колонны) прочитан построчно и соответствует
§8.2-8.3 спеки; тесты `test/wall-thickness.test.mjs` («issue 302 …», 6 новых
тестов) целятся именно в эти границы, включая регресс на 1.25·h-лимит
(старый #249) и на вырожденный реверс-mitre (Y-60 из отчёта владельца).
- Формальный детектор `junctionContractHoles` не «слеп»: юнит-тест кормит
ему заведомо дырявое тело (2 из 3 полос) и требует красноты — прошёл;
отдельно мутант `junction-detector-blind` подтверждает то же на живом
харнессе.
- Golden-набор реально расширяет покрытие матрицы §13 почти полностью:
L отсутствует как отдельная сцена, но обоснованно — `buildMultiWallNodeMap`
строит узлы только для `rays.length >= 3` (`wall-thickness.ts:1939`),
обычный двухлучевой угол этим механизмом вообще не обрабатывается (это
зона старого, не тронутого этой задачей `outsetContour`/`insetContour`
mitre); включение «L» в таблицу §13 спеки было избыточным пожеланием, а не
пропущенным требованием — не в счёт находок.
- Трейлеры: оба `User-Visible: yes`-коммита (`9afc410d`, `e8338663`) несут
правки в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите;
формулировка финальной записи («the old junction chamfers are gone»)
описывает видимый результат (визуально фаска исчезла — трим невидим,
работает только на вырожденных геометриях) и не расходится с рендером.
- Golden-коммиты несут `Release:`/`Baseline-Reviewed:` на реальные зелёные
прогоны CI (проверено — оба run ID существуют и относятся к веткам этой
задачи).
- `process-gate.mjs` офлайн — 0 предупреждений; ветка полностью содержит
`origin/dev` (ребейз произведён, конфликтов нет).
- AC3 количественно: индекс эталонов содержит ровно 16 новых + 2 изменённых
хэша из 126 (сверено чтением `baselines-index.json`, не поверил на слово).
## Чего не проверял
- Полный `demo/smoke_*.mjs` (189 файлов) и полный `npm run mutants`
(~90 мутантов) — предрелизные гейты, задача не задевает всё; выборка
обоснована в таблице выше.
- Локальный полный `python -m pytest tests_backend` — диф не трогает Python.
- Числовой перф-профиль large-house вручную не переснимал; доверился
зелёному CI job `performance_smoke` на `b95c55d3` (подтверждено — не reuse).
- Не проверял три «зарегистрированные связи» смока `smoke_decor.mjs` и
`smoke_space_scale_defaults.mjs` (символ `cellCm` — широко используемый,
слабая связь по инструменту) — доверился их независимости от узловой
механики по чтению кода (`cellCm` там используется вне контекста
multi-wall-узлов).
## Вердикт
High: 0 · Medium: 2 (обе в скоупе) → возврат автору, без нового issue (#202).
Оба Medium дешёво чинятся: M1 — переписать один абзац
`docs/WALL-THICKNESS.md:188-196` под фактический контракт; M2 — добавить
build-префикс в guard одного мутанта и подтвердить «поймано 1 из 1»
исполнением. Ни одна находка не требует правки продуктовой геометрии —
сам механизм (AC1-AC5, AC7, AC9) проверен исполнением и корректен.
+246
View File
@@ -0,0 +1,246 @@
# CODE-REVIEW-302-r2
Issue: #302 · Этап: code · Заход: r2 · блокирующих циклов израсходовано 1/4 до
этого захода
## Скоуп раунда
r1 (SHA `fa1112e7`) закончился жёлтым: High 0, Medium 2 (M1 — доку
`docs/WALL-THICKNESS.md` не переписали под решение №5; M2 — гвард мутанта
`junction-fans-disabled` инфраструктурно сломан, «поймано» не было проверено
исполнением). Автор хендоффнул исправление на вершине `a5ea578`
(комментарий issue 2026-08-25T14:00:05Z), затем конвейер перед ревью
ребейзнул ветку на свежий `dev` — вершина стала `42e02396`.
**Проверка ребейза (§7.2).** `git diff a5ea578..42e02396 --stat` даёт ровно
один файл: `docs/reviews/SPEC-REVIEW-304-r1.md` (+128, чужой issue). Дерево
кода/тестов/доки #302 идентично байт-в-байт. Это не «другой код» — единственный
привнесённый ребейзом файл не пересекается ни с одним путём этой задачи и не
проходит фильтр `frontend`-триггера CI (подтверждено логом job `changes`,
CI-прогон 32856428594). Разбор веду **по дельте** r1→r2: `git diff
fa1112e7..HEAD` — четыре файла по существу (`docs/WALL-THICKNESS.md`,
`scripts/mutation-gate.mjs`, `src/wall-thickness.ts`, плюс пересборка бандла и
`docs/images/screenshots.json`), plus два review-документа. Расширил разбор за
пределы формальной дельты в одном месте (см. находку M3) — при чтении
`src/wall-thickness.ts` полностью для оценки M2/Low вокруг него нашёл
расхождение AC6 с реализацией; код этого места не менялся между r1 и r2, но
находка целиком в скоупе задачи, а не в скоупе только этой правки, поэтому
привожу её отдельно, а не молчу.
## Как проверялось
Гейты «всегда» (исполнением, на HEAD `42e02396`):
| гейт | команда | результат |
|---|---|---|
| типы | `npx tsc --noEmit` | чисто |
| юниты | `npm test` | 1303 pass / 1 skip / 0 fail |
| сборка | `npm run build` + `diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | rollup ok; `git status` после билда чист (свежий `dist` побайтно совпал с закоммиченным) — три копии (пересобранная, `dist/`, `custom_components/`) идентичны |
| доки | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» |
| инварианты | `npm run invariants -- --config test/fixtures/302-junction-artifacts.json` | «ссылки разрешимы, записи толщины находятся» |
Мутанты, названные M2/Low (исполнением, штатным харнессом
`scripts/mutation-gate.mjs --id=...`):
| id | результат |
|---|---|
| `junction-fans-disabled` | поймано 1 из 1 |
| `junction-fan-ignores-thick-length` | поймано 1 из 1 |
| `junction-reflex-outer-mitre-missing` | поймано 1 из 1 |
| `junction-fan-limit-back-to-249` | поймано 1 из 1 |
| `junction-detector-blind` | поймано 1 из 1 |
| `junction-pieces-unbounded` | поймано 1 из 1 |
| `partition-merge-ignores-junction` | поймано 1 из 1 |
| `junction-checks-room-vertices-only` | поймано 1 из 1 |
`junction-supports-not-restored` и `multi-wall-paper-full-origin-cut` из
реестра удалены этим диффом — проверено, что их find-паттерны (обращения к
удалённым `paperWithNodeCorners`/`[...corners.supports, ...corners.fans]`)
больше не существуют в файле: мутанты стали неприменимы к текущему коду, снятие
не притворное.
Смоки/golden: `smoke-select.mjs` для дельты `fa1112e7..HEAD` даёт прямые
совпадения `smoke_junction_holes`, `smoke_decor`, `smoke_grid_scale_invariance`,
`smoke_real_plan_masonry`, `smoke_space_scale_defaults` и пять
«зарегистрированных связей» (`junction_patch_resilience`, `multiwall_junction`,
`multiwall_strip_containment`, `resize_pointer_real_plan`,
`resize_wall_thickness`). Локально браузерные проверки (`golden:verify`,
`demo/smoke_*.mjs`) **не выполнились**: `page.route` не перехватывает
динамический `import()` в этом окружении — `Failed to fetch dynamically
imported module: http://demo.local/assets/houseplan-card.js`. Проверил, что
это ограничение песочницы, а не регрессия: та же ошибка воспроизводится
байт-в-байт на чистом `origin/dev` (worktree, символическая ссылка на
`node_modules`) с давно существующим `demo/smoke_align_guides.mjs`, который
дифф не касается.
Вместо локального прогона поднял CI-прогоны по `gh run view`:
- `32855773018` (headSha `30b4bbf6`, кодово идентичен `a5ea578`/`42e02396` по
`src/**`/`test/**`/`scripts/**` — единственная разница дальше по цепочке это
фикс отпечатка скриншотов) — `frontend`, `golden`, все три шарда `smoke`,
`performance_smoke` зелёные; красным был только `docs` (протухший отпечаток
скриншотов — это и есть то, что чинит следующий коммит `42e02396`, дока
«refresh the screenshot fingerprint»);
- `32856428594` (headSha `a5ea578`) — `docs` зелёный (фингерпринт поправлен),
`frontend`/`golden`/`smoke` пропущены самим `changes`-джобом: между
`30b4bbf6` и `a5ea578` изменился только `docs/images/screenshots.json`, под
фронтенд-триггер не попадает;
- `32856829923` (headSha `42e02396`, текущий HEAD) — красный один
`process-gate` на несуществующем в раннере before-SHA (артефакт force-push
после ребейза на dev, автор описывает его же во всех трёх последних
хендоффах); `frontend`/`docs`/`provenance` зелёные, `golden`/`smoke`
пропущены — тем же основанием (после `a5ea578` изменился только чужой
`SPEC-REVIEW-304-r1.md`).
Собранные вместе, эти три прогона покрывают полный набор гейтов на
кодово-идентичном дереве, включая golden и все смоки — считаю это equivalent
доказательству «оно работает», раз локальный браузерный прогон недоступен.
## Закрытие раунда r1
| находка r1 | чем закрыта | где видно |
|---|---|---|
| **M1** — `docs/WALL-THICKNESS.md` §3 описывал отменённое решение «фаска #249 сохраняется» | Абзац переписан под решение №5 целиком: полный mitre в секторе, `bevelMultiWallBody` — только адресный трим для #271, `bevelMultiWallPaper` убрана | `docs/WALL-THICKNESS.md:185-206` (коммит `72a5992f`); прочитано и сверено построчно с текущим кодом `junctionNodeGeometry`/`bevelMultiWallBody` — соответствует |
| **M2** — гвард `junction-fans-disabled` был `node demo/smoke_junction_holes.mjs` без сборки `test-build`, падал `ERR_MODULE_NOT_FOUND` на чистом дереве | Guard переведён на `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" ...`; дополнительно (второй виток, коммит `af33c49e`) сама проверка переведена с самоссылочного контракт-проб-детектора на независимый юнит формы `issue 302 a T node covers every sector with a fan or a mitre` (`test/wall-thickness.test.mjs:2930-2940`) | `scripts/mutation-gate.mjs:2174-2190`; исполнено лично — `node scripts/mutation-gate.mjs --id=junction-fans-disabled` → «поймано 1 из 1» |
| Low — `bevelMultiWallPaper` мёртвый код | Функция удалена целиком (`src/wall-thickness.ts`, было `:2955-2994`); следом измерением снята и re-union саппорт-квадов в теле (`31ba14e0`), плюс два осиротевших мутанта из реестра | `git diff fa1112e7..HEAD -- src/wall-thickness.ts` — оба блока отсутствуют; live-подтверждение — тест `issue 302 the owner repro is hole-free end to end` (`test/wall-thickness.test.mjs:3026-3054`) гоняет упрощённый пайплайн на реальной фикстуре репро и не находит дыр |
## Находки
### M3 (Medium, в скоупе задачи) — AC6 не выполнен: узловая механика не «безоперационна по difference», спека это не признаёт
**Файл:** `src/wall-thickness.ts:3535-3555` (вызов), `src/wall-thickness.ts:2856-2953`
(тело `bevelMultiWallBody`); спека `docs/specs/302-junction-node-material.md:64-73`
(§4, решение №5), `:129-131` (§8.2), `:196-198` (AC6).
**Что заявлено.** Решение владельца №5 (принято в день ребейза, зафиксировано
в спеке и в теле коммита `79e86b0a`): «фаска #249 демонтируется **целиком**...
слой `bevelMultiWallBody`/`bevelMultiWallPaper` **удаляется**». §8.2 повторяет
это как формальный контракт: «**В узловой механике нет ни одной операции
`difference`** (вычитающий слой фаски демонтирован): дыра между полосами
невозможна **по построению**». AC6 — то же самое как проверяемый критерий.
**Что в коде.** `bevelMultiWallBody` (старая реализация фаски #249, с
`difference()` на строках 2889, 2898, 2922, 2924) не удалена: она вызывается
на подмножестве узлов, отфильтрованном `needsTrim` (короткий толстый саппорт,
#271) — `src/wall-thickness.ts:3542-3554`. Это не мёртвый код: комментарий на
`fixture #197` (`test/wall-thickness.test.mjs:2236-2240`) прямо говорит, что
тест полагается на этот трим («#271 removes only the area that the old
node-wide 8H rectangles invented»), и площадь фикстуры (`closeTo(...,
124535.20808099362, ...)`) посчитана с его участием.
**Почему это не придирка к формулировке.** Весь смысл переработки #302 —
уйти от вычитающих ремонтных слоёв, потому что именно они были источником
рецидивирующих дыр (это буквально сюжет issue: 29 коммитов «point fixes»,
которые не сходились). «Дыра невозможна по построению» — это утверждение
именно про отсутствие `difference`; для узлов, где реально срабатывает
`needsTrim`, инвариант «hole-free» держится не построением, а корректностью
унаследованного вычитающего кода — той же категории кода, что и раньше ломался.
Спека и коммит основного решения (`79e86b0a`) заявляют «удаляется целиком», но
имплементация — тот же коммит! — сохраняет узкий трим и честно объясняет
зачем в комментарии кода. То есть автор знал про исключение в момент
написания контрактной формулировки, но не отразил его ни в §4/§8.2/AC6 спеки.
`docs/WALL-THICKNESS.md` (живой документ подсистемы) это исключение описывает
верно — именно потому что M1 в этом самом раунде его туда вписал; спека
(контракт, по которому пишутся AC) осталась с абсолютной, невыполненной
формулировкой.
Отдельно: ни один этап ревью до сих пор AC6 не подтверждал. Решение №5
поменяло текст спеки уже ПОСЛЕ обоих раундов SPEC-REVIEW (они зелёные на
версии «фаска #249 сохраняется» — SHA `3b19111a`); AC6 в его текущей редакции
никогда не проходил ни одного ревью. В вердикте r1 (комментарий issue
2026-08-25T13:29:36Z) явно перечислены как проверенные AC1, AC2, AC4, AC5,
AC7, AC9 — AC6 в списке демонстративно нет, то есть r1 либо не проверял его,
либо проверял и не сообщил результат.
**Воспроизведение.** `grep -n "difference(" src/wall-thickness.ts` в диапазоне
2856-2953 (`bevelMultiWallBody`) — 4 вызова; трассировка вызова от
`wallBodiesGeometry` (`:3542-3554`) до этой функции безусловна при
`trimNodes.length > 0`. `needsTrim` реально истинен минимум для узла фикстуры
#197 (по прямому указанию комментария теста, задача #271).
**Серьёзность и что чинить.** Не блокирует релиз функционально — вся
визуальная/golden матрица подтверждает, что заявленный владельцем результат
(без вырезов/рожков, полный mitre) достигнут, и трим — узкий, адресный,
дисциплинированно закомментированный код, а не регресс. Но AC — формальный
критерий приёмки этой задачи, и он не выполнен в буквальном прочтении. Дёшево
чинится без правки кода: привести спеку в соответствие с уже написанной (в
этом же раунде) правдой `docs/WALL-THICKNESS.md` — явно назвать исключение
#271 в §4.5/§8.2 и переформулировать AC6 («узловая механика аддитивна для
всех узлов, кроме адресного #271-трима, унаследованного из #249 и суженного
до вырожденного случая»), либо, если владелец сочтёт исключение
неприемлемым, вернуть задачу на технический разбор устранения самого трима.
Это решение продуктовое/архитектурное, не моё — фиксирую находку, не
предписываю какой из двух путей выбрать.
## Что проверено и корректно
- M1, M2, Low из r1 закрыты по существу, не только по заявлению — см. таблицу
выше; для M2 лично прогнал мутант и получил «поймано 1 из 1», а не поверил
тексту коммита (текст коммита `72a5992f` сам признаёт, что автор один раз
уже ошибочно заявил «проверено исполнением», не проверив).
- Удаление `bevelMultiWallPaper`/`paperWithNodeCorners`/двух мутантов —
полное, без осиротевших ссылок; подтверждено `grep` по обеим удалённым
сигнатурам и живым тестом на реальном пайплайне (`the owner repro is
hole-free end to end`).
- `docs/WALL-THICKNESS.md` после M1 фактически точен для того путя, который
описывает M3 (адресный трим назван прямо) — то есть подсистемная дока не
расходится с кодом, расходится только сама спека issue.
- Трейлеры всех четырёх коммитов дельты (`72a5992f`, `af33c49e`, `31ba14e0`,
`42e02396`) содержат `Issue: #302` и `User-Visible: no` — корректно: эти
четыре коммита не меняют видимое поведение относительно того, что уже было
выпущено с `User-Visible: yes` в `79e86b0a`/`a5d30467` (там же в том же
коммите обновлены оба CHANGELOG — проверено `git show 79e86b0a --
docs/CHANGELOG.md docs/CHANGELOG.ru.md`).
- tsc/test/build/check-docs/invariants — все чисто, гейты не помечены как
условно пройденные.
- Мутационное покрытие узловой механики (8 мутантов, включая переписанный
M2) — самопроверено исполнением, не только чтением реестра.
## Чего не проверял и почему
- **`npm run golden:verify` и `demo/smoke_*.mjs` — не выполнил локально.**
Окружение ревью не даёт Chromium перехватить динамический `import()`
(воспроизведено то же самое на чистом `origin/dev` с чужим смоком —
ограничение песочницы, не регрессия дельты). Компенсировано тремя
CI-прогонами на кодово-идентичном дереве (см. «Как проверялось»): golden
126/126 и все три шарда смоков зелёные на `30b4bbf6`; `docs`-джоб зелёный на
`a5ea578`/`42e02396` после фикса отпечатка.
- **Полный golden-набор и весь `demo/smoke_*.mjs` (не только выбранные
smoke-select) не гонял вручную** — избыточно: `smoke-select.mjs` дал узкий
список, а CI уже прогнал полный набор (все три шарда — это весь
`demo/smoke_*.mjs`, не подмножество) на этом дереве.
- **Продуктовую визуальную приёмку 16 новых junction-сцен не пересматривал
глазами** — это решение владельца (комментарии issue 2026-08-25 11:40 и
12:42), не предмет код-ревью; полагаюсь на golden pixel-diff.
- **AC3, AC8** (пиксельное совпадение непричастных сцен, перф-бюджет) —
унаследованы из r1/из CI (`performance_smoke` зелёный в `32855773018`), не
передельфрено отдельно: дельта r1→r2 не трогает рендер-путь и перф-профиль.
## Унаследовано из r1
Документ r1: `docs/reviews/CODE-REVIEW-302-r1.md` (комментарий issue
2026-08-25T13:29:36Z), проверен на SHA `fa1112e7`.
Принято без повторной проверки, так как дельта r1→r2 не касается этого кода:
- геометрия веера/mitre/bevel-хорды/рефлекса (`junctionNodeGeometry`) —
«прочитана построчно и соответствует контракту §8» (кроме уточнения по
AC6 выше — то новая находка этого раунда, не подтверждение r1);
- AC1, AC2, AC4, AC5, AC7, AC9 — сочтены выполненными и проверенными
исполнением в r1; код, который их обеспечивает, не менялся между `fa1112e7`
и `HEAD` (сверено `git diff fa1112e7..HEAD -- src/wall-thickness.ts` —
единственные правки вне M1/M2/Low перечислены в разделе «Закрытие раунда
r1» и не затрагивают mitre/bevel/reflex ветки);
- продуктовая рамка (SCOPE.md, J1/J2) и полнота DoR спеки — не пересматривал,
дельта не продуктовая;
- решение владельца №5 (полный mitre, отказ от фаски #249 как видимого
эффекта) как таковое не оспариваю — это принятое продуктовое решение;
находка M3 — про честность формальной формулировки AC6, а не про само
решение.
## Вывод
Мultimedia M1/M2/Low закрыты добросовестно и проверены исполнением, а не на
слово. Обнаружена одна новая находка в скоупе (M3): AC6 этой же задачи не
выполнен буквально, и это никогда не проходило ревью (решение №5 переписало
спеку уже после двух зелёных SPEC-REVIEW). Возвращаю жёлтым для приведения
спеки в соответствие с уже написанной верной докой подсистемы либо для
явного решения владельца по трим-исключению.
+254
View File
@@ -0,0 +1,254 @@
# CODE-REVIEW-302-r4
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — материал узла стен: переработка на аддитивную логику
- Этап: код-ревью (PROCESS.md §2.7)
- Заход: r4 · блокирующих циклов израсходовано на входе 2/4 (r1 жёлтый, r2 жёлтый, r3 зелёный — бюджет не тратит)
- Ревьюемый SHA: `96318fb94f6d64e96054b8ba4d34f615e3088477`
- Ветка: `issue/302-junction-node-material`
- Вердикт: **зелёный** · High: 0 · Medium: 0
## Почему это ПОЛНЫЙ разбор, а не разбор по дельте
r3 (SHA `560c5b81`, вердикт зелёный) не смержился: конфликт при ребейзе на
`dev` (комментарий владельца от 14:33). Автор перевёл задачу в
`S6-in-progress`, выполнил `git rebase origin/dev`, разрешил конфликт и
запушил заново — старые SHA (`fa1112e7`, `42e02396`, `560c5b81`) при этом
переизданы force-push'ем и физически не существуют в текущей истории
(`git cat-file -t <sha>` → `Not a valid object name`; `git fetch origin <sha>`
→ `couldn't find remote ref`). Разбор по дельте против объекта, которого нет,
невозможен буквально.
Существеннее: сам ребейз — не техническая формальность. `git merge-base HEAD
origin/dev` равен текущему тику `origin/dev` (`1575b1e8`), и в диапазон между
прежней базой ветки и этим тиком входит `a2b4d32b fix(plan): keep wall axes
across tools` (#304) — реальная фича, физически задевающая `src/houseplan-card.ts`
и рендер осевых линий, которая столкнулась с этой веткой ровно в одной паре
golden-сцен (`safe-resize-handles-clamp-*`, где стыкуются осевые линии из #304
и сомкнутая вершина ромба из decision №5 этой задачи). Это тот samый случай,
который правило §7.2 требует разбирать полностью: «после ребейза это другой
код». Разбор ниже поэтому охватывает весь диапазон `origin/dev...HEAD`
(49 файлов, 2257/-356), а не только три коммита ребейза.
Раздел «Унаследовано» — не для сокращения объёма проверки, а для
трассируемости: почти весь контроль ниже я выполнил заново и независимо в
этом раунде (перечень гейтов — раздел «Что проверено» и «Гейты»), а не
переписал из r1–r3 на слово. Пересечение с прежними выводами является
следствием того, что `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
пуст (см. ниже) — код фичи не менялся с r2, а не потому что я решил доверять.
## Дельта ребейза (три коммита после r3)
| коммит | что делает |
|---|---|
| `b2b26407` | принимает объединённые эталоны `safe-resize-handles-clamp-{dark,light}` (осевые линии #304 + сомкнутая вершина ромба decision №5); `baselines-index.json` починен по фактическим PNG после конфликта слияния |
| `d3927f2e` | rebundle обеих копий (`dist/`, `custom_components/.../houseplan-card.js`) на объединённом дереве |
| `96318fb9` | обновляет `docs/images/screenshots.json` (отпечаток скриншотов документации, обязателен при любой правке `src/**`) |
Подтверждено чтением: `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
не выполним напрямую (SHA отсутствует), но три коммита ребейза сами по себе
трогают только `demo/golden/baselines/**`, `baselines-index.json`, обе копии
бандла и `docs/images/screenshots.json` — ни строки `src/**`/`test/**`
/`demo/**` (кроме бинарных PNG) эти три коммита не меняют (см. `git show
--stat` на каждом, приведено в комментарии issue тем же перечнем файлов).
Отсюда: узловая геометрия (`junctionNodeGeometry`, `junctionContractHoles`,
`junctionNodeBound`, точки вызова в `wallBodiesGeometry`) идентична r2/r3
байт-в-байт; я перечитал её самостоятельно (раздел ниже), а не принял этот
факт на слово.
## Скоуп проверки
Материал: `git log --oneline origin/dev..HEAD` (25 коммитов) и
`git diff origin/dev...HEAD` (49 файлов). Прочитаны: тело issue #302 и все
комментарии, `docs/specs/302-junction-node-material.md` (текущая редакция,
после правки AC6/§4.5/§8.2 в r2→r3), `docs/WALL-THICKNESS.md` (секции
«Junction nodes» и «Junction tooling»), `docs/SCOPE.md`, `docs/reviews
/CODE-REVIEW-302-r1.md` и `-r2.md` для контекста прежних находок.
## Продуктовая рамка
Задача закрывает белые клинья на стыках стен — прямой отчёт владельца,
регресс из релиза в релиз (docs/SCOPE.md, job «нарисовать и увидеть точный
план» — геометрия стен без визуальных дефектов является базовым требованием
самого продукта, не отдельной фичей). Решение №5 (владелец, тем же днём)
сузило исходную идею «сохранить фаску #249» до «полный mitre везде» по
результатам визуального осмотра собственных эталонов — это законная
итерация внутри одной задачи, а не расширение скоупа.
## Что проверено чтением кода
- `junctionNodeGeometry` (`src/wall-thickness.ts:2032-2158`): один угловой
обход по лучам, отсортированным по азимуту; для каждой пары соседних лучей
— mitre при условии «В СЕКТОРЕ» (`directionOk`, разная проверка для
обычной и рефлексной пары), лимит `MITRE_LIMIT × max(half)` и ограничение
концом толстого саппорта (`thickLength`, #271); иначе — плоская хорда
(рефлекс) либо локальный bevel, у которого `reach()` ограничен саппортом,
лимитом и удвоенной глубиной пары. Ни одного вызова `difference()`/`subtract`
в этой функции — подтверждено grep по телу функции. Соответствует §8.2
буквально.
- `junctionContractHoles` (`:2178-2226`) и живая проверка в
`demo/smoke_junction_holes.mjs`: проба — дыра, если она в полосе луча ИЛИ
в веере узла (контрактное покрытие §8.4), но не в самой геометрии; отдельно
клиппуется `bound` (фасадная граница). Самопроверка «детектор не слеп»
— юнит-тест `test/wall-thickness.test.mjs:213` — гоняет детектор на заведомо
дырявой фикстуре.
- `junctionNodeBound` (`:3197-3226`): та же `exteriorEnvelopeGeometry`, но с
пустой узловой картой — то есть «гладкий» контур без узловых вырезов;
используется, чтобы куски узла не отращивали фасад на вогнутой вершине
(concave-Split контракт).
- Единственное вычитание узловой механики — адресный трим #271
(`src/wall-thickness.ts:3538-3556`): `needsTrim()` фильтрует узлы с
вырожденно коротким толстым саппортом (`support.length < support.halfDepth
* 2`) и вызывает `bevelMultiWallBody` ТОЛЬКО на этом подмножестве — не на
всех узлах. AC6 подтверждён по коду, не по комментарию.
- Бумага (`floorFootprintGeometry`, `paperRoomShapesWithWalls`): вызовы
`bevelMultiWallPaper` убраны полностью (функция удалена — `grep
bevelMultiWallPaper src/wall-thickness.ts` даёт 0 совпадений); паперная
геометрия — просто `rawPaperGeom`, с комментарием, что footprint∪shell уже
покрывает каждый узел (измерено в r2 на #197 и репро, байт-в-байт).
- `dropDegenerateRings` (`:3159-3176`): убирает вырожденные (площадь ≤ eps)
кольца, которые boolean union оставляет там, где хорда веера совпадает с
хордой старой фаски — иначе они топологические дыры для потребителей,
считающих кольца.
- Оба рендерера получают тело из одного и того же
`wallBodiesUnionPath` — точка входа не менялась, только тело функции.
## Находка
### Low — стек-документация `JunctionNodeGeometry` описывает не тот контракт
`src/wall-thickness.ts:2012-2025`, докстринг перед
`export interface JunctionNodeGeometry`:
> «One angular walk produces both halves of the corner rule: a FAN per pair
> of adjacent rays…; **a CUT per over-limit pair — the wedge beyond that same
> chord, which is how the approved #249 chamfer looks**. Fan and cut of one
> pair meet exactly at the chord and never overlap…»
Комментарий описывает механизм с ДВУМЯ артефактами — аддитивным веером и
вычитающим «cut» (то есть фаску #249 как вычитание). Фактический интерфейс,
объявленный прямо под этим текстом, содержит только `fans` и `supports` —
поля `cuts` не существует, и сама функция `junctionNodeGeometry` не вызывает
`difference()` ни разу (подтверждено чтением всего тела функции). Это ровно
тот контракт, который decision №5 и AC6 фиксируют как обязательный: «единственное
вычитание узловой механики — адресный трим #271», а не «per over-limit pair».
Проверено `git log -L2012,2030:src/wall-thickness.ts`: комментарий написан в
самом первом WIP-коммите (`a1361974`, «junction node gets back what a chamfer
must never eat» — до decision №5, когда фаска #249 ещё сохранялась как
вычитающий слой) и ни разу не тронут дальнейшими коммитами decision №5
(`aac50151`) и «fans are the whole additive node» (`d3ed299d`), которые
убрали cut-половину механизма из кода, но не из докстринга.
**Почему Low, а не Medium.** Правило 11 PROCESS.md («документация в том же
коммите, что поведение») касается продуктовой документации
(`docs/**`, changelog, USER-GUIDE) — она вся актуальна и уже дважды сверена
построчно (r1 M1 закрыл `docs/WALL-THICKNESS.md`, r2/r3 M3 закрыл текст
спеки). Здесь — внутренний докстринг исходника, не читаемый пользователем и
не являющийся доказательством ни одного AC (AC6 доказан кодом точки вызова
`bevelMultiWallBody` и мутантом `junction-fan-limit-back-to-249`, не
комментарием). Поведение корректно, тесты и мутанты это подтверждают
исполнением. Риск — исключительно в том, что следующий разработчик,
читающий именно этот файл, ошибётся насчёт контракта «нет вычитаний».
**Решение.** Снято с записью, без правки: находка не в скоупе обязательной
правки этого раунда (Low), но дёшева — 6 строк докстринга, переписывающих
«a CUT per over-limit pair… #249 chamfer looks» на «no cut — the fan always
closes either the mitre or the bounded bevel chord, additively». Рекомендую
поправить при следующем touch этого файла.
## Инвариант модели (#254)
Диапазон меняет геометрию (стыки, `wallIntervals`, узловые записи толщины).
`npm test` уже гоняет инварианты на всех фикстурах проекта — 1304/1304 pass.
Отдельно прогнан `npm run invariants -- --config
test/fixtures/302-junction-artifacts.json` (фикстура-репро владельца, 5
узлов, острые углы 15–70 см): «Инварианты выполнены: ссылки разрешимы,
записи толщины находятся» — ключ записи толщины проверен на точное
совпадение со строкой решёточного ребра, не на допуск.
## Гейты — что прогнал сам
| гейт | результат |
|---|---|
| `npx tsc --noEmit` | чисто |
| `npm test` | 1304 pass / 1 skip / 0 fail |
| `npm run build` + сверка бандла | `git status` после пересборки чист — обе копии (`dist/`, `custom_components/houseplan/frontend/`) байт-в-байт совпадают с закоммиченными |
| `node scripts/check-docs.mjs` | passed (7 файлов, 10 внешних ссылок) — обязателен, diff трогает `src/**` |
| `npm run invariants -- --config test/fixtures/302-junction-artifacts.json` | passed |
| `node scripts/process-gate.mjs` (диапазон `origin/dev..HEAD`, 25 коммитов) | «гейт пройден, предупреждений 0» |
| 8 junction/partition-мутантов (`junction-fans-disabled`, `junction-fan-ignores-thick-length`, `junction-reflex-outer-mitre-missing`, `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `partition-merge-ignores-junction`, `junction-checks-room-vertices-only`) | каждый лично прогнан `node scripts/mutation-gate.mjs --id=<id>` → «поймано 1 из 1» |
| трейлеры (`Issue:`, `User-Visible:`, `Release:`, `Baseline-Reviewed:`) | проверены на всех 25 коммитах; оба `User-Visible: yes` (`76d60b3e`, `aac50151`) правят `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md` в том же коммите |
## Гейты — не прогнал сам, компенсировано CI
`npm run golden:verify` и браузерные смоки (`demo/smoke_*.mjs`) в этой
песочнице не запускаются физически: `page.waitForFunction: Timeout 30000ms
exceeded` / `Failed to fetch dynamically imported module`. Это не следствие
изменений задачи — воспроизвёл ту же ошибку на чистом `origin/dev`
(отдельный клон, `npm ci`, `node demo/smoke_decor.mjs`) — сетевое/браузерное
ограничение самой ревью-песочницы, то же самое, что документировал r2.
Компенсация — CI-прогон на `d3927f2e` (третий коммит ребейза; тот же код,
что на HEAD `96318fb9` — разница только `docs/images/screenshots.json`,
подтверждено чтением diff):
- job `golden` (`97846497668`): 126/126 «passed», включая все 16
junction-сцен и обе `safe-resize-handles-clamp-{dark,light}` — прочитан
полный лог, посчитано `grep -c passed` = 126, ни одного «failed»/«diff».
- три шарда `smoke` (`97846497632/522/569`): `smoke_junction_holes`,
`smoke_near_orthogonal_junction`, `smoke_wall_junctions`,
`smoke_junction_patch_resilience`, `smoke_multiwall_junction` — все `ok`;
189 смоков суммарно по трём шардам, единственные строки с «FAIL»/«not ok»
в логе — это исходный текст скрипта (`echo "FAIL $name"`) и смок с
говорящим именем `smoke_glow_fail_dark`, который сам зелёный.
- `performance_smoke` (`97846497544`): success — AC8 (перф-бюджет) закрыт
исполнением, не только «механизм существует».
- `frontend` (`97846156731`): success.
Финальный коммит `96318fb9` легитимно получил «skipped» на этих job'ах через
`reuse` (кэш по хэшу контента) — проверено по `changes`/`reuse` job'ам того
же прогона (`32862032767`), содержимое между `d3927f2e` и `96318fb9`
отличается только некодовым `screenshots.json`.
`node scripts/smoke-select.mjs --base <merge-base> --head HEAD`: прямое
совпадение — `smoke_junction_holes`, `smoke_decor`,
`smoke_grid_scale_invariance`, `smoke_real_plan_masonry`,
`smoke_space_scale_defaults`; зарегистрированная связь —
`smoke_junction_patch_resilience`, `smoke_multiwall_junction`,
`smoke_multiwall_strip_containment`, `smoke_resize_pointer_real_plan`,
`smoke_resize_wall_thickness`. Все перечисленные, кроме
`smoke_real_plan_masonry`/`smoke_space_scale_defaults`/`smoke_decor`
(тематически про `cellCm`, не про узлы), покрыты либо AC9, либо CI-прогоном
выше; отдельно `smoke_real_plan_masonry` тоже был в CI как «прямое
совпадение» по `MITRE_LIMIT» и зелёный там же.
## AC — построчно
| AC | статус | как доказано |
|---|---|---|
| AC1 (0 дыр на всём сете §13) | ✅ | `junctionContractHoles` читан построчно; CI golden 126/126 включает все 16 сцен + репро; юнит «the owner repro is hole-free end to end» зелёный локально |
| AC2 (репро владельца, 5 узлов) | ✅ | `smoke_junction_holes` (живой рендер, CI ok) + юнит-тест на той же фикстуре, локально зелёный |
| AC3 (несвязанные сцены — побайтно; junction — легально) | ✅ | `baselines-index.json`: изменения ограничены junction-сценами и двумя `safe-resize-handles-clamp-*` (объяснено выше — легитимное слияние с #304); подтверждено CI golden 126/126 |
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит `issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)` зелёный; репро-фикстура включает именно эту пару |
| AC5 (виртуальный участок не порождает кладку) | ✅ (чтением) | фильтр `ray.halfDepth > 0` в начале `junctionNodeGeometry` исключает нулевые лучи из веерного обхода; сцены `junction-t-virtual-arm`/`junction-x-virtual-through` в CI golden зелёные |
| AC6 (единственное вычитание — трим #271) | ✅ | `needsTrim()` фильтр по коду (см. выше); мутант `junction-fan-limit-back-to-249` лично прогнан — «поймано 1 из 1»; текст спеки §4.5/§8.2 приведён в соответствие в r3 |
| AC7 (оба рендерера — один путь) | ✅ (чтением, архитектура не менялась) | `wallBodiesUnionPath` — одна точка входа |
| AC8 (перф в бюджете) | ✅ | `performance_smoke` CI job success на коде, идентичном HEAD |
| AC9 (существующие смоки стыков) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction` — CI ok; `split_corner_wall`, `zero_divider_taper`, `wall_thickness*` — часть `npm test` 1304/1304 |
## Не проверял отдельно (не требуется этой дельтой)
- Визуальную приёмку 16 junction-сцен глазами (какой именно pixel-diff у
каждой) — доверился golden-порогам CI и текстовому описанию решения №5 в
issue; геометрический контракт проверен чтением кода и детектором, а не
«на глаз».
- Полный browser-smoke матрицу (189 смоков) целиком — не запрашивает ни один
AC; ограничился прямыми совпадениями/AC9/зарегистрированными связями,
все они зелёные в CI на коде, идентичном HEAD.
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
## Итог
Один Low, снятый с записью без правки (стек-докстринг `JunctionNodeGeometry`
описывает вычитающий «cut», которого в коде нет — см. находку выше). High: 0,
Medium: 0. Вердикт — зелёный.
+154
View File
@@ -0,0 +1,154 @@
# SPEC-REVIEW-302-r1
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — «Артефакты на стыках стен: дыры в union-геометрии. Переработка механизма стыков + полноценный сет скриншот-тестов»
- ТЗ: `docs/specs/302-junction-node-material.md`
- Ветка: `issue/302-junction-node-material`, ревьюемый SHA: `3b19111aa5894f624e608b9f7fef31736a4ecdf5` (родитель `4b8f17ba`, ровно вершина `dev` на момент написания ТЗ)
- Заход: r1 · блокирующих циклов израсходовано 0/4
- Класс изменения: A (продукт), трек: обычный (не `small`) — корректно, задача явно затрагивает perf-бюджет и переработку подсистемы, критерии лёгкого трека не выполняются
- Вердикт: **зелёный**
## Скоуп ревью
Первый заход, разбор полный (§2.9 не применяется — нет r0). Проверялись:
тело issue #302 и единственный комментарий автора, `docs/specs/302-junction-node-material.md`
целиком, `docs/SCOPE.md`, `PROCESS.md` §1–§9, `docs/WALL-THICKNESS.md` целиком,
исходник `src/wall-thickness.ts` (4145 строк) — выборочно, по каждому
идентификатору и утверждению ТЗ, требующему проверки на существование/точность.
## Как проверялось
1. **Продуктовая рамка.** `docs/SCOPE.md` — задача улинии с миссией
(«live spatial overview») через качество геометрии плана; это не новая
фича, а устранение искажений в самом визуальном представлении дома —
прямое следствие J1. Конфликта со SCOPE нет.
2. **Процесс.** Issue помечен `S4-spec-review`, что и запускает этот этап;
размер задачи (обычный) обоснован — трогает `src/wall-thickness.ts`
целиком, влияет на perf-бюджет, не подходит под критерии `small` (§5
PROCESS.md требует «нет влияния на производительность» — здесь влияние
прямо названо в §10 ТЗ).
3. **Фактчекинг утверждений о коде и истории**, чтобы отделить подтверждённые
факты от гипотез, выданных за решение (обязательный пункт инструкции):
- `MITRE_LIMIT`, `openEps`, `buildMultiWallNodeMap`, `wallIntervals`,
`multiWallNodeAt`, `outsetContour`/`insetContour`,
`bevelMultiWallBody`/`bevelMultiWallPaper`, `unionJunctionPatches`,
`linearWallJoinPatches`, `stableJunctionPatch`, `wallBodiesGeometry`,
`wallBodiesUnionPath` — все существуют в `src/wall-thickness.ts` ровно с
тем поведением, которое им приписывает ТЗ (grep + прочтение сигнатур).
- `EPS_NODE = openEps × 4` — совпадает буквально с вызовом в
`multiWallNodesForGeometry` (`wall-thickness.ts:2035-2036`).
- `wallBodiesUnionPath` действительно единственная точка входа для обоих
рендереров: `src/houseplan-card.ts:11687` (Full/Plan) и
`src/space-render.ts:432` (Static) — вызывают именно её. Утверждение §2
ТЗ «оба рендерера получают результат из одного и того же кода» не
догадка, а факт.
- Ссылки на прежние ТЗ (#141, #172, #197, #229, #249, #271, #272, #275,
#279, #288, #290, #296) сверены с содержанием `docs/WALL-THICKNESS.md` —
каждая деталь («R = 1.25×H», `NEAR_AXIS_MAX_DEGREES = 0.25`,
«near-orthogonal», «short ray handoff», сращивание коллинеарных равных
стен из #229) находит буквальное соответствие в каноническом документе
подсистемы либо в исходнике (`src/near-axis.ts:6-7`).
- Все девять смоков из AC9 (`smoke_wall_junctions`,
`smoke_junction_patch_resilience`, `smoke_split_corner_wall`,
`smoke_zero_divider_taper`, `smoke_wall_thickness*`) и
`smoke_render_perf` (AC8) существуют в `demo/`.
- `demo/golden/matrix.mjs` существует и это действительно data-only
каталог golden-фикстур (`GOLDEN_MATRIX_VERSION`), куда естественно
ложится новый сет из §13.
- Нового «детектора дыр» и `isPointInPath`-инструментария в репозитории
сейчас нет (`grep` пусто) — заявка «новый чистый модуль» корректна, это
не дублирование существующего.
- SHA `4b8f17b`, на котором получены цифры 359/360 дыр, существует в
истории и совпадает с прямым родителем ревьюемого коммита — воспроизвести
довод «дыры родились в базовой фазе» можно на том же дереве.
4. **Смысловая проверка контракта §8**, а не только буквенная. Рассмотрены
вырожденные и граничные случаи: узел из 2 лучей (веер строится на обеих
угловых секторах — внутренней и внешней стороне, поскольку у двух лучей при
круговом обходе ровно два соседства); дегенерат mitre при малом угле
(обрезается `MITRE_LIMIT`, переходит в bevel — предотвращает уход материала
в бесконечность); связь с историческим классом дефектов «короткий луч
короче радиуса ремонта» (#271/#288) — новая механика по построению не
продолжает луч вдоль его оси вовсе («конец полосы» — точка ровно у узла,
офсетная по перпендикуляру, а не отложенная на расстояние `MITRE_LIMIT`
вдоль луча), поэтому класс дефектов «репэйр перерос короткий луч» не имеет
аналога в новой механике — не пробел ТЗ, а архитектурное упрощение, которое
стоит явно назвать в фиксации, но не блокирует ревью.
5. **Проверка DoR-состава** (§2.5, §7.1 PROCESS.md): сценарий ✓, «что человек
увидит» ✓, проблема/причина ✓ (§3 с исполненным доказательством), скоуп и
не-скоуп ✓ (§6–7), контракт поведения ✓ (§8), данные/i18n/a11y/privacy ✓
(§9, всё «не меняется» — обосновано отсутствием миграции конфига), perf ✓
(§10, бюджет назван), риски ✓ (§11, три риска с конкретными мерами), AC ✓
(§12, 9 штук, пронумерованы), план автотестов ✓ (§13–14), откат ✓ (§16),
release-артефакты ✓ (§15).
## Находки
Ни одной находки уровня High или Medium. Два пункта Low — решение ревьюера:
сняты с записью, правки не требуют.
1. **Low — способ доказательства AC не продублирован буквально на каждой
строке §12.** AC1–AC5, AC8 описывают ожидаемый результат словами
(«детектор §8.4», «попиксельное совпадение», «смок сверяет пути»), а не
явным тегом `unit`/`golden`/`smoke` при каждом пункте, как того просит
буква §2.5 PROCESS.md. Решение: снимается без правки — §13 однозначно
называет механику («каждая сцена: golden крупным планом + детектор §8.4»,
«юниты: чистые функции веера... и детектора»), и разработчик/ревьюер кода
не может трактовать способ доказательства иначе. Формальный тег ничего не
добавил бы к однозначности.
2. **Low — нет отдельного раздела «UX».** Шаблон §7.1 называет UX отдельным
разделом; в этом ТЗ его содержание разнесено между шапкой («Touch editor:
not exposed») и §2 («Что человек увидит до и после»). Решение: снимается —
задача не меняет ни одного интерактивного пути (только геометрия отрисовки
тел стен), содержательно вопрос закрыт, отдельный заголовок был бы пустой
формальностью.
Открытых продуктовых вопросов к владельцу нет и это обоснованно: контракт
поведения продиктован самим владельцем в комментарии к issue (кандидат-алгоритм
из тела issue почти буквально формализован в §8.2), формальные константы
детектора отмечены как техническое предположение в §17 с направлением
уточнения («только в сторону строгости»), стадийность демонтажа старых слоёв —
там же, помечено как свободное для реализации техническое решение. Это
редкий случай, когда молчание не является риском: сам факт задачи и её решение
исходят от одного и того же человека.
## Что проверено и корректно
- Соответствие SCOPE.md — задача в рамках мандата, не расширяет функциональность.
- Соответствие процессу — статус, класс изменения, трек, обязательные разделы,
трейлеры/release-артефакты названы верно.
- Все технические идентификаторы, числа и ссылки на историю, на которые
опирается контракт §8, проверены на существование и точность прямым чтением
кода и `docs/WALL-THICKNESS.md`, а не приняты на слово.
- Контракт §8.2–8.4 логически замкнут: покрывает все перечисленные в §8.3
вырожденные случаи, не оставляет геометрического механизма для дыры между
соседними веерами (общая вершина — узел, дуги секторов покрывают полный
оборот), и делает объективно проверяемым единственное новое утверждение
(«нет дыр») через детектор с собственной самопроверкой на дырявой фикстуре
(мутант `hole-detector-blind`).
- AC9 корректно называет регрессионный периметр — все перечисленные смоки
существуют и по названию действительно покрывают стыковую механику.
- Откат (§16), release-артефакты (§15) и риски golden-переснятия (§11.1)
учитывают процессные уроки #230/#235 именно так, как того требует PROCESS.md.
## Чего не проверял
- Я не запускал никакие гейты (`tsc`, `npm test`, `npm run build`,
`golden:verify`, смоки) — на этапе ревью ТЗ кода ещё нет, гейты этапа
относятся к код-ревью (§2.7 PROCESS.md), а не к ревью ТЗ (§2.4).
- Не проверял геометрию mitre/bevel численно (не строил прототип и не считал
вручную пересечения офсетных прямых) — на этапе ТЗ это не требуется: контракт
верифицируется исполнением на код-ревью через детектор §8.4 и golden, а не
через ручной пересчёт геометрии ревьюером ТЗ.
- Не читал полностью весь `src/wall-thickness.ts` (4145 строк) построчно —
прочитаны и сверены только фрагменты, релевантные утверждениям ТЗ
(сигнатуры функций, константы, точки вызова `wallBodiesUnionPath`).
- Не проверял содержание единственного комментария к issue построчно на
предмет более ранних версий ТЗ — комментарий один, зафиксированное решение
соответствует финальному ТЗ.
## Итог
`docs/specs/302-junction-node-material.md` готово к переходу в `S5-ready`.
Продуктовая рамка ясна, контракт поведения проверяем объективным детектором и
golden-эталонами, риски демонтажа старого стека названы и покрыты
регрессионным периметром (AC9), технические предположения отделены от решения
и помечены как свободные к изменению.
+261
View File
@@ -0,0 +1,261 @@
# Issue #302 — материал узла стен: переработка на аддитивную логику
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302)
- Класс изменения: A (продукт)
- Размер: обычный (не `small`) — переработка подсистемы
- Автор ТЗ: Codex, 2026-08-25
- Touch editor: not exposed — меняется только построение геометрии тел стен,
одинаковое при любом способе ввода.
## 1. Сценарий
Владелец рисует двор с двумя пристройками: две комнаты делят острую вершину
(~57°), стены 15–70 см на клетке 30 см. На каждом втором стыке — белые
треугольные клинья и зазубрины. Артефакты возвращаются из релиза в релиз:
29 коммитов с «junction», четыре ТЗ (141, 197, 249, 279) — и всё равно.
## 2. Что человек увидит до и после
**До.** Клинья и щели в стыках; вид зависит от числа лучей, углов и толщин
непредсказуемо. **После.** Любой узел — сплошная кладка: mitre в пределах
лимита, bevel-фаска за ним, без дыр и без «лишнего» материала, одинаково на
карте и в статическом рендерере.
## 3. Подтверждённая причина (всё — исполнением на dev `4b8f17b`)
1. **Дыры — в геометрии, не в отрисовке.** На живой карте по `_wallUnionGeometry().d`
сеткой проб `isPointInPath(evenodd)`: 154 пробы у стыка не покрыты
материалом, будучи окружены кладкой с ≥5 из 8 сторон.
2. **Дыры рождаются в базовой фазе и никем не лечатся.** Чистый конвейер
`wallBodiesGeometry` с пофазными снапшотами на репро владельца
(5 узлов, сетка проб):
| после фазы | дыр |
|---|---|
| room-rings + edge-bodies | **359** |
| + unionJunctionPatches | 359 |
| + facade-clip | 359 |
| + exterior-shell | 359 |
| + bevelMultiWallBody | **360** (bevel добавил дыру) |
Отключение `bevelMultiWallBody`, `unionJunctionPatches` и facade-clip по
одному ничего не меняет (359–360) — все «ремонтные» слои для этого класса
дыр нерелевантны.
3. **Локализация.** Оба кольца комнат строятся и мержатся без исключений
(проверено логом), но клинья сидят на внутренних углах колец в окрестностях
multi-wall-узлов — там, где `outsetContour`/`insetContour` получают
`multiWallNodes` и обрезают угловой материал, а фаски/патчи вниз по
конвейеру эти места не накрывают. Визуализация с отмеченными пробами
приложена к issue.
Архитектурно: конвейер — 10 фаз, смешивающих аддитивные патчи и вычитающие
разрезы с try/catch-фолбэками. Пока в узловой механике есть `difference`,
гарантии «дыр нет» не существует по построению; каждый точечный фикс двигает
баланс add/subtract и рождает регресс в соседней конфигурации — что история
и показывает.
## 4. Продуктовые решения владельца (2026-08-25, issue #302)
1. Не следующий точечный фикс, а **переработка узловой механики на простую и
надёжную логику**.
2. **Отдельный полноценный сет тестов со скриншотами стыков крупным планом**:
разное число лучей, толщины, углы, виртуальные стены и т.д.
3. Детектор дыр как объективный инвариант — в дополнение к скриншотам.
4. ~~Фаска #249 на внешних углах узлов сохраняется~~ — решение середины дня,
**отменено решением №5**.
5. **Полный mitre везде** (финальное решение 2026-08-25, по визуальному
сравнению трёх вариантов на T-90 и Y-60 50 см): узлы смыкаются как обычное
пересечение стен на чертеже, фаска #249 демонтируется целиком. Поводом
стали артефакты фаски на не-ортогональных узлах в новых эталонах
(`junction-y-60-equal50` — вырез, `junction-acute30-mixed` — торчащие
углы). Следствия: узловая механика чисто аддитивная, слой
`bevelMultiWallPaper` удаляется, `bevelMultiWallBody` остаётся только
адресным латеральным тримом #271 (уточнение при реализации; §8.2),
существующие junction-эталоны переснимаются осознанно.
## 5. Цели
- Ни одной внутренней дыры ни в одной конфигурации узла из тестового сета и
на репро владельца.
- Одна механика узла вместо стека ремонтных слоёв; поведение выводимо из
короткого контракта §8.
- Оба рендерера получают результат из одного и того же кода (уже так —
`wallBodiesUnionPath`; сохраняется).
## 6. Scope
- `src/wall-thickness.ts`: новая узловая механика; демонтаж заменённых слоёв
(`bevelMultiWallBody`/`bevelMultiWallPaper`, вычитающие разрезы узлов,
спец-обработка `multiWallNodes` в `outsetContour`/`insetContour` — в той
мере, в какой их роль переходит к новой механике).
- Новый чистый модуль детектора дыр (переиспользуется тестами).
- `demo/golden/matrix.mjs` + фикстура: сет узловых сцен крупным планом.
- Юниты, смок, мутанты.
## 7. Не входит
- Модель хранения (docs/ADR: полигоны комнат + walls/partitions) — не меняется.
- Правило роста ±cm/2 от оси (docs/WALL-THICKNESS.md §2) — не меняется.
- Проёмы: вычитающий слот проёма остаётся как есть (это не узловая механика).
- Штриховка, цвета, бумага-подложка вне узлов.
## 8. Контракт поведения
### 8.1. Узел
Узел — кластер концов лучей (интервалов стен из `wallIntervals`) в допуске
`EPS_NODE = openEps × 4` (текущий допуск `buildMultiWallNodeMap` — сохраняется).
Луч приходит в узел со своей полу-толщиной; лучи упорядочиваются по азимуту.
### 8.2. Правило узла: аддитивные веера (решение №5)
Для каждой пары соседних по азимуту лучей (сектор ≤ 180°; рефлексные секторы —
внешняя сторона выпуклого угла — пропускаются):
**Веер (аддитивно):** полигон «узел → край полосы A → mitre → край полосы B»,
где mitre — пересечение обращённых в сектор офсетных прямых. Границы:
- mitre принимается, пока он в пределах классического `MITRE_LIMIT ×
max(толщин пары)` — то же правило, что у обычных углов комнат;
- и пока он не дальше конца ТОЛСТОГО саппорта каждого луча — иначе веер
нарисовал бы латеральный фантом рядом с тонким продолжением (#271);
- иначе — bevel-хорда: по каждому краю до `min(длина толстого саппорта,
√(limit² − half²))`, замыкание хордой.
Дополнительно узел аддитивно получает точные саппорт-квады своих лучей
(каждый ограничен собственной конечной длиной). Все куски клиппуются гладкой
фасадной границей `junctionNodeBound` — узел не растит новый фасад на
вогнутой вершине.
**Единственное вычитание узловой механики — адресный латеральный трим
#271**: `bevelMultiWallBody` вызывается только для узлов с вырожденно-коротким
толстым саппортом (короче собственной толщины), где базовые контуры красят
лишнюю ширину, которую аддитивно не убрать; его угловые следы на таких узлах
перекрываются веерами. На всех остальных узлах механика чисто аддитивная:
слой фаски #249 как ВИД демонтирован целиком, дыра между полосами невозможна
по построению, «лишний» материал ограничен `MITRE_LIMIT` и фасадной
границей.
### 8.3. Спец-случаи
- Луч нулевой толщины (виртуальный участок, осевой черновик): в веерах не
участвует, соседями по азимуту становятся его соседи (текущее поведение
«zero divider не порождает кладку» сохраняется, docs/specs/172).
- Два коллинеарных луча одной толщины — вырожденный веер (пустой), стык
бесшовный (согласуется с #229).
- Одинокий конец (degree-1) — плоский торец, веера нет (как сейчас).
- Колонна в узле — самостоятельное тело, union поверх (как сейчас).
### 8.4. Инвариант «нет дыр» — формальный
Уточнение по факту реализации (первая формулировка «окружено кладкой с ≥5 из
8 сторон» ложно срабатывала на легитимном полу комнаты в острых внутренних
углах — за inset-mitre): детектор проверяет **контрактное покрытие**. Проба p —
«дыра», если p ∈ (полоса какого-либо луча узла ∪ веер узла) и p ∉ тело. Сетка
проб шагом `0.2 × gridPitch` в радиусе `MITRE_LIMIT × halfDepth + halfDepth` от
узла; принадлежность полосе — строго внутри квада (0 ≤ t ≤ длина, |перпендикуляр|
< half − ε). Ноль дыр — обязательство для каждого узла каждой сцены сета §13.
### 8.5. Что не меняется наружно
- Форма прямых участков, торцы, проёмные тоннели, бумага по контурам комнат.
- Ортогональные L/T/X-стыки одинаковой толщины обязаны совпасть с текущим
видом попиксельно (golden), кроме сцен, где сегодня есть дефекты.
## 9. Данные, i18n, a11y, privacy, security
Конфиг не меняется, миграций нет, строк интерфейса нет. Приватность/безопасность
не затрагиваются.
## 10. Performance
Замена вычитающих фаз на аддитивные веера уменьшает число булевых операций на
узел. Бюджет: `smoke_render_perf` не хуже базовой линии; large-house фикстура —
без деградации, замер в отчёте на код-ревью.
## 11. Риски
1. **Golden-переснятие.** Существующие сцены стыков изменятся законно там, где
сегодня дефект. Каждое расхождение разбирается поимённо; принятие — только
`golden:accept -- --reviewed` отдельным коммитом с `Release:` +
`Baseline-Reviewed:` (урок #230). Сцены, не связанные с узлами, обязаны
совпасть побайтно.
2. **Демонтаж слоёв.** Убирая `bevelMultiWallBody` и вычитающие разрезы, можно
потерять их полезную функцию для конфигураций вне сета. Ответ — широта сета
§13 и обязательный прогон всех существующих junction-смоков и golden.
3. **Незамеченный «лишний» материал.** Аддитивные веера могут закрыть то, что
раньше было честной щелью (например, два независимых узла рядом). Допуск
`EPS_NODE` не расширяется, веера строятся только между лучами ОДНОГО узла.
## 12. Acceptance criteria
- **AC1.** Детектор §8.4: ноль внутренних дыр на каждом узле каждой сцены сета §13.
- **AC2.** Репро владельца (5 узлов): ноль внутренних дыр; визуально клинья
исчезли (golden-сцена репро).
- **AC3.** Сцены, не связанные с узлами ≥3 лучей, совпадают с текущими
эталонами попиксельно. Junction-сцены меняются законно (решение №5) и
переснимаются осознанно с поимённым разбором.
- **AC4.** Острый угол 57° со стенами 50/70: сплошная кладка (перекрытие полос
не срезается), свободная часть клина срезана по лимиту узла.
- **AC5.** Виртуальный участок в узле не порождает кладку; соседние физические
лучи смыкаются веером через него.
- **AC6.** Единственное вычитание узловой механики — адресный трим #271 на
узлах с вырожденно-коротким толстым саппортом; все прочие узлы аддитивны
(фаска #249 как вид демонтирована). Мутант возвращает лимит веера к 1.25·h —
юнит формы веера обязан покраснеть (вырез Y-60 из отчёта владельца).
- **AC7.** Оба рендерера дают идентичную геометрию узла (один вызов
`wallBodiesUnionPath`; смок сверяет пути).
- **AC8.** Перф: `smoke_render_perf` в бюджете; large-house без деградации.
- **AC9.** Существующие смоки стыков (`wall_junctions`,
`junction_patch_resilience`, `split_corner_wall`, `zero_divider_taper`,
`wall_thickness*`) зелёные.
## 13. Тестовый сет (golden крупным планом + детектор)
Каждая сцена: узел занимает весь кадр; тёмная тема; для каждой сцены детектор
§8.4 по всем узлам. Матрица:
| группа | сцены |
|---|---|
| число лучей | L (2), T (3), X (4), звезда (5) |
| толщины | равные 15; смешанные 15/50, 50/70, 15/70 |
| углы | 90°, 60°, 45°, 30°, 15°, 170° |
| виртуальные | физический+виртуальный в T; виртуальный сквозь X |
| комнаты | вершина комнаты + перегородка; T в середину стены комнаты; общая острая вершина двух комнат (репро) |
| прочее | колонна в узле; конец черновика; перекрёсток двух перегородок |
Комбинации не декартовы — ~20 сцен, отобранных по одному представителю на
класс, плюс сцена-репро из issue. Юниты: чистые функции веера (mitre/bevel,
пороги, вырожденные случаи) и детектора.
## 14. Мутационный гейт
| id | Что ломает | Гвард |
|---|---|---|
| `node-fan-disabled` | веера не строятся вовсе | детектор на сете |
| `node-fan-outer-only` | веер только с внешней стороны | детектор |
| `node-fan-ignores-mitre-limit` | mitre без лимита | юниты веера |
| `node-fan-includes-zero-ray` | нулевой луч порождает кладку | юниты + смок |
| `node-fan-limit-back-to-249` | лимит веера снова 1.25·h — вырез Y-60 | юниты формы веера |
| `hole-detector-blind` | детектор всегда зелёный | самопроверка детектора на заведомо дырявой фикстуре |
Все юнит-гварды — с пересборкой `test-build` (урок #230/#235).
## 15. Release-артефакты
`User-Visible: yes`: обе редакции CHANGELOG; `docs/WALL-THICKNESS.md` §3 и §9
переписываются под новую механику; строка в USER-GUIDE при необходимости.
Golden-переснятие — отдельным коммитом по правилу §11.1.
## 16. Откат
Один revert продуктового коммита + revert коммита эталонов. Конфиг не
меняется, миграций нет.
## 17. Принятые предположения (техническое, менять свободно)
- Константы детектора (§8.4) выбраны по факту воспроизведения; могут быть
уточнены, но только в сторону строгости.
- Демонтаж старых слоёв допустимо вести поэтапно (веера поверх текущей базы →
снятие bevel-слоя → снятие узловой спец-обработки контуров), если каждый шаг
держит AC1–AC9 зелёными; порядок — на усмотрение реализации.
+75 -39
View File
@@ -614,45 +614,6 @@ export const MUTANTS = [
replace: ' return v;',
}],
},
{
id: 'multi-wall-paper-full-origin-cut',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue #197 keeps" '
+ 'test/wall-thickness.test.mjs',
because: 'the exterior paper must retain valid multi-wall overlap up to R; cutting from the '
+ 'offset origins recreates the measured white T-junction wedge from #261',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const cuts = multiWallEffectiveCutGeometry(\n'
+ ' node, map, true, true, protectedStrips,\n'
+ ' );',
replace: ' const cuts = multiWallEffectiveCutGeometry(\n'
+ ' node, map, false, true, protectedStrips,\n'
+ ' );',
}, {
file: 'src/wall-thickness.ts',
find: ' if (envelope) localInside = intersection(localInside, envelope);\n'
+ ' else if (centre) localInside = intersection(localInside, centre);',
replace: ' if (centre) localInside = intersection(localInside, centre);\n'
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
}, {
file: 'src/wall-thickness.ts',
find: ' return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;',
replace: ' return cuts;',
}, {
file: 'src/wall-thickness.ts',
find: ' if (protectedStrips) local = union(local, protectedStrips);',
replace: ' // protected-strip fail-safe intentionally disabled by the mutant',
}, {
file: 'src/wall-thickness.ts',
find: ' if (protectedStrips) {\n'
+ ' try {\n'
+ ' let protectedInside = protectedStrips;',
replace: ' if (protectedStrips && false) {\n'
+ ' try {\n'
+ ' let protectedInside = protectedStrips;',
}],
},
{
id: 'multi-wall-orthogonal-strip-protection-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -2209,6 +2170,81 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'junction-fans-disabled',
// Юниты формы, не детектор: контрактные пробы детектора строятся из той
// же функции и слепнут вместе с ней, а «T-узел даёт два веера» — внешняя
// истина, не зависящая от мутируемого кода.
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'без вееров сектор между соседними полосами узла остаётся дырой — '
+ 'это и есть класс артефактов #302',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (mitre) {\n push([[P[0], P[1]], EA, mitre, EB]);\n continue;\n }',
replace: ' if (mitre) {\n continue;\n }',
}, {
file: 'src/wall-thickness.ts',
find: ' push([[P[0], P[1]], EA, A2, B2, EB]);',
replace: ' void A2; void B2;',
}],
},
{
id: 'junction-fan-ignores-thick-length',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'mitre, шагнувший за короткий толстый саппорт, рисует латеральный '
+ 'фантом рядом с тонким продолжением (#271)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' : tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;',
replace: ' : tA > 1e-9;',
}],
},
{
id: 'junction-reflex-outer-mitre-missing',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'рефлексный сектор — наружный угол между крайними лучами; без '
+ 'обратного mitre там остаётся вырез Y-60 из отчёта владельца',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const directionOk = reflex\n ? tA <= 1e-9 && tB <= 1e-9',
replace: ' const directionOk = reflex\n ? false',
}],
},
{
id: 'junction-fan-limit-back-to-249',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'лимит веера 1.25·h — это отставка решения №5: узлы снова с '
+ 'вырезами и ступеньками вместо полного mitre',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);\n // Facing strip edges',
replace: ' const limit = node.limit;\n // Facing strip edges',
}],
},
{
id: 'junction-detector-blind',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
because: 'слепой детектор превращает инвариант «нет дыр» в декорацию — '
+ 'самопроверка на заведомо дырявой фикстуре обязана краснеть',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (!inGeometry(geometry, x, y)) holes.push([x, y]);',
replace: ' void x; void y;',
}],
},
{
id: 'junction-pieces-unbounded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="concave vertex" test/wall-thickness.test.mjs',
because: 'куски узла без клипа гладкой фасадной границей отращивают новый '
+ 'фасад на вогнутой вершине',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (bound) ring = intersection(ring, bound);',
replace: ' void bound;',
}],
},
{
id: 'hatch-step-ignores-cell-cm',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+320 -46
View File
@@ -2008,6 +2008,212 @@ export function buildMultiWallNodeMap(
return { epsilon: eps, coordinateScale: scale, nodes, index };
}
/**
* Corner geometry of one node (#302 + the #249 chamfer, owner 2026-08-25).
*
* One angular walk produces both halves of the corner rule:
* - a FAN per pair of adjacent rays — additive sector material from the node
* out to the mitre point, or to the bevel chord when the mitre runs past
* the node's approved join limit;
* - a CUT per over-limit pair — the wedge beyond that same chord, which is
* how the approved #249 chamfer looks.
* Fan and cut of one pair meet exactly at the chord and never overlap, and the
* cut lies strictly between the two strip edges: subtracting it can touch
* neither strip's interior. That bound is what the old bevel layer kept
* failing to hold — its cuts reached past the limit with a separate
* "protection" pass patching the damage after the fact.
*/
export interface JunctionNodeGeometry {
fans: number[][][];
/** Exact support quads of every ray — the strips the node actually owns. */
supports: number[][][];
}
export function junctionNodeGeometry(
map: MultiWallNodeMap | null | undefined,
): JunctionNodeGeometry {
const out: JunctionNodeGeometry = { fans: [], supports: [] };
if (!map?.nodes?.length) return out;
const areaEps = Math.max(map.epsilon, 1e-9) ** 2;
for (const node of map.nodes) {
const rays = node.rays
.filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0)
.map((ray) => ({
...ray,
// The fan follows the strip that actually exists at the node: the
// ray's max half-depth is only valid as far as the support that owns
// it. Walking past a short thick support would paint a phantom beside
// a thinner continuation (#271).
thickLength: Math.max(...ray.supports
.filter((support) => support.halfDepth >= ray.halfDepth - 1e-12)
.map((support) => support.length), 0),
angle: (() => {
const a = Math.atan2(ray.u[1], ray.u[0]);
return a < 0 ? a + Math.PI * 2 : a;
})(),
}))
.sort((a, b) => a.angle - b.angle);
if (rays.length < 2) continue;
const P = node.point;
// The support quads are the ground truth the chamfer must never eat:
// each is bounded by its own finite length, so re-adding them can never
// repaint a lateral phantom beyond a short support (#271).
for (const ray of rays) {
for (const support of ray.supports) {
if (!(support.halfDepth > 0) || !(support.length > 0)) continue;
const ex = -ray.u[1] * support.halfDepth;
const ey = ray.u[0] * support.halfDepth;
const far = [
P[0] + ray.u[0] * support.length,
P[1] + ray.u[1] * support.length,
];
out.supports.push([
[P[0] + ex, P[1] + ey],
[far[0] + ex, far[1] + ey],
[far[0] - ex, far[1] - ey],
[P[0] - ex, P[1] - ey],
]);
}
}
for (let i = 0; i < rays.length; i++) {
const A = rays[i];
const B = rays[(i + 1) % rays.length];
const sector = (() => {
const raw = B.angle - A.angle;
return raw > 0 ? raw : raw + Math.PI * 2;
})();
if (sector < 1e-9) continue;
const reflex = sector > Math.PI + 1e-9;
const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
// Facing strip edges: A's at angle+90°, B's at angle−90°.
const EA = [P[0] - A.u[1] * A.halfDepth, P[1] + A.u[0] * A.halfDepth];
const EB = [P[0] + B.u[1] * B.halfDepth, P[1] - B.u[0] * B.halfDepth];
const cross = A.u[0] * B.u[1] - A.u[1] * B.u[0];
const inSector = (point: number[]): boolean => {
let angle = Math.atan2(point[1] - P[1], point[0] - P[0]) - A.angle;
while (angle < 0) angle += Math.PI * 2;
return angle <= sector + 1e-9;
};
let mitre: number[] | null = null;
if (Math.abs(cross) > 1e-9) {
const tA = ((EB[0] - EA[0]) * B.u[1] - (EB[1] - EA[1]) * B.u[0]) / cross;
const tB = ((EB[0] - EA[0]) * A.u[1] - (EB[1] - EA[1]) * A.u[0]) / cross;
const candidate = [EA[0] + A.u[0] * tA, EA[1] + A.u[1] * tA];
// The mitre is only a corner when it actually sits IN the sector —
// forward along the rays for an ordinary pair, backward for a reflex
// outer corner — inside the classic bound, and never past a thick
// support (#271: overshooting one paints a lateral phantom).
const directionOk = reflex
? tA <= 1e-9 && tB <= 1e-9
: tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;
if (directionOk
&& Math.hypot(candidate[0] - P[0], candidate[1] - P[1]) <= limit
&& inSector(candidate)) {
mitre = candidate;
}
}
const push = (poly: number[][]) => {
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
};
if (mitre) {
push([[P[0], P[1]], EA, mitre, EB]);
continue;
}
if (reflex) {
// A degenerate reflex mitre (parallel or out-of-bound edges) closes
// with the plain chord between the two strip edges.
push([[P[0], P[1]], EA, EB]);
continue;
}
// Bevel: walk each offset line a LOCAL distance — bounded by the thick
// support, by the classic limit and by twice the pair's depth, so the
// chord stays a corner detail and cannot fold across the plan.
const reach = (half: number, length: number) => Math.min(
length,
Math.sqrt(Math.max(limit ** 2 - half ** 2, 0)),
2 * Math.max(A.halfDepth, B.halfDepth),
);
const A2 = [
EA[0] + A.u[0] * reach(A.halfDepth, A.thickLength),
EA[1] + A.u[1] * reach(A.halfDepth, A.thickLength),
];
const B2 = [
EB[0] + B.u[0] * reach(B.halfDepth, B.thickLength),
EB[1] + B.u[1] * reach(B.halfDepth, B.thickLength),
];
push([[P[0], P[1]], EA, A2, B2, EB]);
}
}
return out;
}
/** The fans alone — kept for callers that only ever add material. */
export function junctionNodeFans(
map: MultiWallNodeMap | null | undefined,
): number[][][] {
return junctionNodeGeometry(map).fans;
}
/**
* The objective no-holes invariant of #302 (spec §8.4).
*
* A probe is a HOLE when the contract says the point is masonry — inside one
* of the node's support strips or sector fans, and inside the approved facade
* bound — yet the produced geometry does not cover it. Legitimate floor of an
* acute room corner never trips this (it is outside both strips and fans),
* which is what the first "surrounded by masonry" formulation got wrong.
*/
export function junctionContractHoles(
geometry: any,
map: MultiWallNodeMap | null | undefined,
options: { step: number; bound?: any },
): { node: [number, number]; holes: number[][] }[] {
if (!map?.nodes?.length || !(options.step > 0)) return [];
const corners = junctionNodeGeometry(map);
const inPolygon = (points: number[][], x: number, y: number): boolean => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
inside = !inside;
}
return inside;
};
const inGeometry = (geom: any, x: number, y: number): boolean => {
let inside = false;
for (const polygon of geom || []) for (const ring of polygon || []) {
if (inPolygon(ring, x, y)) inside = !inside;
}
return inside;
};
const reports: { node: [number, number]; holes: number[][] }[] = [];
for (const node of map.nodes) {
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
const holes: number[][] = [];
for (let dx = -radius; dx <= radius; dx += options.step) {
for (let dy = -radius; dy <= radius; dy += options.step) {
const x = node.point[0] + dx;
const y = node.point[1] + dy;
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = x - node.point[0];
const ry = y - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0])
<= support.halfDepth - options.step * 0.25;
}));
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
if (!inStrip && !inFan) continue;
if (options.bound && !inGeometry(options.bound, x, y)) continue;
if (!inGeometry(geometry, x, y)) holes.push([x, y]);
}
}
if (holes.length) reports.push({ node: [...node.point], holes });
}
return reports;
}
/** Find the canonical degree-3+ node matching a contour vertex. */
export function multiWallNodeAt(
map: MultiWallNodeMap | null | undefined,
@@ -2746,40 +2952,6 @@ function bevelMultiWallBody(
return current;
}
function bevelMultiWallPaper(
paper: any,
centre: any,
map: MultiWallNodeMap,
): any {
let beveled = paper;
let protectedStrips: any = null;
try {
protectedStrips = multiWallProtectedMapGeometry(map);
} catch {
return paper;
}
for (const node of map.nodes) {
try {
const cuts = multiWallEffectiveCutGeometry(
node, map, true, true, protectedStrips,
);
if (cuts) beveled = difference(beveled, cuts);
} catch {
// Isolate a failed optional node cut; retain the last valid paper.
}
}
try {
if (protectedStrips) {
const protectedPaper = intersection(protectedStrips, paper);
beveled = union(beveled, protectedPaper);
}
// Paper is the complete room footprint. Interior bevel cuts expose floor,
// not the scene background, so the centre union must always remain solid.
return union(centre, beveled);
} catch {
return paper;
}
}
/**
* Collapse arithmetic noise on computed junction vertices before polyclip sees
@@ -2985,14 +3157,68 @@ export function floorFootprintGeometry(
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
return multiWallNodes.nodes.length
? bevelMultiWallPaper(paper, exterior.centre, multiWallNodes)
: paper;
return paper;
} catch {
return null;
}
}
/**
* Drop zero-area rings a boolean union leaves where two chords coincide
* exactly (fan chord over chamfer chord). They paint nothing, but they are
* topological holes and every downstream ring-counting consumer sees them.
*/
function dropDegenerateRings(geom: any, areaEps: number): any {
if (!Array.isArray(geom)) return geom;
const polygons = geom
.map((polygon: any) => {
if (!Array.isArray(polygon) || !polygon.length) return polygon;
const [outer, ...holes] = polygon;
if (Math.abs(signedArea(outer || [])) <= areaEps) return null;
return [outer, ...holes.filter(
(ring: number[][]) => Math.abs(signedArea(ring || [])) > areaEps,
)];
})
.filter((polygon: any) => !!polygon);
return polygons;
}
/**
* The approved outer boundary for node pieces: the building footprint plus
* the exterior wall band with PLAIN corners — the very shape the contour had
* before any node existed. Fans and support tips are clipped to it, so the
* node can never grow new facade (the concave-Split contract), while the
* plain corners — unlike the node-notched envelope — never reopen the sector
* holes the pieces exist to close.
*/
export function junctionNodeBound(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
map: MultiWallNodeMap,
): any | null {
try {
const plain: MultiWallNodeMap = {
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
nodes: [], index: new Map(),
};
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, plain,
);
if (!exterior) return null;
return exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
} catch {
return null;
}
}
export function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
@@ -3237,9 +3463,13 @@ export function wallBodiesGeometry(
const rawPaperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const paperGeom = multiWallNodes.nodes.length && exterior
? bevelMultiWallPaper(rawPaperGeom, exterior.centre, multiWallNodes)
: rawPaperGeom;
// Paper: the approved chamfer first, then the same additive fans that
// complete the masonry corner complete the paper beneath it (#302).
// Paper needs no node pieces: the footprint-plus-shell union already
// covers every junction (measured on #197 and the owner repro — byte-equal
// with and without them), and with the subtractive paper bevel gone the
// #261 white-wedge class is impossible by construction.
const paperGeom = rawPaperGeom;
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
@@ -3302,9 +3532,55 @@ export function wallBodiesGeometry(
}
}
}
corePhase = 'multi-wall-bevel';
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
// The old bevel layer survives only as a TARGETED lateral trim: it
// removes the ring material a base contour paints past a degenerately
// short thick support (#271) — something no additive piece can undo. It
// runs ONLY on nodes that actually have such a support: everywhere else
// it used to leave the steps and horns the owner rejected (decision #5),
// and the node stays purely additive.
corePhase = 'multi-wall-trim';
if (body && multiWallNodes.nodes.length) {
const needsTrim = (node: MultiWallNode): boolean => node.rays.some(
(ray) => ray.supports.some(
(support) => support.length < support.halfDepth * 2,
),
);
const trimNodes = multiWallNodes.nodes.filter(needsTrim);
if (trimNodes.length) {
const trimMap: MultiWallNodeMap = {
...multiWallNodes, nodes: trimNodes,
};
body = bevelMultiWallBody(body, trimMap, exterior?.centre, paperGeom);
}
}
// Then the node gets its additive corners: the exact support quads of its
// rays — each bounded by its own finite length, so the trimmed lateral
// phantom cannot come back — and one mitre/bevel fan per pair of
// angularly adjacent rays, bounded by the classic MITRE_LIMIT.
corePhase = 'junction-corners';
if (multiWallNodes.nodes.length) {
const corners = junctionNodeGeometry(multiWallNodes);
const bound = junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
);
// Fans only: with the trim now TARGETED (it never cuts an ordinary
// node's strips) the support re-union became dead weight — measured by
// the full unit suite staying green without it. The support quads stay
// exported: the detector and the tests use them as the contract truth.
for (const piece of corners.fans) {
try {
let ring: any = [closedRing(piece)];
if (bound) ring = intersection(ring, bound);
if (!ring?.length) continue;
body = body ? union(body, ring) : ring;
} catch {
// A degenerate piece must not take the whole node down; the rest
// still stands on its own.
}
}
body = dropDegenerateRings(body, Math.max(multiWallNodes.epsilon, 1e-9) ** 2);
}
const roomGeom = body || [];
// cut opening tunnels (axis-aligned to opening angle)
corePhase = 'openings';
@@ -3641,9 +3917,7 @@ export function paperRoomShapesWithWalls(
const multiWallNodes = multiWallNodesForGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
const paper = multiWallNodes.nodes.length
? bevelMultiWallPaper(rawPaper, exterior.centre, multiWallNodes)
: rawPaper;
const paper = rawPaper;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
+154
View File
@@ -0,0 +1,154 @@
{
"cell_cm": 30,
"rooms": [
{
"id": "rmrwhxasn",
"poly": [
[
0.23333333333333334,
0.029166666666666667
],
[
0.525,
0.029166666666666667
],
[
0.525,
0.7666666666666667
],
[
0.23333333333333334,
0.7666666666666667
]
]
},
{
"id": "rmt4w8xzu-0",
"poly": [
[
0.30833333333333335,
0.2375
],
[
0.32916666666666666,
0.14166666666666666
],
[
0.37916666666666665,
0.19166666666666668
],
[
0.37916666666666665,
0.2375
]
]
},
{
"id": "rmt4w9f2r-0",
"poly": [
[
0.37916666666666665,
0.19166666666666668
],
[
0.32916666666666666,
0.14166666666666666
],
[
0.4083333333333333,
0.1
],
[
0.43333333333333335,
0.15833333333333333
]
]
}
],
"walls": [
{
"key": "0.320833,0.191667@1.7850",
"cm": 50,
"a": [
0.30833333333333335,
0.2375
],
"b": [
0.32916666666666666,
0.14166666666666666
]
},
{
"key": "0.370833,0.120833@2.6572",
"cm": 50,
"a": [
0.32916666666666666,
0.14166666666666666
],
"b": [
0.4083333333333333,
0.1
]
},
{
"key": "0.345833,0.237500@0.0000",
"cm": 15,
"a": [
0.37916666666666665,
0.2375
],
"b": [
0.30833333333333335,
0.2375
]
},
{
"key": "0.354167,0.166667@0.7856",
"cm": 70,
"a": [
0.32916666666666666,
0.14166666666666666
],
"b": [
0.37916666666666665,
0.19166666666666668
]
},
{
"key": "0.408333,0.175000@2.5900",
"cm": 50,
"a": [
0.43333333333333335,
0.15833333333333333
],
"b": [
0.37916666666666665,
0.19166666666666668
]
},
{
"key": "0.420833,0.129167@1.1661",
"cm": 50,
"a": [
0.4083333333333333,
0.1
],
"b": [
0.43333333333333335,
0.15833333333333333
]
},
{
"key": "0.379167,0.216667@1.5706",
"cm": 25,
"a": [
0.37916666666666665,
0.19166666666666668
],
"b": [
0.37916666666666665,
0.2375
]
}
]
}
+2 -2
View File
@@ -367,7 +367,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 44);
assert.equal(GOLDEN_MATRIX_VERSION, 45);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -466,7 +466,7 @@ test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.multiWallJunction.rays, 3);
assert.equal(scenario.multiWallJunction.node.length, 2);
assert.equal(scenario.multiWallJunction.discardedWedgeProbe.length, 2);
assert.equal(scenario.multiWallJunction.retainedOverlapProbe.length, 2);
assert.equal(scenario.multiWallJunction.enclosedHoles, 0,
'the product contract must not preserve the temporary two-hole inventory');
const fixture = prepareGoldenFixture(scenario);
+219 -11
View File
@@ -25,6 +25,7 @@ import {
WALL_HATCH_MIN_PX,
wallHatchStepUnits, wallHatchNeedsSolid,
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
junctionNodeBound, junctionNodeGeometry, junctionContractHoles,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { resolveOpenCuts } from '../test-build/open-spans.js';
@@ -196,6 +197,11 @@ const assertBoundedMultiWallBevels = (
);
const triangles = multiWallBevelTriangles(map);
assert.ok(triangles.length > 0, 'fixture no longer exercises an oversized multi-wall join');
// Strips live only inside the approved facade bound: a giant shared wall
// poking past the building outline is clipped there, exactly as before.
const facadeBound = junctionNodeBound(
rooms, walls, [], pitch, cell, GRID_PITCH, scale, map,
);
for (const triangle of triangles) {
const node = map.nodes.find((candidate) => triangle.slice(0, 2).every((point) =>
Math.hypot(
@@ -211,20 +217,43 @@ const assertBoundedMultiWallBevels = (
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
];
const protectedStrips = multiWallProtectedStripGeometry(node, map);
const protectedCoverage = protectedStrips
? geometryProbeCoverage(protectedStrips, probe)
: 0;
// #302 contract (owner decision #5): the node is purely additive. A
// probe inside the node's support strips or mitre/bevel fans — within the
// plain facade bound — is FILLED; outside all of them the wedge is empty.
const nodeCorners = junctionNodeGeometry({
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
nodes: [node], index: new Map(),
});
const inPolygonProbe = (points) => {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const [xi, yi] = points[i];
const [xj, yj] = points[j];
if ((yi > probe[1]) !== (yj > probe[1])
&& probe[0] < ((xj - xi) * (probe[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const inStrips = node.rays.some((ray) => ray.supports.some((support) => {
const rx = probe[0] - node.point[0];
const ry = probe[1] - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - 1e-7;
}));
const inFans = nodeCorners.fans.some((fan) => inPolygonProbe(fan));
const inContract = (inStrips || inFans)
&& (!facadeBound || geometryProbeCoverage(facadeBound, probe) > 1e-7);
const actualCoverage = geometryProbeCoverage(geometry.geom, probe);
if (protectedCoverage > 1e-7) {
if (inContract) {
assert.ok(
actualCoverage + 1e-7 >= protectedCoverage,
`a bevel removed protected orthogonal material at ${probe}`,
actualCoverage > 1e-7,
`the additive node lost contract material at ${probe}`,
);
} else {
assert.ok(
actualCoverage < 1e-7,
`an unprotected excessive multi-wall wedge remains filled at ${probe}`,
`a wedge outside every strip and fan remains filled at ${probe}`,
);
}
}
@@ -1196,15 +1225,35 @@ test('issue #249 bounds the exported three-wall junction with straight bevels',
nodePoint[1] + ray.u[1] * node.halfDepth * 2,
], 'an incident wall arm no longer touches the junction');
}
// #302: the chamfer is strip-safe now. A wedge probe inside the strips the
// node actually owns stays FILLED (an acute junction is solid masonry);
// outside every strip the wedge is still discarded, as approved in #249.
const bound = junctionNodeBound(
rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, nodes,
);
for (const triangle of localTriangles) {
const base = [
(triangle[0][0] + triangle[1][0]) / 2,
(triangle[0][1] + triangle[1][1]) / 2,
];
assertProbeOutside(geometry.geom, [
const probe = [
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
], 'the discarded mitre wedge is still filled');
];
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
const rx = probe[0] - node.point[0];
const ry = probe[1] - node.point[1];
const along = rx * ray.u[0] + ry * ray.u[1];
if (along < 0 || along > support.length) return false;
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - 1e-7;
})) && (!bound || geometryProbeCoverage(bound, probe) > 1e-7);
if (inStrip) {
assertProbeInside(
geometry.geom, probe, 'the strip-safe chamfer removed strip material',
);
} else {
assertProbeOutside(geometry.geom, probe, 'the discarded mitre wedge is still filled');
}
}
const repeated = wallBodiesGeometry(
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
@@ -2190,7 +2239,10 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
// #271 removes only the area that the old node-wide 8H rectangles invented
// after finite ray endpoints; all semantic #197/#249/#261 probes above stay.
// #272 additionally opens any point-contact bevel cut to the exterior.
closeTo(geometryArea(geometry.geom), 124534.6091222676, 1e-6);
// #302: the node's support quads and mitre fans (reflex outer corners
// included) add a sliver of masonry (+0.6 units²) at this fixture's
// junctions.
closeTo(geometryArea(geometry.geom), 124535.20808099362, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
@@ -2862,3 +2914,159 @@ test('ownEdgeOffsets reads the atomic profile, not a whole-edge lookup (#233)',
assert.deepEqual(ownEdgeOffsets(rooms, 'r', [], [], pitch, 5, GRID_PITCH, 1), [0, 0, 0, 0]);
assert.equal(ownEdgeOffsets(rooms, 'missing', whole, [], pitch, 5, GRID_PITCH, 1), null);
});
// --- issue #302: junction node corners --------------------------------------
const nodeMapOf = (intervals, eps = 1e-6) => buildMultiWallNodeMap(intervals, eps, 1);
const starIntervals = (arms) => arms.flatMap(({ deg, half, len = 300 }, index) => {
const rad = (deg * Math.PI) / 180;
const b = [500 + Math.cos(rad) * len, 500 + Math.sin(rad) * len];
return [{
key: `arm-${index}`, a: [500, 500], b, half, cm: half * 2,
kind: 'outer', open: false, roomId: `r${index}`,
}];
});
test('issue 302 a T node covers every sector with a fan or a mitre', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 90, half: 5 }, { deg: 180, half: 5 },
]));
assert.equal(map.nodes.length, 1);
const { fans, supports } = junctionNodeGeometry(map);
assert.equal(supports.length, 3, 'one quad per support');
// Sectors: [0..90], [90..180] (two quarter fans) and [180..360] reflex —
// skipped: the outside of the bar corner is legitimately empty.
assert.equal(fans.length, 2);
});
test('issue 302 a reflex sector closes with the outer mitre', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 30, half: 5 }, { deg: 100, half: 5 },
]));
const { fans } = junctionNodeGeometry(map);
// [0..30], [30..100] and the reflex [100..360]: the outer corner between
// the extreme rays closes with a BACKWARD mitre — the Y-60 notch of the
// owner's report lived exactly in a skipped reflex sector.
assert.equal(fans.length, 3);
// The reflex fan must be the four-point mitre, not the flat chord fallback:
// its apex sits deeper than either strip edge.
const node0 = map.nodes[0];
const reflexFan = fans.find((poly) => poly.some((point) => {
const dx = point[0] - node0.point[0], dy = point[1] - node0.point[1];
const deg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
// The apex of the backward mitre: strictly inside the reflex sector and
// deeper than a strip edge (a chord fallback never leaves the edges).
return deg > 110 && deg < 350 && Math.hypot(dx, dy) > node0.halfDepth * 1.05;
}));
assert.ok(reflexFan, 'the reflex sector fell back to the chord — no outer mitre');
const node = map.nodes[0];
for (const fan of fans) {
for (const point of fan) {
assert.ok(
Math.hypot(point[0] - node.point[0], point[1] - node.point[1])
<= MITRE_LIMIT * node.halfDepth + 1e-7,
`fan escaped the classic corner bound: ${point}`,
);
}
}
});
test('issue 302 a mitre past the short thick support degrades to a bevel', () => {
// The mitre point lies inside the node limit but PAST the 2-unit thick
// support of the 0° ray: accepting it would paint a lateral phantom beside
// the thin continuation (#271), so the pair must fall back to a bevel.
const map = nodeMapOf([
...starIntervals([{ deg: 90, half: 2.5 }, { deg: 210, half: 2.5 }]),
{ key: 'short-thick', a: [500, 500], b: [502, 500], half: 10, cm: 20,
kind: 'outer', open: false, roomId: 'rt' },
]);
assert.equal(map.nodes.length, 1);
const { fans } = junctionNodeGeometry(map);
// The [0°..90°] fan: mitre would sit at (502.5, 510) — inside the limit
// (10.3 < 12.5) but past the thick support (t = 2.5 > 2) → five-point bevel.
const fan = fans.find((poly) => poly.length === 5
&& poly.some((point) => point[1] > 505));
assert.ok(fan, 'the mitre past the thick support was accepted instead of a bevel');
const bound = MITRE_LIMIT * Math.max(10, 2.5);
for (const point of fan) {
assert.ok(
Math.hypot(point[0] - 500, point[1] - 500) <= bound + 1e-7,
`fan escaped the classic corner bound: ${point}`,
);
}
});
test('issue 302 the hole detector is not blind: a deliberately holed body is red', () => {
const map = nodeMapOf(starIntervals([
{ deg: 0, half: 5 }, { deg: 120, half: 5 }, { deg: 240, half: 5 },
]));
// A body that covers only two of the three strips: the third is a hole.
const quad = (deg, half, len) => {
const rad = (deg * Math.PI) / 180;
const u = [Math.cos(rad), Math.sin(rad)];
const e = [-u[1] * half, u[0] * half];
return [[
[500 + e[0], 500 + e[1]],
[500 + u[0] * len + e[0], 500 + u[1] * len + e[1]],
[500 + u[0] * len - e[0], 500 + u[1] * len - e[1]],
[500 - e[0], 500 - e[1]],
[500 + e[0], 500 + e[1]],
]];
};
const holed = [quad(0, 5, 300), quad(120, 5, 300)];
const reports = junctionContractHoles(holed, map, { step: 2 });
assert.equal(reports.length, 1, 'the detector missed the missing strip');
assert.ok(reports[0].holes.length > 10);
const full = [quad(0, 5, 300), quad(120, 5, 300), quad(240, 5, 300),
...junctionNodeGeometry(map).fans.map((fan) => [[...fan, fan[0]]])];
assert.equal(junctionContractHoles(full, map, { step: 2 }).length, 0,
'the detector flags a body that covers the whole contract');
});
test('issue 302 the owner repro is hole-free end to end', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const geometry = wallBodiesGeometry(
rooms, fixture.walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.equal(geometry.status, 'ok');
const map = buildMultiWallNodeMap(
wallIntervals(rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W)
.filter((iv) => !iv.open && iv.half > 0),
pitch * NORM_W * 0.04 * 4, NORM_W,
);
assert.ok(map.nodes.length >= 2, 'the repro lost its multi-wall nodes');
const bound = junctionNodeBound(
rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, map,
);
const reports = junctionContractHoles(geometry.geom, map, {
step: GRID_PITCH * 0.2, bound,
});
assert.deepEqual(
reports.map((report) => ({ node: report.node, holes: report.holes.length })),
[],
'the владелец repro still has junction holes',
);
});
test('issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)', () => {
// The owner's repro corner: mitre at ~8.7 units — beyond the retired 1.25×h
// join limit (6.1) but well inside the classic MITRE_LIMIT bound. It must
// be a four-point mitre fan, not a bevel: this is the visible difference
// between decision #5 and the #249 look.
const map = nodeMapOf(starIntervals([
{ deg: 45, half: 4.861 }, { deg: 102.3, half: 3.472 }, { deg: 332.2, half: 3.472 },
]));
assert.equal(map.nodes.length, 1);
const { fans } = junctionNodeGeometry(map);
const sectorFan = fans.find((poly) => poly.length === 4 && poly.some((point) => {
const dx = point[0] - 500, dy = point[1] - 500;
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
return Math.hypot(dx, dy) > 6.2 && angle > 45 && angle < 103;
}));
assert.ok(sectorFan, 'the 57° sector fell back to a bevel — the 1.25×h limit is back');
});