Одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному:
шаг паттерна был константой в юнитах, а толщина стены переводится в юниты через
`cell_cm`, — значит число полос было пропорционально `1/cell_cm`. Разброс между
крайними масштабами достигал 25 раз, а при `cell_cm ≥ 10` в стену не попадало
и одной полосы: штриховка вырождалась в случайные штрихи или исчезала.
Шаг стал физической величиной: `wallHatchStepUnits(cellCm)` возвращает
`8 × (5 / cell_cm)` — это 9.6 см плана при любом масштабе сетки и ровно
исторические 8 юнитов при эталонном `cell_cm: 5`, так что старые планы не
двигаются. Толщина штриха следует за шагом, поэтому соотношение «штрих к
просвету» тоже перестало зависеть от масштаба.
Формула из описания issue (`cell_cm / 5`) не годилась: она увеличивает шаг там,
где стена и без того тонкая в юнитах, и разброс не исчезает, а растёт — 39
полос против 0.06 на краях диапазона. Множитель обратный; на эту ошибку
поставлен отдельный мутант `hatch-step-inverted`.
Зумовая компенсация `1/zoom` убрана по решению владельца (§4.2 ТЗ): стена,
которая меняет штриховку при зуме, — это тот же дефект, только по другой оси.
От каши на дальнем конце зума защищает второй порог `wallHatchNeedsSolid`
рядом с существующим `wallBodyNeedsSolid`; шаг клампится в [0.5, 80] юнитов,
чтобы патологический `cell_cm` не выродил паттерн.
Статический рендерер (`space-render.ts`) нёс собственную константу 8 и вообще
не знал про зум — то есть уже сегодня расходился с картой при любом зуме,
кроме единицы. Теперь оба читают шаг из одной функции; смок проверяет, что они
согласны между собой, а не только каждый сам с собой.
Два golden-эталона переснято осознанно (`golden:accept -- --reviewed`):
`large-house-zoom-040-dark` (шаг был 20 юнитов, стал 8) и
`large-house-zoom-250-dark` (был 3.2, стал 8). Расхождение осмотрено: меняется
только плотность штриховки тел стен, колонн и перегородок, геометрия и цвета
идентичны. Остальные 80 сцен не тронуты — `accept` переснимает весь набор, и
пять сцен, разошедшихся на шуме рендера, возвращены к прежним байтам вместе с
их хэшами в индексе.
Issue: #230
User-Visible: yes
Дефект High-1 был одинаковым в двух местах — и в живом рисовании, и в
«Оптимизировать планы», — потому что каждый вызывающий собирал геометрию
примыканий сам. Ревью r2 справедливо заметило, что и защита получилась
однобокой: юнит и мутант сторожили только оптимизатор, а путь карты — тот, где
дефект и был виден пользователю, — не сторожил никто. Заплатка в виде второго
мутанта-близнеца оставила бы причину на месте: два списка координат, которые
обязаны совпадать, но ничем не связаны.
Поэтому геометрия переехала в `spaceMergeGeometry(space, { excludeDraftId })`:
один источник комнат, колонн и концов черновиков, одни координаты, одно место,
где можно ошибиться. Оба вызывающих теперь строчка вызова.
Покрытие идёт за причиной, а не за симптомом: три юнита в
`test/wall-merge.test.mjs` проверяют масштаб полигонов (включая комнаты в форме
x/y/w/h и комнату без геометрии), исключение активного черновика и сам T-стык к
середине стороны комнаты. Мутанты `partition-merge-rescales-rooms` и
`chain-merge-sees-own-draft` перенацелены на общий модуль и теперь краснеют для
обоих путей сразу: 2 и 1 падение, проверено применением патча.
Сценарий с комнатой в смоке пробовал — не взлетел: рисование в комнату
поднимает `_offerWallFaces`, и цепочка не завершается штатно. Ломать смок под
тест не стал, юниты общего модуля покрывают оба пути честнее.
Issue: #229
User-Visible: no
**High-1.** Комнаты хранятся в тех же координатах, что и перегородки:
`roomPoly` отдаёт сырой полигон конфига. Обе обвязки делили его на `NORM_W`
ещё раз, комната уезжала в область ~0.0001, и `junctionAt` не находил ни
одного совпадения. Узел на T-стыке к середине стены комнаты — тот самый
случай, ради которого ТЗ прошло два раунда ревью, — молча исчезал.
Воспроизведено вызовом `optimizePlans`: `partitionsMerged === 1` там, где
ожидается 0.
**High-2.** Завершаемая цепочка к моменту слияния ещё лежит в `room_drafts`:
каждый клик персистит её через `_persistActiveDraftSegment`, а удаляется
черновик строкой ниже вызова слияния. Собственные концы цепочки считались
чужим примыканием, и стык с существующей стеной не срастался. Активный
черновик теперь исключается — ровно так же, как это делает
`plan-snap-overlay` (`activeDraftId`).
Дыры в тестах, которые это пропустили, закрыты по существу, а не заплаткой:
- `demo/smoke_wall_chain_merge.mjs` рисует продолжение реальными кликами через
`_markupClick`, а не присваиванием `_path`, — то есть исполняет тот путь, на
котором дефект и жил. Клики задаются в координатах плана и переводятся через
живой view box, иначе смок целится мимо только что нарисованной стены.
- `test/plan-optimizer.test.mjs` получил комнату с примыканием к середине
стороны: юниты модуля этого не ловили, потому что передают полигон уже в
согласованном масштабе, минуя обвязку.
- Мутанты `partition-merge-rescales-rooms` и `chain-merge-sees-own-draft`
сторожат оба места: проверены применением патча, 1 и 2 падения.
Issue: #229
User-Visible: no
Рисование прямой стены в несколько кликов оставляло по записи на каждый
отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок,
перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен
комнат этого давно нет — `normalizeWallIntervals` схлопывает каждый сплошной
участок одной толщины. Независимые перегородки жили по другому правилу.
Новый чистый модуль `src/wall-merge.ts` даёт им то же правило:
- `mergeCollinearPartitions` сращивает соседей одинаковой толщины и
направления до неподвижной точки, но только там, где узел никому не нужен.
Узел остаётся, если в него приходит третья перегородка, стена комнаты
(стороной, а не только вершиной), колонна или конец сохранённого черновика.
- Направление выжившей записи канонизируется лексикографически: иначе одна и
та же физическая стена выходила то a→b, то b→a в зависимости от порядка
входа, и каждый host.t вдоль неё переворачивался вместе с ней.
- `applyOpeningMoves` переносит проёмы на выжившую запись: и авторитетный
`host`, и legacy-проекцию `x/y/angle`, которую рисует старый читатель
конфига (docs/CONFIG-COMPATIBILITY.md, #132). Проекция здесь не кэш —
канонизация направления разворачивает угол на 180°.
Рисование сращивает только свою цепочку и то, чего она коснулась (§8.6 ТЗ):
молча править чужие швы в стороне оно не вправе — для этого есть
«Оптимизировать планы» с предпросмотром, отчётом и отменой. Оптимизация
проходит по всему пространству без seed-ограничения и отдельной строкой
сообщает, сколько записей исчезло.
Issue: #229
User-Visible: yes
Review CODE-REVIEW-220-r1.
H1: markersNeedingPlacement decided who depends on the order by reading
marker.area alone, while resolveExplicitMarkerPlacement reads
`marker.area || <area of the HA device>`. The ordinary marker — bind an HA
device, store neither field — is anchored by the registry and never depended
on the order, yet it was being written a space it never asked for. Dormant
today, and the day that HA area changes it moves the marker to whatever space
used to be first. The resolver now asks for the area actually in force.
M1: a mouse released past the panel left the gesture stuck, swallowing the
next click. Pointer capture is the usual answer and is now taken, but it is
not a guarantee — the browser grants it only for a live pointer. The window
listener is what actually closes the gesture.
M2: the fifth mutant from the spec is registered, plus a sixth for the stuck
drag above.
Writing the smoke for M1 turned up why the first attempt passed against
broken code: synthetic PointerEvents default to composed:false and never
leave the shadow root, so nothing outside the panel could ever hear them.
Real pointer events are composed; the smoke now says so.
Issue: #220
User-Visible: no
The order of config.spaces used to be whatever order the spaces were created
in, and there was no way back other than deleting a space and drawing it
again.
The gesture is deliberately narrow — mouse, editors only. The same tabs are
the primary way to switch spaces in View, where touch is first class, so a
drag there would compete with the tap that switches. Recorded in the spec as
"Touch editor: not exposed".
The part that needed care is not the drag. Position in the array feeds three
things: the marker placement fallback, the swipe neighbour and a positional
`floor`. So the write that stores the new order also writes down the
placement that used to depend on it: a marker with neither an explicit space
nor an area that names one gets the space it has right now. Both changes go in
one save; splitting them would leave a window in which markers move on their
own. The positional `floor` cannot be fixed from here, so the card says so
once.
Issue: #220
User-Visible: yes
Every push to dev paid for the full browser trio and the backend suite,
including commits that touch only documentation, workflows or process
scripts — the bundle and the harness were byte-identical, so the runs
proved nothing new. On 2026-08-19 alone that was roughly six pushes at
about seven minutes each.
The reuse key per heavy job is sourceFingerprint (src, demo fixtures,
golden scenarios, build manifests) plus that job's own harness: smoke
takes demo/smoke_*.mjs, golden takes demo/golden/** including baselines,
performance_smoke takes demo/performance/**, backend takes tests_backend
and the Python sources. A cache marker is written only by a successful run
of the same key, so a hit proves a job with identical inputs already
passed. scripts/** is deliberately outside every key: infrastructure work
edits it constantly and reuse would never fire.
This is not the path filter from the `changes` job, which stays disabled
on dev on purpose: there the scope is guessed from paths and "green" means
different things, here input equivalence is proven by a hash. And a
release candidate always bumps the version, which is part of the
fingerprint, so its keys are new by construction and the full gate set
still runs before every beta and release.
A waived job is announced with a notice and a run summary line rather than
skipped in silence, and the marker save tolerates a concurrent identical
run instead of reddening the job.
Issue: #208
User-Visible: no
Rule 8 demanded a working S-label from every class A/B commit's issue,
while owner decision #118 sends infrastructure work outside the S1..S8
flow entirely — such an issue has no status label by construction. The
two rules contradicted each other and the machine-checked one won, so
Validate on dev went red on every infrastructure commit (#175, #191,
#202, #206) and the catch-up signal stopped meaning anything. A gate that
is always red is not a gate.
The waiver keys on the diff, not on a permission label: a range with no
class A file at all. An `infra` label could be pinned on a product task
to walk a product commit past the status check; ceasing to touch class A
without ceasing to be infrastructure work is not possible. Issue
existence, open state, `blocked` and fail-closed on an unreachable gh all
still apply, and the waiver prints a visible warning rather than passing
in silence.
Mutation-checked both ways: unwiring the waiver reddens the CLI test,
and letting class A keep the waiver reddens both new tests.
Issue: #207
User-Visible: no
The old fixture ran the branch into the end of p1, so the T-patch lived
beyond the host (x>100) and the probe point [92,0] sat outside it under
any code behaviour: deleting the cutPartitionBody flatMap over patches
kept all subtests green (#188, found at the #132 code review).
The branch now meets the middle of the span, putting both node patches
inside the default opening's slot. The test proves its own fixture first:
an uncut run must show the patches bridging the slot, so if the geometry
ever stops producing them the control goes red instead of silently
devaluing the real assertion. Mutation-checked: reverting the flatMap
fails exactly this test, 888/889.
Issue: #188
User-Visible: no
After the mandatory rebase of a published issue branch the pre-push hook
still passes remote_old..local_new, and once the old tip is no longer an
ancestor that range drags in the whole advanced dev history: on #117 it
meant 84 foreign commits and 20 false rule-8 rejections over already
closed issues, leaving --no-verify as the only exit.
The clamp lives in the gate rather than the hook: .githooks/pre-push
carries an executable bit that MCP publication strips (the commit-msg
precedent), so editing it needs an owner-side commit. When the target is
an issue branch and the declared base is not an ancestor of the head, the
base becomes the merge-base with origin/dev. Fast-forward pushes keep
their exact range, every own commit is still judged, and a real violation
in a post-rebase commit still blocks — covered by a scenario test that
goes red without the wiring.
Issue: #190
User-Visible: no