Compare commits

...
Author SHA1 Message Date
Sergey Matyunin 50099acc75 fix: allow stable promotion of published beta history
Validate / provenance (push) Successful in 5m46s
Validate / process-gate (push) Failing after 5m56s
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 13m53s
Validate / backend (push) Failing after 10m41s
Validate / golden (push) Failing after 9m40s
Validate / performance_smoke (push) Failing after 17m26s
Validate / smoke (push) Failing after 34m54s
Full Performance / performance (push) Failing after 1h54m13s
Issue: #130
User-Visible: no
2026-08-13 22:59:23 +03:00
Sergey Matyunin a282f850af build: promote v1.63.0
Issue: #129
User-Visible: yes
2026-08-13 22:47:48 +03:00
Sergey Matyunin d7f3bb8119 test: accept v1.63.0-beta.2 golden baselines
Issue: #123
User-Visible: no
Release: v1.63.0-beta.2
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/31734606270
2026-08-13 22:26:38 +03:00
Sergey Matyunin 5c6ab8ea9b Release v1.63.0-beta.2 candidate
Issue: #123
User-Visible: yes
2026-08-13 22:11:02 +03:00
Sergey Matyunin fda4893f0c Merge updated dev for v1.63.0 2026-08-13 22:10:28 +03:00
Matysh 888e90450a perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 22:07:42 +03:00
Sergey Matyunin b2263a6551 Merge main into dev for v1.63.0 2026-08-13 22:05:35 +03:00
Matysh 565f518dcd perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:59:55 +03:00
Sergey Matyuninandclaude[bot] 02e0c9801d Fix corner split smoke geometry input
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] e93c405b13 docs: code review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] 955de3e69c Fix corner split exterior walls
Issue: #123
User-Visible: yes
2026-08-13 18:55:49 +00:00
claude[bot]andclaude[bot] 7af4146614 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Sergey Matyuninandclaude[bot] a43602934c Specify corner split wall geometry
Issue: #123
User-Visible: no
2026-08-13 18:55:49 +00:00
Matysh 516257e322 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:51:42 +03:00
Matysh 9177c9a944 perf: make review scope and ceremony fit the size of the task
The owner's report: the process works but every stage takes a long time even on
simple bugs. Two causes, and neither was the one that first comes to mind.

The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127
smoke files and a full golden capture — right for a task rated 10/10 for
complexity, absurd for a bug about a room divider. Full suites are the pre-beta
gate; the review now runs typecheck, unit and build always, and smokes, golden,
pytest or performance only where the diff and the AC call for them. The price of
narrowing it is honesty: the reviewer must list which gates it ran, which it did
not, and why, so a skipped gate is a visible decision rather than a silent one.

The reviewer also built its own environment out of model turns, with no npm cache
and no browser cache, paid for from the same forty-five minutes. The workflow now
installs dependencies and Chromium as ordinary cached steps, after switching to
the task branch so the lockfile is the branch's own.

Second, ceremony did not scale down. The light track makes a spec cheap; the new
trivial track does without one — S2-analysis straight to S5-ready, no spec review,
AC in the issue body. It is deliberately hard to qualify for: a bug on one surface,
no new UX contract, no migration, no i18n, no perf or touch effect, three checkable
AC at most, and expected behaviour already on record. Nothing left to decide is the
criterion that holds the whole thing up, and it cannot be met by feeling sure.

Code review is never skipped on either track. It is what stands in for testing
here, so it is the one stage speed may not buy.

Issue: #127
Issue: #128
User-Visible: no
2026-08-13 21:33:36 +03:00
Matysh 8a3f6efa0a fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:11:41 +03:00
Matysh be7d6b9706 fix: the review document is published even without a task branch
Issues labelled before the pipeline existed keep their spec straight in dev and
have no issue/NN branch. The publish step quietly exited zero for them, so the
verdict would arrive as a comment and the analysis behind it would be thrown
away — the fifth instance today of a step reporting success by doing nothing.

The document now goes wherever the spec itself lives: the task branch when there
is one, dev otherwise. Publishing also survives dev moving on while the review
ran, which takes up to forty-five minutes, by rebasing once before it gives up.

Four issues are waiting on this — #12, #30, #44 and #52 — each with a spec in dev,
a status label applied during the bulk pass in August and a review that never ran
because nothing was there to raise the event.

Issue: #114
User-Visible: no
2026-08-13 21:05:17 +03:00
Matysh 2fd042a7de feat: an outsider's issue is worked like any other once admitted
The guard refused to review any issue the owner had not filed himself. The rule
was meant to keep malformed outside reports out of the pipeline, but it checked at
every step instead of at the entrance, and it duplicated a guarantee the platform
already gives: only someone with write access can apply a label. Applying the
first status label is the owner's explicit decision, and it is the only place the
question belongs.

So the author check is gone. While an issue carries no status label it sits
outside the process and the invariants do not apply; once labelled, the task is in
flight and who filed it stops mattering.

The old rule also cost real work. On #123 an outside bug report had been analysed
and specified before the guard turned it away in nine seconds, and the remedy on
offer was to refile the same thing as the owner's own issue.

Issue: #114
User-Visible: no
2026-08-13 20:41:36 +03:00
Matysh d7e2c4d4f0 fix: the guard says why it refused, in the issue
A review label promises work. When the guard declined it wrote the reason to the
run log and nothing else, so the issue sat in a status nobody was acting on and
nobody could tell. #123 showed it: an outside reporter's issue was walked up to
S4-spec-review, the guard refused in nine seconds because only the owner's issues
enter the process, and the issue itself said not a word.

Refusals that a human can act on now become a comment: wrong author, blocked,
review-4. Only when a stage was actually recognised, so an unrelated label change
stays silent.

This is the same defect as the merge conflict that left the label untouched, seen
from the other side. The pattern is worth naming: doing nothing quietly is the
most expensive thing a pipeline can do.

Issue: #114
User-Visible: no
2026-08-13 20:30:15 +03:00
Matysh d1be6891b2 fix: a review run always moves the label, conflict or not
Validate / hacs (push) Failing after 55s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 6m16s
Validate / backend (push) Failing after 8m39s
Validate / provenance (push) Successful in 37s
Validate / golden (push) Failing after 8m4s
Validate / smoke (push) Failing after 27m15s
Validate / performance_smoke (push) Failing after 14m5s
Full Performance / performance (push) Failing after 1h15m11s
A green code review whose merge conflicted used to leave the label where it was.
That is a dead end: the author waits for the label to change, so it polled thirty
times and reported the limit as exhausted — on a task the reviewer had already
passed. The verdict existed and nobody could act on it.

The merge step no longer fails the job. It reports whether it merged, and a green
review that did not merge sends the task back to S6-in-progress, because the work
did return to the author — a rebase rather than a code fix, and the comment says
so and says the verdict still stands.

The invariant is now stronger and worth stating plainly: after a review run the
label always changes. A pipeline whose state can stall silently is worse than one
that reports the wrong state loudly.

Issue: #114
User-Visible: no
2026-08-13 16:58:18 +03:00
Matysh 9be81c1413 fix: repair the line continuation in the failure handler
The step that comments on the issue when a review run dies carried a literal
backslash instead of a line continuation, so gh received four arguments and
--repo ran as a command of its own. The handler for failures would itself have
failed, silently, and only when something had already gone wrong.

bash -n does not catch this: the syntax is valid, the meaning is not. Checking
run blocks now also means looking for a doubled backslash at end of line.

Issue: #114
User-Visible: no
2026-08-13 16:16:39 +03:00
Matysh fafeca4540 fix: count review cycles per stage, not across the whole issue
The guard counted every verdict comment on the issue, so a spec-review verdict
consumed a cycle from the code-review budget. On #89 the first code review came
out as r2/4. With two spec cycles the second code review would have hit review-4
after a single fix — the limit would have fired on a task nobody had reviewed
twice.

The stage is now resolved first and only its own verdicts are counted, recognised
by the review document named in the comment. If the document is missing the
verdict is not counted: undercounting grants an extra cycle, overcounting would
stop the work early, and of the two mistakes the recoverable one wins.

Issue: #114
User-Visible: no
2026-08-13 16:08:20 +03:00
Matysh 3ade633538 ci: merge into dev before setting S8-merged
The label asserts the code is in dev. The workflow used to set it on a green
code review while the commits were still only on the task branch, so between
the verdict and the author's merge the state machine stated something untrue —
which is exactly what happened on #104.

The merge now runs inside the pipeline, before the label. A conflict leaves
the issue in S7-code-review and comments instead.

Issue: #114
User-Visible: no
2026-08-13 13:20:26 +03:00
Matysh 68596a75a0 ci: the reviewer writes a review document to the task branch
PROCESS.md wants a review document in docs/reviews/; the CI reviewer could
only leave a comment, and flagged the gap itself. It may now write there.

What lands in the commit is decided by the workflow, not by the model: every
path outside docs/reviews/ is reverted before staging, and the commit carries
the usual trailers so the provenance gate accepts it.

Issue: #114
User-Visible: no
2026-08-13 13:04:26 +03:00
Matysh 65a86db122 fix(ci): repair the failure comment step
The multi-line --body started at column zero, which ends the YAML block
scalar. The parser silently truncated the run script and left an unclosed
double quote, so the whole workflow became unusable and blocked the code
review on #104.

The body now goes through a heredoc. Validating YAML alone did not catch
this; every run block is checked with bash -n from now on.

Issue: #114
User-Visible: no
2026-08-13 12:57:04 +03:00
Matysh a29df12e0b ci: raise the turn limit, bound the run by time instead
The r2 spec review on #104 produced a complete green verdict and then failed
on --max-turns 40 at turn 43, so the label step never ran and the transition
had to be reconciled by hand. Forty was a guess; a review that reads SCOPE,
AGENTS, PROCESS, the issue thread and the spec exceeds it routinely, and a
code review that also runs gates needs far more.

The real guard against a runaway run is the job timeout, not the turn count.

Issue: #114
User-Visible: no
2026-08-13 12:37:14 +03:00
Matysh 9146b4c357 ci: fix OIDC permission and review the issue branch
The first live run failed with "Could not fetch an OIDC token": the action
needs id-token: write to authenticate the GitHub App.

The reviewer also checked out dev, where the material under review does not
exist yet — specs and code are committed to issue/<NN>-slug. The job now
switches to that branch when it is pushed, and warns loudly when it is not.

Issue: #114
User-Visible: no
2026-08-13 12:02:31 +03:00
Matysh 9ad01be813 ci: event-driven process pipeline for spec and code review
Adds .github/workflows/process.yml. A status label change is the trigger:
S4-spec-review runs the spec review, S7-code-review runs the code review,
and the verdict decides the next label. Only a green verdict advances;
yellow and red return the task to its author. Cycle limits (4, or 2 on the
light track) are counted from the verdicts already posted on the issue.

Labels are moved with HP_PROCESS_TOKEN, not GITHUB_TOKEN, so the change
emits an event and the chain continues.

Issue: #114
User-Visible: no
2026-08-13 11:34:01 +03:00
56 changed files with 2380 additions and 702 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" $issues_flag >&2; then
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
+88 -12
View File
@@ -48,6 +48,7 @@ jobs:
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
@@ -58,8 +59,9 @@ jobs:
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком треке (PROCESS.md §4).
limit=4; [ "$SMALL" = "true" ] && limit=2
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
@@ -134,8 +136,15 @@ jobs:
fetch-depth: 0
ref: dev
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
@@ -156,6 +165,24 @@ jobs:
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Review
id: review
uses: anthropics/claude-code-action@v1
@@ -205,10 +232,38 @@ jobs:
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимостей в рабочей
копии нет: перед гейтами выполни `npm ci`. Проверь трейлеры Issue и
User-Visible, при User-Visible: yes — правки в оба changelog в том же
коммите.
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
@@ -249,14 +304,21 @@ jobs:
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "ветки задачи нет — документ некуда класть"; exit 0
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
git checkout -- . 2>/dev/null || true
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
echo "документ ревью не создан"; exit 0
echo "::warning::документ ревью не создан"
exit 0
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
@@ -266,9 +328,23 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$BRANCH"
echo "документ опубликован в $BRANCH"
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
echo "документ опубликован в $target"
- name: Решение по вердикту
id: decide
+1
View File
@@ -51,6 +51,7 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
+9
View File
@@ -41,6 +41,15 @@ of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
human-facing view synchronised from the labels, not the source of truth.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
step: while an issue carries no status label it is outside the process and the
+59 -4
View File
@@ -70,7 +70,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком треке 2
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
@@ -294,6 +295,42 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
@@ -431,6 +468,17 @@ npm run golden:verify # если менялся визуал
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
@@ -464,8 +512,8 @@ Project v2 остаётся человеческим представление
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `hotfix`, `process`,
`review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
@@ -560,7 +608,7 @@ Project v2 остаётся человеческим представление
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Две оговорки к проверке 8, обе выяснились при реализации.
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
@@ -573,6 +621,13 @@ Validate стартует от этого push и успевает прочит
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.63.0-beta.1"
VERSION = "1.63.0"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.63.0-beta.1"
"version": "1.63.0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

+25 -22
View File
@@ -1,29 +1,32 @@
{
"schema": 1,
"matrixVersion": 17,
"acceptedAt": "2026-08-13T14:30:05.929Z",
"sourceFingerprint": "66f31850eafc963848acdc4e56e363a364bbb9b189bf4ade73d3c111ae2fc375",
"matrixVersion": 18,
"acceptedAt": "2026-08-13T19:26:19.387Z",
"sourceFingerprint": "27fda3d75e4cda95b9d85a9481f15ccd44b96d8c9d3311e0476718d36e2588c5",
"chromium": "151.0.7922.34",
"scenarios": {
"isometric-geometry-view-dark": "6db601f322fe55e33a1a89fe62f5d3a7d53f12bac4a68c7911b12f6382ef4fca",
"isometric-geometry-view-light": "c4c005fa55f64280abf9fccccdf7dc8efc3497bd626572bc11f45cc3cddaa159",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "73939fa61bebe2254591a788fad455a026a5e3b7394eeff27d4fe9a905de7ad2",
"isometric-geometry-view-light": "fc62f6ee8935d2e0c57b4918bf5fe554056a5956747b53a752ae265349ce662e",
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "36c83bbe39809f346ebb5a0f4ed633c938e2fed028defaa48097c281db8e23a3",
"isometric-touch-kiosk-dark": "5eba7794e563e1ef5b9387184693c819976454d0efd222bd12b38aad8ea03e20",
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"geometry-plan-editor-dark": "19b5c84943b70074ba1aae51d1e54f9a59c44dde34fff89e32f0babb1e90a2ca",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "024ac666ac4dac01a36f7d1fdbf3bb41f76a627377f7c02dc373f429dfe96d43",
"tray-wide-tool-ru": "669fc1cf04433b936c0e9d05799ad19eda8bc003d5ab71937d0512c8f49ba921",
"tray-medium-group-en": "50cd3980b21f554bec2e76f4d2e8032f4bd30c0679477eb2f5cbe22f68935b7c",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
@@ -42,15 +45,15 @@
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "1957c1c797c7793fb8dcf592ca74c9f2e3eccfcf4bac1d0187482c055bebb93b",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 320 KiB

+29 -1
View File
@@ -1,5 +1,5 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
@@ -51,6 +51,34 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
const before = stage === 'before';
fixture.config.spaces.push({
id: scenario.space,
name: 'Corner Split',
rooms: before
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
: [
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
],
walls: before
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
],
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
+7 -1
View File
@@ -1,13 +1,19 @@
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 = 17;
export const GOLDEN_MATRIX_VERSION = 18;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
+146
View File
@@ -0,0 +1,146 @@
/** Issue #123: a Split ending at a room vertex must not reshape the facade. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
const update = async () => {
c._cfgEpoch++;
c._wallUnionCache = null;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
};
const bbox = (element) => {
const box = element?.getBBox?.();
return box ? [box.x, box.y, box.width, box.height] : null;
};
const sameBox = (a, b, epsilon = 0.01) => !!a && !!b
&& a.every((value, index) => Math.abs(value - b[index]) <= epsilon);
const a = [0.10, 0.10], tr = [0.90, 0.10], b = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const original = { id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, br, bl] };
const outerBefore = [
entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15),
];
const outerAfter = [
entry(a, tr, 15), entry(tr, b, 15), entry(b, br, 15),
entry(br, bl, 15), entry(bl, a, 15),
];
const splitRooms = [
{ id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, b] },
{ id: 'corner-fresh', name: 'Corner fresh', area: null, poly: [b, br, bl, a] },
];
const space = sp();
space.settings = { ...(space.settings || {}), show_borders: true };
space.rooms = [structuredClone(original)];
space.walls = structuredClone(outerBefore);
delete space.open_spans;
delete space.openings;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
c._setMode('plan');
await update();
const beforeWall = bbox(sr().querySelector('.wallbody'));
const beforePaper = bbox(sr().querySelector('.hp-paperg'));
out.beforeDrawn = !!beforeWall && !!beforePaper;
space.rooms = structuredClone(splitRooms);
const pathByThickness = {};
for (const cm of [0, 15, 100]) {
space.walls = [
...structuredClone(outerAfter),
...(cm ? [entry(a, b, cm)] : []),
];
await update();
const wall = sr().querySelector('.wallbody');
const paper = sr().querySelector('.hp-paperg');
out[`wall${cm}KeepsFacade`] = sameBox(bbox(wall), beforeWall);
out[`paper${cm}KeepsFacade`] = sameBox(bbox(paper), beforePaper);
pathByThickness[cm] = wall?.getAttribute('d') || '';
}
out.dividerChangesInterior = pathByThickness[15] !== pathByThickness[100]
&& pathByThickness[0] !== pathByThickness[15];
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
const planD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
const lightSpace = c._spaceModel();
const lightPolys = lightSpace.rooms
.filter((room) => Array.isArray(room.poly))
.map((room) => ({ r: room, poly: room.poly }));
const lightPhysical = c._physicalBodiesR(lightSpace);
const lightGeom = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
const lightPoints = lightGeom.flat(2);
const lightBox = lightPoints.length ? [
Math.min(...lightPoints.map((point) => point[0])),
Math.min(...lightPoints.map((point) => point[1])),
Math.max(...lightPoints.map((point) => point[0])) - Math.min(...lightPoints.map((point) => point[0])),
Math.max(...lightPoints.map((point) => point[1])) - Math.min(...lightPoints.map((point) => point[1])),
] : null;
out.lightUsesFacade = sameBox(lightBox, beforeWall);
c._setMode('view');
await update();
const viewD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
out.planViewParity = !!planD && viewD === planD;
const kioskBefore = c._config.kiosk;
c._config.kiosk = true;
await update();
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
dispatchEvent(new HashChangeEvent('hashchange'));
await c.updateComplete;
c._setProjection('iso');
await update();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().flat(2).length > 0;
c._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const cfg = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+186 -186
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -326,6 +326,20 @@ its types declare named exports — breaking either tsc or the runtime): merge a
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
bigger part keeps the room identity (name/area/devices).
`wallBodiesGeometry()` is the canonical physical masonry for flat full/static
rendering, hidden isometric projection and Glow/sun occlusion. Its exterior
shell is derived from the union of room centrelines plus the surviving `outer`
atomic intervals; internal/shared interval bodies are clipped to that union
before the shell is restored. Consequently a Split edge ending at an exterior
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. The full card retains the
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
cache guarded by a structural geometry fingerprint. This is computed render
state only: it never rewrites rooms or wall entries, and an HA state tick does
not rebuild topology.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
+22
View File
@@ -2,6 +2,28 @@
## Unreleased
## v1.63.0 — 2026-08-13
- Preserved explicit door, window and gate bindings when their standalone
sensor or lock marker is removed, and fixed the supported empty state after
deleting the last space
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Splitting a room from an existing corner no longer deforms the exterior
facade, including with thick dividers. Flat, static and hidden isometric
rendering use the same preserved wall geometry
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.2 — 2026-08-13
- Splitting a room from an existing corner no longer deforms the exterior wall
or pulls a thick internal divider through the facade. Plan, View, kiosk,
static cards, hidden isometric rendering and light obstacles now use the same
preserved exterior geometry, including already saved plans
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Small fixes and improvements.
## v1.63.0-beta.1 — 2026-08-13
- Deleting a standalone sensor or lock marker no longer breaks its explicit
+22
View File
@@ -8,6 +8,28 @@
## Unreleased
## v1.63.0 — 2026-08-13
- Сохранены явные привязки дверей, окон и ворот после удаления отдельного
маркера датчика или замка; исправлено предусмотренное пустое состояние после
удаления последнего пространства
([#104](https://github.com/Matysh/houseplan-card/issues/104),
[#111](https://github.com/Matysh/houseplan-card/issues/111)).
- Split из существующего угла комнаты больше не деформирует наружный фасад,
в том числе с толстым разделителем. Плоский, статичный и скрытый
изометрический рендер используют одну сохранённую геометрию стен
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.2 — 2026-08-13
- Split из существующего угла комнаты больше не деформирует наружную стену и
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
используют одну сохранённую наружную геометрию, в том числе для уже
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
- Мелкие исправления и улучшения.
## v1.63.0-beta.1 — 2026-08-13
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
+9 -7
View File
@@ -1,16 +1,18 @@
<!-- release: v1.63.0-beta.1 -->
<!-- release: v1.63.0 -->
## Основное
- Удаление маркера датчика или замка больше не разрывает его привязку к двери, окну или воротам.
- Исправлено падение после удаления последнего пространства; из пустого состояния снова можно добавить пространство.
- Удаление отдельного маркера больше не разрывает явную привязку двери, окна или ворот.
- После удаления последнего пространства интеграция остаётся рабочей и позволяет создать новое.
- Разделение комнаты из существующего угла больше не искажает наружный фасад даже при толстом разделителе.
- Мелкие исправления и улучшения.
## Highlights
- Deleting a sensor or lock marker no longer breaks its door, window or gate binding.
- Fixed the crash after deleting the last space; a space can again be added from the empty state.
- Removing a standalone marker no longer breaks an explicit door, window or gate binding.
- The integration remains usable after deleting the last space and allows a new one to be created.
- Splitting a room from an existing corner no longer deforms the exterior facade, even with a thick divider.
- Small fixes and improvements.
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0-beta.1/docs/CHANGELOG.md)
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.ru.md)
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.63.0/docs/CHANGELOG.md)
+2 -2
View File
@@ -21,8 +21,8 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). It also carries the reviewed process automation work #105 and #118–#121. |
| Version | **v1.63.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion candidate after published v1.63.0-beta.2 |
| Current local cycle | v1.63.0 promotes the published beta line without product-code changes. v1.63.0-beta.2 preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light; v1.63.0-beta.1 contains the empty-plan and opening-reference fixes (#111, #104), plus reviewed process automation #105 and #118–#121. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+3
View File
@@ -304,6 +304,9 @@ desktop: для точного рисования, Resize, модификато
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
- Большая по площади часть сохраняет исходную комнату и устройства.
- Для меньшей части открывается диалог новой комнаты.
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
только изнутри, даже если она толще наружной стены.
### Resize
+16 -2
View File
@@ -42,6 +42,17 @@ Every thick wall grows **half outward and half inward** from the polygon edge
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
outer walls. Paper and the content frame grow under that outer half.
The exterior silhouette is generated from the boolean union of room
centrelines and its surviving `outer` atomic intervals. A shared Split edge
therefore disappears before exterior mitres are built. When Split ends at an
existing corner, its divider is clipped to the interior side of this envelope:
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
divider thickness remains inside the facade. The same computed geometry is
used for the full/static/hidden-isometric renderers and light occlusion. The
paper and masonry paths come from one cached structural pass in flat renderers;
live HA state ticks do not repeat the boolean topology. Saved room and wall data
is not migrated or rewritten.
## 3. Body render
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
@@ -132,14 +143,17 @@ virtual-T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
room; nested-room tie; partially out-of-span legacy opening; overlapping
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
clipping and room-side colour ownership; whole and
atomic rekey after edge/scale.
atomic rekey after edge/scale; corner Split exterior equality across
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
opening corners; nav mode restores after `can_write`; a 1 cm body uses
solid-only in both full and static cards while a 20 cm body keeps its hatch;
door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`).
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
Plan/View/kiosk/static/isometric surfaces and the light barrier
(`demo/smoke_split_corner_wall.mjs`).
## 9. Independent partitions, drafts and columns
+250
View File
@@ -0,0 +1,250 @@
# CODE-REVIEW-123-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ:** `docs/specs/123-corner-split-wall.md` (зелёное ревью
`docs/reviews/SPEC-REVIEW-123-r1.md`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 4 коммита, релевантный для
продукта `47c6f10 Fix corner split exterior walls` (`Issue: #123`,
`User-Visible: yes`); `git diff origin/dev...HEAD` — 20 файлов
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`,
`test/wall-thickness.test.mjs`, `test/golden-matrix.test.mjs`,
`demo/golden/matrix.mjs`, `demo/golden/harness.mjs`,
`demo/smoke_split_corner_wall.mjs`, три bundle snapshot, документация)
- **Роль:** ревьюер кода (не исполнитель), этап `S7-code-review`
- **Цикл:** r1/4
## Скоуп ревью
По каждому AC1–AC13 (`docs/specs/123-corner-split-wall.md` §10) — доказан ли он
автотестом, который умеет падать, либо чтением кода. Дополнительно: гейты §8
PROCESS.md, трейлеры и changelog §10.1/§2.6, соответствие `docs/SCOPE.md` (J4/J6,
регрессия внутри уже принятой функциональности — расширения скоупа нет),
терминология `docs/USER-GUIDE.ru.md`, каноника `docs/WALL-THICKNESS.md` /
`docs/ARCHITECTURE.md`.
## Как проверялось
Окружение подготовлено `npm ci` (зависимости отсутствовали). Для нескольких
пунктов ниже дополнительно установлен `npx playwright install chromium` —
браузерные бинарники тоже отсутствовали, а без них нельзя было исполнить
браузерный смок и самостоятельно проверить AC7/AC8/AC9.
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `752/752` (см. `npm run inventory`), 0 fail |
| Build | `npm run build` | зелёный, `dist/houseplan-card.js` собран |
| Bundle sync | `cmp dist/… custom_components/houseplan/frontend/…` и `cmp dist/… demo/srv/assets/…` | оба совпадают побайтно; sha256 всех трёх = `182fb55a…483ff`, совпадает со значением из хендоффа |
| Process gate | `node scripts/process-gate.mjs` и `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` в обоих запусках (второй проверил и метку `S7-code-review` на #123 через `gh`) |
| Regression-can-fail | новый `test/wall-thickness.test.mjs` (тесты `corner Split …`, см. ниже) скопирован в чистый `git worktree` на `origin/dev` (SHA `8a3f6ef`, т.е. без фикса #123) и прогнан отдельно | `57/63 pass, 6 fail` — новые тесты `keeps the original exterior wall body and paper`, `preserves the facade for thin and thick outer/divider matrices`, `keeps unequal exterior arms…`, `Split with both endpoints at exterior vertices…`, а также существующий `paper with walls covers shared centreline…` реально падают на добаговом коде. Тест краснеет не тавтологически (см. AC2 в §10 ТЗ) |
| Browser smoke (новый) | `node demo/smoke_split_corner_wall.mjs` (после сборки и синхронизации `demo/srv/assets/houseplan-card.js`) | **crash**, см. находку High-1 |
| Browser smoke (независимая проверка того же сценария с исправленным вызовом) | автономный скрипт вне репозитория, тот же фикстур/сценарий из `demo/smoke_split_corner_wall.mjs`, но с исправленным вызовом `_lightBarriers(model, polys, physical)` вместо `_lightBarriers(model)` | все 14 проверок `true`, включая `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `lightUsesFacade`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig` — см. находку High-1 для интерпретации |
| Golden/performance/backend | не запускались | по `PROCESS.md` §8/§11.4 это пре-релизные гейты, не гейт код-ревью; `custom_components/houseplan/**/*.py` в диапазоне не менялся, backend вне скоупа |
## Находки
### High-1 — новый браузерный смок падает на первой же строке и не подтверждает ни один AC
**Файл:** `demo/smoke_split_corner_wall.mjs:87`, метод `src/houseplan-card.ts:13154-13155`
```js
// demo/smoke_split_corner_wall.mjs:87
const lightGeom = c._lightBarriers(c._spaceModel()).masonryGeometry;
```
```ts
// src/houseplan-card.ts:13154-13155
private _lightBarriers(
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[], physical: number[][][],
): { … }
```
`_lightBarriers` принимает три обязательных параметра; смок передаёт один. Все
корректные вызовы в самом продукте (`_renderGlowLayer`,
`src/houseplan-card.ts:13258-13261`) собирают `polys`/`physical` перед вызовом.
**Воспроизведение:** `npm run build`, синхронизировать
`demo/srv/assets/houseplan-card.js`, затем `node demo/smoke_split_corner_wall.mjs`.
Результат — необработанное исключение внутри `page.evaluate`:
```
browserType.launch: … (после `npx playwright install chromium`, если бинарник отсутствует)
page.evaluate: TypeError: e is not iterable
at wu._lightBarriers (…/houseplan-card.js:4710:3112)
at eval (…, <anonymous>:82:23)
```
Поскольку исключение бросается **внутри** `await page.evaluate(...)`, весь вызов
рушится целиком — `return out;` в конце скрипта никогда не выполняется. Ни одно
из 14 полей (`wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`,
`dividerChangesInterior`, `lightUsesFacade`, `planViewParity`, `kioskParity`,
`isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig`) не
попадает в `checkAll`/`finish` — процесс падает необработанным исключением
Node (`triggerUncaughtException`), минуя даже отчёт `FAILED (n)`.
Это единственный новый браузерный тест, который ТЗ называет доказательством
для **AC7** (`unit + smoke`, Glow/солнце видят тот же фасад) и **AC8**
(`smoke + golden`, паритет Plan/View/kiosk/`houseplan-space-card`/изометрии), а
`docs/WALL-THICKNESS.md` и оба changelog ссылаются на этот файл как на
подтверждение кросс-поверхностного паритета. В текущем виде файл не
подтверждает вообще ничего — не является «тестом, который умеет падать
содержательно», а падает по причине, не связанной с проверяемым инвариантом.
**Важно — это не признак ошибки в продуктовом коде.** Я independently
воспроизвёл тот же сценарий (тот же фикстур, тот же путь Split из вершины,
0/15/100 см) отдельным скриптом с исправленным вызовом
`_lightBarriers(model, polys, physical)`, зеркалящим построение `polys`/
`physical` из `_renderGlowLayer`. С исправленным вызовом все 14 проверок,
которые должен был выполнить смок, включая AC7 (свет использует тот же
фасад) и AC8 (Plan/View/kiosk/статичная карточка/изометрия рисуют идентичный
`d`-путь), возвращают `true`. Это подтверждает: сам фикс
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`)
работает корректно; дефект локализован в одной строке доставленного смока.
**Почему High, а не Low/Medium.** Дефект не является хрупкостью окружения
(в отличие от известного `smoke_opening_measure.mjs` из `AGENTS.md`) — это
неверная сигнатура вызова, которая на 100% воспроизводима и рушит весь файл
без остатка, включая проверки, не связанные со светом. Не пропустить в этот
раз дешевле, чем чинить по исключению §11.4 после пре-релизного гейта: доводы
исключения («часть проблем физически не может быть найдена раньше») здесь не
применимы — проблема обнаруживается прямо сейчас, при код-ревью, инструментами,
которые у ревьюера есть.
**Что нужно для зелёного вердикта:** починить вызов на строке 87 (по образцу
`_renderGlowLayer`), пересобрать бандл и приложить содержательный (не просто
безошибочный) вывод `node demo/smoke_split_corner_wall.mjs` — `OK` со всеми 14
полями `true`, не только отсутствие исключения.
## Что проверено и корректно
- **AC1–AC6, AC9, AC10 (`unit`):** новые тесты в `test/wall-thickness.test.mjs`
(`corner Split keeps the original exterior wall body and paper`,
`…clips every divider thickness when exterior walls are absent`,
`…preserves the facade for thin and thick outer/divider matrices`,
`…keeps unequal exterior arms and is order/id/winding independent`,
`Split from a concave vertex does not turn the child mitre into facade`,
`Split with both endpoints at exterior vertices preserves both corners`,
`…clean floors are exactly the room union minus canonical walls`,
`…rendering does not materialize or mutate saved geometry`) буквально
покрывают матрицу AC3/AC4 (толщины разделителя 0/1/15/100, наружные 1/15/100,
выпуклая/вогнутая вершина, оба endpoint, reverse winding/permutation) и
сравнивают не bbox, а boolean-разность геометрии до/после — именно то, что
требует §11.1 ТЗ. Существующий регрессионный набор (partial shared wall,
virtual-T, nested room, split materialisation и т.д., AC10) остался зелёным:
`752/752`.
- **Тест умеет падать содержательно, не тавтологически** (§18 PROCESS.md):
прогон тех же новых тестов на добаговом `origin/dev` (`8a3f6ef`) даёт
`6 fail / 57 pass` с содержательными сообщениями (`expected 0, got
7240.3…`, `wall geometry missing for outer=15, divider=100`), а не падение
из-за отсутствующего экспорта — тесты действительно проверяют устранённый
дефект, а не самосогласованность нового кода.
- **AC7/AC8/AC9 по существу** (не по доставленному смоку, см. High-1):
подтверждено самостоятельным прогоном исправленной копии сценария — общая
каноническая геометрия (`wallBodiesGeometry`/`wallBodiesUnionPath`)
действительно используется светом (`_lightBarriers`), Plan, View, kiosk,
`houseplan-space-card` (статичный рендер через `renderSpaceStatic` в
`src/space-render.ts`) и скрытой изометрией (`_isoSource` в
`src/houseplan-card.ts:4421-4451` строит геометрию тем же
`wallBodiesGeometry(...).geom`, `src/iso-walls.ts` не менялся и не завёл
вторую модель) — все дают идентичный путь/bbox для фикстуры из issue.
- **AC11 (ревью кода):** кэширование на месте и не задевает hot path. Полный
рендер кэширует пару `{d, paperD}` в `this._wallUnionCache`, ключ —
`${space}|${cfgEpoch}|rooms.length}` (`src/houseplan-card.ts:9660-9679`), т.е.
HA state tick без структурных изменений не пересчитывает topology.
Статичная карточка (`src/space-render.ts:38-56`) добавляет `WeakMap`-кэш по
тому же `cfg`-объекту с ключом `contentFingerprint({rooms, walls, extras,
cellCm})` — сервер отдаёт тот же неизменяемый объект конфигурации на
каждый tick, так что фингерпринт не пересчитывает boolean-топологию заново.
Проверено чтением, не исполнением — `performance_smoke`/large-house
benchmark — пре-релизный гейт (см. таблицу выше), а не гейт код-ревью.
- **AC12:** typecheck/test/build зелёные, три bundle snapshot побайтно
идентичны (см. таблицу гейтов), sha256 совпадает со значением из хендоффа.
- **AC13 и документация:** `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` описывают
исправление как сохранение фасада при Split, без переименования инструмента
или нового 3D-контракта; правки в **обоих** changelog находятся в **том же**
коммите `47c6f10`, что и поведение (`git show --stat 47c6f10`). Терминология
`docs/USER-GUIDE.ru.md` («Split», «общая стена», «фасад») совпадает с
таблицей инструментов в этом же документе. `docs/WALL-THICKNESS.md` и
`docs/ARCHITECTURE.md` описывают именно тот механизм (`exteriorEnvelopeGeometry`
+ surviving `outer` intervals), который реализован в
`src/wall-thickness.ts:1209-1318` — не изобретённая задним числом
формулировка.
- **Трейлеры и процесс:** `node scripts/process-gate.mjs` и
`node scripts/process-gate.mjs --issues` оба зелёные без предупреждений —
ветка `issue/123-corner-split-wall`, трейлеры `Issue: #123` на всех 4
коммитах, `User-Visible: yes` только на коммите, меняющем поведение, и
ровно в нём правки обоих changelog. Метка `S7-code-review` — единственная
статусная на issue.
- **Реакция на Low-1 ревью ТЗ:** формулировка доказательства AC11 в
`docs/specs/123-corner-split-wall.md` изменена с `(performance + ревью
кода)` на `(ревью кода)` с явной ссылкой на существующий
`performance_smoke`/benchmark — ровно та правка, которую предложил
`SPEC-REVIEW-123-r1.md`, без нового цикла ревью ТЗ.
- **Golden:** три новых сценария (`split-corner-wall-before-dark`, `…-thin-dark`,
`…-thick-dark`, `demo/golden/matrix.mjs`) построены на отдельном
`space: 'golden-corner-split'`, не пересекающемся ни с одним существующим id
в `demo/fixtures/visual-matrix.mjs`; `GOLDEN_MATRIX_VERSION` корректно
увеличен (17 → 18); `test/golden-matrix.test.mjs` проверяет форму фикстуры
(`show_borders`, число комнат, толщину разделителя по стадии) без
исполнения браузера. Эталоны не принимались — консистентно с §11.3/§13 ТЗ,
golden — пре-релизный гейт.
- **Данные и compatibility:** формат `RoomCfg`/`WallEntry` не изменён; поиск по
диапазону не нашёл новых `config key`, миграций или изменения schema
version — соответствует §8 ТЗ и `docs/CONFIG-COMPATIBILITY.md`.
- **Скоуп:** правки укладываются в заявленную поверхность
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`,
тесты, демо, документация); инструмент «Перегородка», модель `rooms`/`walls`/
`partitions`, снаппинг и диалог новой комнаты не тронуты — совпадает с §5
ТЗ (не-скоуп).
- **Дохлый код, замеченный по пути, но не блокирующий (Low-2, на решение
автора без нового цикла):** `paperRoomShapesWithWalls()`
(`src/wall-thickness.ts:1793`) остаётся экспортируемой и покрыта
`test/wall-thickness.test.mjs`, но после этого изменения её больше не
вызывает ни `_paperShapes` (`src/houseplan-card.ts`), ни `renderSpaceStatic`
(`src/space-render.ts`) — оба перешли на `wallBodiesUnionPath(...).paperD`.
Она не создаёт вторую копию геометрии (внутри вызывает тот же
`exteriorEnvelopeGeometry()`), поэтому архитектурный контракт §7.4 ТЗ не
нарушен — это просто более не используемый продуктом публичный экспорт.
- **Low-3 (на решение автора без нового цикла):** отказ от старого
nonzero-fill fallback в `wallBodiesUnionPath` (при отказе boolean-операции
функция теперь возвращает `null`, то есть стены/бумага не рисуются вовсе,
вместо прежнего искажённого, но видимого рендера) — намеренное решение,
прямо предписанное §7.7 ТЗ и названное риском в хендоффе автора. Не нашёл
отдельного unit-теста, который бы гонял именно этот путь (`malformed input →
null`) — вся текущая матрица тестов проходит через успешные boolean-операции.
Не блокирует: поведение явно специфицировано и осознанно, но следующей
правкой стоит закрыть тестом.
## Чего не проверял
- Golden capture/verify (`npm run golden:verify`) и `performance_smoke` —
осознанно, это пре-релизные гейты по `PROCESS.md` §8/§11.4, не гейт
код-ревью; их будущий провал (если случится) чинится по §11.4 без нового
код-ревью.
- Backend (`tests_backend`) — `custom_components/houseplan/**/*.py` не входит в
диапазон, backend вне скоупа ТЗ.
- Точность самого boolean-алгоритма (`polyclip-ts`) на произвольных
реально-сложных этажах за пределами матрицы фикстур ТЗ — проверены ровно те
конфигурации, что перечислены в §11.1 ТЗ и AC3/AC4; более широкий fuzz по
случайным полигонам не проводился и не был частью ТЗ.
- Полный автоматический паритет Plan/View/kiosk/static/iso для реального
large-house fixture (`demo/fixtures/large-house.mjs`) — проверено только на
фикстуре issue (прямоугольник 900×800 с наружными стенами 15 см); эта же
фикстура используется во всех новых unit- и golden-сценариях, так что это
совпадает с заявленным скоупом ТЗ, а не с недосмотром ревью.
## Вердикт
Красный. High: 1, Medium: 0. Единственная блокирующая находка — сломанный
`demo/smoke_split_corner_wall.mjs` (неверная сигнатура вызова
`_lightBarriers`), который не подтверждает ни один из AC7/AC8/AC9, хотя ТЗ и
документация ссылаются на него как на доказательство. Сам продуктовый фикс
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`)
подтверждён: 752/752 unit, новые регрессионные тесты содержательно падают на
добаговом коде, а независимый прогон исправленной копии смок-сценария
подтверждает AC7–AC9 напрямую. Возврат в `S6-in-progress` нужен только для
починки одной строки смока и приложения его содержательного (не просто
безошибочного) вывода — повторный код-ревью после этого обязателен
(`PROCESS.md` §2.6: ребейз/правка гейта не освобождает от повторного ревью,
если правка не является чисто «упавший пре-релизный гейт» по §11.4, а этот
дефект найден на этапе код-ревью, не пре-релиза).
+165
View File
@@ -0,0 +1,165 @@
# SPEC-REVIEW-123-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ под ревью:** `docs/specs/123-corner-split-wall.md` (коммит `ba56d4f`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`) — оценка сложности 7/10, больше одной поверхности,
визуальная и световая геометрия; лёгкий трек корректно не применён
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы) и §12 (запреты);
- `AGENTS.md` — классы файлов, ветка, легитимность приёма чужого issue в процесс;
- каноническим документам затронутой подсистемы: `docs/WALL-THICKNESS.md`,
`docs/SUN.md`, `docs/LIGHT.md`, `docs/ISOMETRIC.md`, `docs/TOUCH-SUPPORT.md`,
`docs/CONFIG-COMPATIBILITY.md`;
- `docs/USER-GUIDE.ru.md` — терминология «Split» / «Перегородка»;
- фактическому состоянию кода (`src/wall-thickness.ts`, `src/iso-walls.ts`,
`src/space-render.ts`, `test/wall-thickness.test.mjs`) — на предмет того, что
технические утверждения ТЗ не являются непроверенной догадкой.
## Как проверялось
1. Прочитан весь тред issue #123, включая решение владельца о приёме чужого
issue в процесс (после правки конвейера, коммит `024cdc0`, issue #114) и
протокол аналитики с defaults Q1–Q3, принятыми владельцем 2026-08-13
(комментарий https://github.com/Matysh/houseplan-card/issues/123#issuecomment-5283843252).
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — см. таблицу ниже.
3. Прочитан код `wallBodiesGeometry()` (`src/wall-thickness.ts:1361-1412`):
подтверждено, что тело стены строится как per-room `outset(poly, half) −
inset(poly, half)`, затем `union` по комнатам — именно механизм, который ТЗ
называет причиной дефекта (диагональный Split из вершины вносит острые митры
дочерних комнат в наружный union).
4. Прочитан `src/iso-walls.ts` и `docs/ISOMETRIC.md` — подтверждено, что скрытая
изометрия уже потребляет тот же `wallBodiesGeometry()` MultiPolygon, а не
отдельную модель; утверждение ТЗ §6.4 о единой геометрии для Plan/View/
`houseplan-space-card`/изометрии не является новым архитектурным изобретением
автора, а фиксирует уже существующий контракт.
5. Прочитан `docs/LIGHT.md` («Opaque: the wall bodies exactly as the plan draws
them (`wallBodiesGeometry`)») — подтверждает AC7 (Glow/солнце используют то
же исправленное preграждение) технически достижимым без отдельной правки
light-барьеров.
6. Прочитан `docs/TOUCH-SUPPORT.md` — формулировка «safety floor» и «pointer
cancellation» в ТЗ §9 дословно соответствует канону, а не придумана.
7. Прочитан `docs/USER-GUIDE.ru.md` (таблица инструментов, разделы «Split» и
«Перегородка») — терминология ТЗ совпадает с пользовательским словарём,
различие Split/Перегородка воспроизведено верно и явно вынесено в не-скоуп
(п.5.4).
8. Прочитан `test/wall-thickness.test.mjs` — регрессионные сценарии, которые ТЗ
в §11.1 п.7 требует не сломать (partial shared wall, virtual-T mitre, nested
room, 45° wall, split materialisation), реально существуют в файле, то есть
план автотестов не ссылается на несуществующее покрытие.
9. Проверено, что `docs/ARCHITECTURE.md`, `docs/STATUS.md`,
`docs/CHANGELOG(.ru).md` существуют — release-артефакты в §13 указывают на
реальные файлы.
10. Проверена запись в `docs/specs/README.md` — строка на #123 добавлена в том
же коммите, ссылка issue ↔ ТЗ двусторонняя.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после (без терминов реализации) | ✅ | §1, одна фраза |
| Проблема | ✅ | §2, с воспроизведёнными числами bbox на `948f284` |
| Скоуп / не-скоуп | ✅ | §4 / §5 |
| Контракт поведения | ✅ | §6 |
| UX | ✅ | §9 |
| Модель данных и миграция | ✅ | §8 |
| i18n | ✅ | §9 (пусто, обосновано) |
| AC1…ACn с доказательством | ✅ | §10, 13 штук, каждый с типом |
| План автотестов | ✅ | §11 |
| Риски | ✅ | §14 |
| Откат | ✅ | §15 |
| Release-артефакты | ✅ | §13 |
Все обязательные разделы присутствуют и содержательны, не формальные заглушки.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — тип доказательства AC11 не входит буквально в перечень §2.5
**Файл:** `docs/specs/123-corner-split-wall.md:293-295`
AC11 помечен `(performance + ревью кода)`. DoR (`PROCESS.md` §2.5) перечисляет
допустимые типы доказательства как `unit` / `backend` / `smoke` / `golden` /
«ревью кода»; литерала `performance` в этом перечне нет. По существу критерий
всё равно доказуем: в ТЗ явно указано «ревью кода» вторым типом, а
`performance_smoke`/large-house benchmark — существующие release-blocking гейты
(§11.4 этого же ТЗ, `PROCESS.md` §8), а не новый вид проверки. Блокирующим не
является, но для чистоты трассируемости стоит переформулировать доказательство
AC11 как «ревью кода» с явной ссылкой на существующий `performance_smoke`/
large-house benchmark, не вводя пятый тип доказательства.
**Решение ревьюера:** Low, не блокирует. Можно поправить формулировку при
следующей правке ТЗ или снять с этой записью — оставляю на усмотрение автора,
т.к. критерий по сути проверяем и не создаёт риска для DoR.
## Что проверено и корректно
- Легитимность приёма issue в процесс (чужой автор, но явно допущен владельцем
после правки конвейера #114) — не относится к дефектам ТЗ, отдельно
зафиксировано в треде issue самим владельцем.
- Соответствие `docs/SCOPE.md`: задача закрывает J6 («Keep the plan true as the
home evolves») и частично J4 (встроенный редактор без искажений архитектуры),
обе строки в статусе «Closed» — это регрессионный баг внутри уже принятой
функциональности, а не новая фича и не расширение скоупа.
- Владелец лично принял defaults Q1–Q3 и приоритет P2 (комментарии
2026-08-13T16:52 и 17:00) — открытых продуктовых вопросов в финальной
редакции ТЗ нет, и это корректно: вопросы были заданы и закрыты на этапе
аналитики, а не додуманы автором.
- Технический диагноз причины (per-room `outset−inset` union, острые митры
дочерних комнат Split входят в наружный силуэт) подтверждён чтением
`src/wall-thickness.ts` — не голословное утверждение автора.
- Раздел 16 «Принятые технические предположения» корректно отделяет свободно
изменяемые технические решения (имена helper'ов, конкретная boolean-
декомпозиция, имя golden/smoke сценария) от решений владельца Q1–Q3,
которые пересмотру не подлежат — ни одна догадка не выдана за факт без
пометки.
- Не найдено ни одного утверждения о поведении, которое не следует ни из
канонических документов, ни из принятых владельцем defaults, ни из чтения
существующего кода, и при этом не помечено как предположение.
- AC1–AC13 однозначны, у каждого указан тип доказательства и он входит (кроме
Low-1) в допустимый по DoR список; план автотестов (§11) даёт конкретный,
проверяемый маршрут для каждого, включая явное требование «тест из п.3
обязан краснеть на `948f284`» — критерий, защищающий от неспособного падать
теста.
- Не-скоуп (§5) корректно отсекает смежные соблазны (не превращать Split в
Перегородку, не трогать инструмент «Перегородка», не менять модель данных,
не вводить новый UX для cap/join) — типичные места, где скоуп мог бы незаметно
расшириться.
- Release-артефакты (§13) перечисляют реальные файлы, включая
`docs/WALL-THICKNESS.md` (exterior/shared junction invariant) и
`docs/USER-GUIDE.ru.md` — корректная точка правки терминологии для
пользователя.
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
двусторонняя (`PROCESS.md` §7.1).
## Чего не проверял
- Не проверял, что предложенная в §7 архитектурная декомпозиция (exterior
envelope vs shared divider body) реализуема без регрессии в
`polyclip-ts`-based boolean операциях — это по правилам ТЗ (§16 п.2)
свободно изменяемое техническое предположение автора кода, не предмет
ревью ТЗ.
- Не проверял производительность реального large-house benchmark — AC11
предполагает существующий гейт, а не новый, и это станет предметом ревью
кода/пре-релизного гейта, не ревью ТЗ.
- Не запускал никаких автотестов — на этапе `spec` это не требуется; проверка
существования регрессионных сценариев (см. «Как проверялось», п.8) сделана
чтением файла, не исполнением.
- Не проверял корректность конкретных числовых bbox-диагностик из §2 —
доверяю записи владельца/автора в треде issue как источнику числа, поскольку
оно уже независимо зафиксировано в комментарии аналитики до написания ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Одна находка Low (AC11 формулировка типа
доказательства) — не блокирует, оставлена автору на усмотрение с записью в этом
документе (не «TODO», а фиксированное решение ревьюера: можно поправить или
отклонить без нового цикла).
+422
View File
@@ -0,0 +1,422 @@
# Issue #123 — Split из вершины не меняет наружную геометрию стен
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
только метками issue
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 7/10; ценность для разработки 8/10;
сложность и риск 7/10
- **Область:** Split в «Редакторе плана», наружные и общие толстые стены,
полный и статический рендеры, скрытая изометрия, чистая площадь, Glow и солнце
- **Модель данных:** без изменений и миграции
- **Связано:** `docs/WALL-THICKNESS.md`, `docs/CANVAS.md`,
`docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md`, `docs/ARCHITECTURE.md`
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома, который поддерживает архитектуру плана в
desktop-браузере.
**Поверхность и момент:** в «Редакторе плана» пользователь выбирает Split,
указывает комнату и начинает разрез точно из существующего угла комнаты. После
создания второй комнаты он при необходимости задаёт новой общей стене толщину.
**До → после, без терминов реализации:** сейчас разделение комнаты из угла
деформирует наружную стену и может вытянуть из фасада большой зуб; после
исправления фасад выглядит ровно как до разделения, а новая внутренняя стена
аккуратно примыкает к нему изнутри.
Задача поддерживает:
- **J4:** встроенный редактор должен позволять построить правдивый план без
внешних инструментов;
- **J6:** последующее изменение комнат не должно искажать уже созданную
архитектуру;
- **J1/J2/J3:** View, состояния и действия остаются на той же пространственной
модели, а не на отдельной исправленной только для редактора картинке.
## 2. Проблема и подтверждённая причина
`splitRoomPath()` правильно делит исходный полигон: площади двух частей дают
площадь исходной комнаты. Сохранение исходных интервалов толщины вокруг новых
дочерних рёбер также уже покрыто тестами.
Ошибка появляется позже, при построении физического тела стены.
`wallBodiesGeometry()` создаёт для каждой комнаты отдельное кольцо
`outset(room) − inset(room)`, затем объединяет кольца. Split из вершины заменяет
один исходный угол двумя углами дочерних комнат. Общая диагональная стена входит
в оба новых контура, поэтому их митры ошибочно становятся частью наружного
силуэта дома:
- даже при нулевой толщине разделителя bbox кладки отличается от исходного;
- при ненулевой толщине острый митр вытягивается наружу заметным зубом;
- нарисованная кладка одновременно используется как препятствие, поэтому это
не только косметический дефект редактора.
Диагностика на `dev` SHA `948f284` для прямоугольной комнаты с наружными
стенами 15 см:
| Состояние | bbox кладки |
|---|---|
| До Split | `[93.75, 93.75, 906.25, 706.25]` |
| Split из вершины, разделитель 0 см | `[92.9167, 93.75, 906.25, 706.25]` |
| Тот же разделитель 15 см | `[83.4702, 86.1502, 906.25, 706.25]` |
CSS-скругление обычного room border не исправляет физическое тело стены,
чистую площадь или световые препятствия. Превращение Split в независимую
`Перегородку` тоже неверно: Split обязан сохранить две комнаты, две возможные
HA-зоны и настоящую общую границу.
## 3. Решения владельца
Владелец принял defaults Q1–Q3 и приоритет P2 13.08.2026. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/123#issuecomment-5283843252
1. Наружный силуэт и наружная грань остаются такими же, как до Split, при любой
допустимой толщине внутренней стены. Общая стена заканчивается у внутренней
грани наружной кладки и не выступает за фасад.
2. Исправленная геометрия едина для Plan, View/киоска,
`houseplan-space-card`, скрытой изометрии, чистой площади, Glow и солнца.
3. Существующие планы исправляются вычисляемо сразу после обновления, без
миграции и перезаписи конфигурации.
## 4. Скоуп
В задачу входят:
1. любой валидный Split, у которого хотя бы один endpoint после действующего
wall-snap совпадает с вершиной исходной комнаты;
2. случаи, где endpoint-вершина выпуклая или вогнутая и где вершинами являются
один либо оба конца разреза;
3. нулевая и любая допустимая толщина новой общей стены, включая толщину больше
толщины примыкающей наружной стены;
4. нулевая, одинаковая и различная толщина двух наружных рёбер у вершины;
5. сохранение исходного внешнего силуэта, наружной грани и настоящего внешнего
угла;
6. чистое внутреннее примыкание без щели пола, наружного зуба или лишнего
митра дочерней комнаты;
7. единая физическая геометрия полного и статического рендеров, скрытой
изометрии, чистого пола и препятствий Glow/солнца;
8. автоматическое исправление уже сохранённых планов на чтении/рендере;
9. unit, browser smoke, visual golden, документация и RU/EN changelog.
## 5. Не входит в задачу
- запрет или предупреждение для Split из угла;
- изменение выбора комнаты, snapping, маршрута кликов, диалога новой комнаты,
правил имени/HA-зоны или выбора большей части;
- изменение модели `rooms`, `walls`, `open_spans`, `partitions` или
`wall_columns`;
- превращение общей стены Split в независимую `Перегородку`;
- изменение поведения инструмента `Перегородка`;
- новый тип стыка, пользовательская настройка cap/join или новые i18n-тексты;
- общая переработка всех пересечений стен, которые не воспроизводят дефект
endpoint-вершины;
- изменение дверей, окон, ворот, виртуальных границ или их конфигурации;
- миграция, schema version, backend и import/export;
- свободная 3D-геометрия или отдельная модель для изометрии.
## 6. Контракт поведения
### 6.1. Split остаётся Split
После подтверждения диалога:
- создаются две комнаты по действующим правилам `splitRoomPath()`;
- их площади по центровым контурам дают площадь исходной комнаты в пределах
действующего epsilon;
- большая часть сохраняет id, имя, HA-зону и устройства исходной комнаты;
- меньшая часть получает новый id и данные из диалога;
- линия разреза является общей производной границей комнат, а не записью в
`partitions[]`;
- Undo/Redo и сохранение работают как сейчас.
### 6.2. Наружная кладка
Пусть `before` — физический внешний контур комнаты непосредственно перед
Split, а `after` — внешний контур объединения получившихся комнат при тех же
наружных интервалах толщины.
Для endpoint в вершине:
1. `after` не содержит кладки снаружи `before`;
2. существующая наружная кладка не исчезает и не получает щель;
3. настоящий внешний угол и его bevel/mitre остаются такими же, как до Split;
4. искусственный угол дочерней комнаты между внешним ребром и линией Split не
участвует в формировании фасада;
5. правило действует и при нулевой наружной толщине: внутренняя стена
обрезается по границе пола и не выступает наружу;
6. разная толщина двух наружных плеч сохраняется без усреднения или
выравнивания.
Численное сравнение использует единый геометрический epsilon; визуально
различимый зуб, щель или ступень не может быть оправдан погрешностью.
### 6.3. Внутренняя общая стена
- При `cm > 0` тело общей стены заканчивается у внутренней грани наружной
кладки. Внутри пола оно сохраняет полную заданную толщину.
- При `cm = 0` Split остаётся общей осевой границей, но не меняет тело
примыкающих наружных стен.
- При толщине разделителя больше наружной лишняя ширина остаётся внутри дома;
она не расширяет фасад.
- Примыкание не оставляет между стенами участок чистого пола и не создаёт
двойную непрозрачность/штриховку.
- Обычный endpoint в середине стены и существующие L/T/virtual junctions не
меняют нынешний контракт.
### 6.4. Все поверхности видят одну геометрию
Исправленный результат обязан быть общим для:
- Plan editor;
- View и киоска;
- `houseplan-space-card`;
- скрытого изометрического Labs-режима;
- paper/room fill и чистой площади;
- физических препятствий Glow и солнечных лучей.
Запрещено исправить только SVG полного card renderer отдельной маской: нарисованная
и физическая кладка снова разойдутся.
### 6.5. Существующие планы
План с уже сохранёнными дочерними полигонами и интервалами толщины:
- отображается правильно после обновления без открытия редактора;
- не получает новый config key;
- не вызывает скрытое сохранение или оптимизацию;
- при простом открытии/рендере сохраняет конфигурацию побайтно;
- остаётся совместимым с предыдущей версией при откате.
## 7. Архитектурный контракт реализации
Конкретные helper names и разбиение файлов являются техническим выбором автора,
но должны соблюдаться следующие границы:
1. Фасадная кладка определяется exterior envelope объединения комнат и
`outer` atomic intervals, а не острыми углами каждого дочернего room ring.
2. `shared` interval Split строится как внутренняя физическая стена и
ограничивается внутренней стороной exterior envelope до объединения тел.
3. Настоящие внешние углы продолжают использовать действующий
mitre/bevel-контракт и `MITRE_LIMIT`; искусственный child corner на endpoint
общей стены не считается внешним углом.
4. Один канонический результат wall-body geometry потребляют drawing,
clean-floor projection и light/sun occlusion. Отдельные исправленные копии
геометрии по render surface запрещены.
5. Расчёт детерминирован относительно порядка комнат, их id и winding.
6. Исправление не мутирует `rooms`, `walls`, `open_spans` и не материализует
config при чтении.
7. Boolean/fallback path также соблюдает exterior invariant; при ошибке
операции нельзя молча вернуться к известной геометрии с наружным зубом.
8. Новые вычисления входят в существующий geometry fingerprint/cache и не
выполняются заново на каждом HA state tick.
Предполагаемые файлы реализации:
- `src/wall-thickness.ts`;
- при необходимости `src/iso-walls.ts`, только для потребления общей
исправленной геометрии без второй модели;
- `test/wall-thickness.test.mjs` и при необходимости `test/logic.test.mjs`;
- новый узкий `demo/smoke_split_corner_wall.mjs` либо эквивалент;
- golden scenario/baseline по правилам review;
- документы из раздела 13.
## 8. Модель данных, compatibility и миграция
Форматы не меняются:
```ts
interface RoomCfg {
poly?: number[][];
}
interface WallEntry {
key: string;
cm: number;
a?: number[];
b?: number[];
}
```
- новых полей и compatibility aliases нет;
- legacy midpoint-only wall keys остаются читаемыми;
- materialisation/normalisation при явном редактировании сохраняет текущий
контракт;
- schema version и backend validation не меняются;
- прямой и обратной миграции нет.
## 9. UX, i18n, accessibility и touch
Новых controls, диалогов, текстов, фокуса или keyboard semantics нет. Поэтому
новые i18n-ключи не требуются.
Plan editor остаётся desktop-first. Touch editor — **best effort**, но safety
floor обязателен: Split на touch не может сохранить другую геометрию из-за
pointer cancellation или второго касания.
View и киоск полностью поддерживаются: исправленный фасад, room fills и
световые препятствия должны совпадать с desktop. `prefers-reduced-motion` не
затрагивается.
## 10. Критерии приёмки
- **AC1 (`unit`):** Split из вершины по-прежнему создаёт точное разбиение:
площади частей суммируются в исходную, большая часть сохраняет identity,
линия разреза не появляется в `partitions[]`.
- **AC2 (`unit`):** для прямоугольника из воспроизведения внешний wall-body
после Split при разделителе 0 см геометрически совпадает с исходным фасадом;
текущая bbox-регрессия отсутствует.
- **AC3 (`unit`):** тот же инвариант выполняется при толщине разделителя 1, 15
и 100 см и при наружной толщине 0, 15 и 100 см; никакая точка кладки не
выступает за допустимый исходный exterior envelope.
- **AC4 (`unit`):** матрица включает острый и тупой угол разреза, один и два
endpoint-угла, выпуклую и вогнутую вершину, а также разные толщины двух
наружных плеч. Результат не зависит от room order, id и winding.
- **AC5 (`unit` + `golden`):** внутренний разделитель примыкает к внутренней
грани наружной стены без щели, зуба, ступени и двойной штриховки; настоящая
форма exterior corner до/после визуально идентична.
- **AC6 (`unit`):** clean-floor geometry двух комнат учитывает внутреннюю стену
только внутри дома; суммарная потеря пола соответствует внутреннему телу
разделителя и не включает наружный spike.
- **AC7 (`unit` + `smoke`):** Glow и солнце используют то же исправленное
препятствие: свет не проходит через примыкание, но и не блокируется
несуществующей кладкой за фасадом.
- **AC8 (`smoke` + `golden`):** Plan, View/киоск и `houseplan-space-card`
показывают один фасад для fixture #123; скрытая изометрия не возвращает зуб и
не вводит вторую геометрию.
- **AC9 (`unit` + `smoke`):** сохранённые ранее room polygons и wall entries
исправляются без записи, миграции или изменения сериализованного config.
- **AC10 (`unit`):** обычный Split от середины стены, wall materialisation,
partial shared intervals, virtual-T mitre, openings и independent partitions
сохраняют действующее поведение.
- **AC11 (ревью кода):** новый exterior/shared расчёт использует существующее
geometry caching; HA state tick не пересчитывает topology. Существующие
`performance_smoke` и large-house benchmark остаются общим предрелизным
подтверждением, а не отдельным типом доказательства AC.
- **AC12 (`typecheck` + `unit` + `build`):** быстрые гейты зелёные; три bundle
snapshot побайтно совпадают.
- **AC13 (ревью документации):** RU/EN changelog и пользовательские документы
описывают исправление как сохранение фасада при Split, не как изменение
инструмента `Перегородка` или новый 3D-контракт.
## 11. План автотестов
### 11.1. Unit
1. Зафиксировать исходную комнату и её exterior wall-body geometry.
2. Выполнить `splitRoomPath()` из точной вершины к середине другого ребра,
materialise/normalise текущие wall intervals и применить толщину общей
стене.
3. Сравнивать не только bbox, а boolean difference exterior geometry до/после:
лишняя и потерянная фасадная площадь должны быть меньше epsilon.
4. Отдельно проверить полное покрытие внутреннего примыкания и отсутствие тела
разделителя снаружи исходного exterior envelope.
5. Повторить матрицу AC3/AC4, включая reverse winding и перестановку rooms.
6. Проверить clean-floor area и барьеры `wallBodiesGeometry()`.
7. Regression suite: partial shared wall, virtual T, nested room, opening cut,
materialisation после Split и independent body union.
8. Проверить отсутствие мутации входных rooms/walls и сериализованного fixture.
Тест из пункта 3 обязан краснеть на `948f284`, а не только подтверждать новое
вспомогательное вычисление.
### 11.2. Browser smoke
Один узкий сценарий на production bundle:
1. создать прямоугольную комнату с наружными стенами 15 см;
2. сохранить внешний SVG bbox/path signature;
3. выполнить пользовательский путь Split из угла и подтвердить новую комнату;
4. проверить 0 см, затем 15 см и 100 см общей стены;
5. переключить Plan → View, киоск, static card и Labs iso;
6. доказать одинаковый exterior bbox, отсутствие наружного зуба и наличие
внутренней стены;
7. проверить Undo/Redo и отсутствие скрытой config write;
8. поставить источник света/солнце у примыкания и подтвердить общий occluder.
По текущему процессу smoke добавляется при реализации, но запускается перед
бетой; в цикле реализации выполняются только typecheck, unit и build.
### 11.3. Golden
Добавить deterministic scenario `split-corner-wall` либо эквивалент с тремя
кадрами:
1. исходный внешний угол;
2. Split из угла с тонкой общей границей;
3. тот же Split с толстой общей стеной.
Кадр должен включать Plan и View либо паритетные full/static поверхности.
Baseline принимается только через `npm run golden:accept -- --reviewed` по
полному Linux CI artifact с обязательными trailers `Release:` и
`Baseline-Reviewed:`. Принятие ради зелёного CI запрещено.
### 11.4. Performance и backend
Backend не меняется. Отдельный backend gate не нужен.
Перед бетой выполняются общий `performance_smoke` и целевой large-house render
benchmark, если реализация меняет асимптотику wall topology. Отдельного нового
численного бюджета нет: действующие бюджеты и exact-SHA CI остаются
release-blocking.
## 12. Производительность и безопасность
**Производительность:** wall topology — cached structural input. Исправление не
должно переносить boolean union/difference в HA state hot path, создавать
отдельный расчёт для каждой render surface или обходить geometry fingerprint.
**Безопасность:** HA service calls, locks, permissions и destructive actions не
затрагиваются. Главный safety-риск здесь — расхождение нарисованного пола и
физического препятствия; единая canonical geometry обязательна.
## 13. Документация и release-артефакты
В том же user-visible implementation commit обновить:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — раздел Split/толстые стены: Split из вершины не
меняет фасад, внутренняя общая стена примыкает изнутри;
- `docs/WALL-THICKNESS.md` — exterior/shared junction invariant;
- `docs/ARCHITECTURE.md` — каноническое разделение exterior shell и shared
divider body без второй модели;
- `docs/STATUS.md` — текущая релизная линия после фактической реализации.
Visual change требует targeted golden из §11.3 и review полного Linux artifact.
Отдельного security artifact нет. Performance подтверждается §11.4. Issue
должна пройти опубликованную beta до stable release.
## 14. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Новый exterior shell сотрёт shared/nested wall | средняя / высокий | строить outer/shared отдельно; nested и partial regression unit |
| Разная толщина наружных плеч усреднится | средняя / средний | atomic interval matrix и точное сравнение фасада |
| Появится щель между divider и наружной стеной | средняя / высокий | coverage/difference unit плюс golden крупного угла |
| Drawing исправится, Glow/солнце останутся старыми | средняя / высокий | один canonical body и occlusion smoke |
| Room order/winding изменит boolean result | средняя / высокий | permutation/reverse-winding unit |
| Existing config перепишется при чтении | низкая / высокий | immutable fixture и browser no-write assertion |
| Geometry hot path станет дороже | средняя / средний | существующий fingerprint/cache и pre-beta performance |
| Исправление сломает обычные T/virtual/opening joins | средняя / высокий | полный целевой regression unit set |
## 15. Откат
Откат — revert implementation commit. Данные и schema не мигрируются, поэтому
планы остаются читаемыми. После отката вернётся прежний визуальный дефект, но
никакого восстановления конфигурации не потребуется. Feature flag и обратная
миграция не нужны.
## 16. Принятые технические предположения — можно менять без пересмотра продукта
1. Endpoint считается вершиной по уже существующему wall-snap/geometry epsilon;
отдельный пользовательский tolerance не вводится.
2. Предпочтительная реализация разделяет exterior shell и shared wall bodies,
но конкретная boolean decomposition может быть другой, если AC доказываются.
3. `wallBodiesGeometry()` остаётся canonical entry point; имя и внутренние
helpers можно менять.
4. Имя smoke/golden scenario не является частью продукта.
5. Новая настройка cap/join не нужна: поведение однозначно следует решениям
владельца.
6. Решения Q1–Q3 из раздела 3 не относятся к изменяемым предположениям.
+1
View File
@@ -77,6 +77,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) |
| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
## Правило актуализации
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.63.0-beta.1",
"version": "1.63.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.63.0-beta.1",
"version": "1.63.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.63.0-beta.1",
"version": "1.63.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+43 -4
View File
@@ -67,6 +67,12 @@ const CLASS_C = [
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
// Метки, при которых файла ТЗ в docs/specs/ быть не должно: на лёгком треке ТЗ
// живёт в теле issue (§5), на коротком — там же, и ревью ТЗ вообще не проводится
// (§5.1, issue #128). Офлайн эти случаи неотличимы от «ТЗ не написано», поэтому
// проверка 3 краснеет только когда метки прочитаны.
export const NO_SPEC_FILE = ['small', 'trivial'];
export const ALLOWED_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
export const STRICT_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review'];
@@ -235,12 +241,12 @@ export function checkSpecs(commits, specFiles, labelsOf = null) {
if (labels === null) {
out.push({
level: 'warn', rule: 3, sha: c.short,
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо только при метке small`,
msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо при метке small или trivial`,
});
} else if (!labels.includes('small')) {
} else if (!labels.some((l) => NO_SPEC_FILE.includes(l))) {
out.push({
level: 'fail', rule: 3, sha: c.short,
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки small на issue нет — код без ТЗ`,
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки ${NO_SPEC_FILE.join(' / ')} на issue нет — код без ТЗ`,
});
}
}
@@ -283,6 +289,22 @@ export function commitsUnderRuleOne(commits) {
);
}
export function isStableTarget(targetRef) {
return /^(?:refs\/heads\/)?main$/.test(targetRef ?? '');
}
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
// post-beta коммиты остаются в выборке и проверяются fail-closed как обычно.
export function commitsNeedingIssueStatus(
commits, { targetRef = '', isPublishedPrereleaseCommit = () => false } = {},
) {
const underRuleOne = commitsUnderRuleOne(commits);
if (!isStableTarget(targetRef)) return underRuleOne;
return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha));
}
// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не
// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен.
export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) {
@@ -376,6 +398,7 @@ function main(argv) {
const repo = value('repo', process.cwd());
const allowed = flag('no-merged') ? STRICT_STATUS : ALLOWED_STATUS;
const targetRef = value('target-ref', process.env.TARGET_REF ?? '');
let range = value('range');
if (!range && flag('github-range')) {
@@ -422,7 +445,23 @@ function main(argv) {
// проверки 3. Второй запрос по тому же issue — лишний сетевой вызов.
let labelsOf = null;
if (flag('issues')) {
const numbers = [...new Set(commitsUnderRuleOne(commits).flatMap((c) => c.issues).map((t) => t.slice(1)))];
const prereleaseTags = isStableTarget(targetRef)
? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) =>
/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-[0-9A-Za-z.-]+$/.test(tag))
: [];
const publishedCache = new Map();
const isPublishedPrereleaseCommit = (sha) => {
if (!publishedCache.has(sha)) {
publishedCache.set(sha, prereleaseTags.some((tag) =>
spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, `${tag}^{commit}`],
{ encoding: 'utf8' }).status === 0));
}
return publishedCache.get(sha);
};
const statusCommits = commitsNeedingIssueStatus(commits, {
targetRef, isPublishedPrereleaseCommit,
});
const numbers = [...new Set(statusCommits.flatMap((c) => c.issues).map((t) => t.slice(1)))];
const runner = ghRunner(process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh');
const cache = new Map();
const cached = (nn) => {
+37 -28
View File
@@ -55,7 +55,7 @@ import {
import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
@@ -208,7 +208,7 @@ import {
} from './opening-placement';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.63.0-beta.1';
const CARD_VERSION = '1.63.0';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
@@ -4432,7 +4432,7 @@ class HouseplanCard extends LitElement {
rooms: space.rooms, walls, openCuts, openings,
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
})}`;
return {
key,
@@ -9646,13 +9646,39 @@ class HouseplanCard extends LitElement {
}
/** Paper under rooms, grown by shared-wall half-thickness when set. */
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
private _paperShapes(rooms: any[]): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const walls = this._spaceWalls;
if (!walls.length) return paperRoomShapes(rooms);
const united = this._wallUnionGeometry();
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
}
/** Canonical paper + masonry geometry, cached by structural config epoch. */
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return null;
const openCuts = this._openPairs().flatMap((p) => p.segs);
return paperRoomShapesWithWalls(
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
return this._wallUnionCache.value;
}
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
@@ -10009,26 +10035,7 @@ class HouseplanCard extends LitElement {
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
const united = this._wallUnionCache.value;
const united = this._wallUnionGeometry();
if (!united) return svg`` as unknown as TemplateResult;
const stage = this._stageEl;
const v = this._viewOr(this._baseVb());
@@ -14026,7 +14033,9 @@ class HouseplanCard extends LitElement {
(styles.ts) is composited once for the whole sheet, so
adjacent rooms never cast seams onto each other's paper. */}
${this._wallHatchDefs(disp.color)}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd" pointer-events="none"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`}
+40 -9
View File
@@ -12,9 +12,7 @@ import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
} from './logic';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
import { wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry } from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
@@ -27,6 +25,7 @@ import {
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH,
@@ -35,6 +34,29 @@ import {
export { spaceModels } from './space-geometry';
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => StaticWallGeometry,
): StaticWallGeometry {
let spaces = staticWallGeometryCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticWallGeometryEntry>();
staticWallGeometryCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
const value = build();
spaces.set(spaceId, { fingerprint, value });
return value;
}
export interface StaticRenderOpts {
hass: any;
registry?: HaRegistrySnapshot;
@@ -306,12 +328,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const wallUnion = (walls.length || extras.length) && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
const needsCanonicalWallGeometry = !!(walls.length || (extras.length && disp.showBorders));
const wallGeometryFingerprint = needsCanonicalWallGeometry
? contentFingerprint({ rooms: space.rooms, walls, extras, cellCm })
: '';
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
: null;
const paperShapes = walls.length && canonicalWallGeometry?.paperD
? [{ path: canonicalWallGeometry.paperD }]
: paperRoomShapes(space.rooms);
const wallUnion = disp.showBorders ? canonicalWallGeometry : null;
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
const wallStroke = disp.color || '#607d8b';
@@ -326,7 +355,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
</pattern>
</defs>` : nothing}
${paperShapes.map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
+177 -39
View File
@@ -6,8 +6,8 @@
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
* centreline.
*/
import { union, difference } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
export interface WallEntry {
key: string;
@@ -1206,6 +1206,113 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
interface ExteriorEnvelopeGeometry {
/** Union of room centrelines. Shared Split edges disappear from this shape. */
centre: any;
/** Wall shell generated only from the surviving exterior boundary. */
shell: any;
}
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
function geometryRings(geom: any): number[][][] {
const out: number[][][] = [];
for (const polygon of Array.isArray(geom) ? geom : []) {
if (!Array.isArray(polygon)) continue;
for (const raw of polygon) {
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
if (ring.length >= 3) out.push(ring);
}
}
return out;
}
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
const dx = b[0] - a[0], dy = b[1] - a[1];
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
const len2 = dx * dx + dy * dy;
return dot >= -eps && dot <= len2 + eps;
}
/**
* Split a boolean-union boundary at every stored exterior interval endpoint.
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
* interval breakpoints is what preserves unequal wall depths on the two sides.
*/
function exteriorBoundaryProfile(
ring: number[][],
outer: WallInterval[],
eps: number,
): { poly: number[][]; offsets: number[] } | null {
const poly: number[][] = [];
const offsets: number[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i], b = ring[(i + 1) % ring.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > eps * eps)) continue;
const cuts = [0, 1];
for (const iv of outer) {
for (const p of [iv.a, iv.b]) {
if (!pointOnSegment(p, a, b, eps)) continue;
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t > eps && t < 1 - eps) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
for (let at = 0; at < unique.length - 1; at++) {
const t0 = unique[at], t1 = unique[at + 1];
const p = [a[0] + dx * t0, a[1] + dy * t0];
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
let half = 0;
for (const iv of outer) {
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
}
poly.push(p);
offsets.push(half);
}
}
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
}
/**
* Exterior masonry is derived from the union of room centrelines, not from
* each room independently. A Split edge therefore vanishes before mitres are
* built and cannot turn its artificial child corner into part of the facade.
*/
function exteriorEnvelopeGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): ExteriorEnvelopeGeometry | null {
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
if (!polys.length) return null;
let centre: any = union(closedRing(polys[0]) as any);
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
const outer = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
let shell: any = null;
for (const ring of geometryRings(centre)) {
const profile = exteriorBoundaryProfile(ring, outer, eps);
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(profile.poly, profile.offsets);
const inset = insetContour(profile.poly, profile.offsets);
if (!outset || !inset) continue;
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
shell = shell ? union(shell, piece) : piece;
}
return { centre, shell: shell || [] };
}
function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
@@ -1368,7 +1475,7 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; depthUnits: number } | null {
): { geom: any; paperGeom: any; depthUnits: number } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
@@ -1404,17 +1511,54 @@ export function wallBodiesGeometry(
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const paperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = roomRings.length ? bodyOf(roomRings[0]) : null;
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
let body: any = null;
for (const ring of roomRings) {
try {
const piece = bodyOf(ring);
body = body ? union(body, piece) : piece;
} catch {
// An acute child contour may be invalid for boolean subtraction. The
// interval pass below still supplies its physical wall without letting
// the artificial mitre back into the exterior envelope.
}
}
// Per-room rings preserve established L/T/nested joins. Atomic quads are
// also included so a rejected acute child ring cannot remove a divider or
// an interior half-wall. Clipping them to the centre union gives a hard
// facade boundary; the canonical exterior shell is added afterwards.
if (exterior) {
for (const edge of wallEdgeBodies(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
)) {
try {
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
body = body ? union(body, piece) : piece;
} catch {
// A valid per-room ring may already own this interval. If neither
// representation is usable the final result fails closed below.
}
}
}
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
for (const patch of junctions)
body = body ? union(body, closedRing(patch) as any) : closedRing(patch);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -1439,7 +1583,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], depthUnits: maxDepth };
return { geom: body || [], paperGeom, depthUnits: maxDepth };
} catch {
return null;
}
@@ -1457,31 +1601,19 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united) return null; // successful empty result: do not resurrect raw rings
// fall back to evenodd rings concatenated
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
if (!rings.length && !extraD) return null;
// `united` is null on this branch: a successful-but-empty union returned
// above and must not be resurrected by the raw-ring fallback.
let maxDepth = 0;
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
// Each room ring already reverses its inset. `nonzero` therefore keeps
// floors as holes while overlapping independent rings add instead of
// cancelling one another (the old even-odd fallback produced pinholes at
// exactly the complex junctions for which a fallback is needed).
return {
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
depthUnits: maxDepth,
fillRule: 'nonzero',
};
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
// a boolean failure would make malformed input violate the facade invariant.
return null;
}
/**
@@ -1666,22 +1798,28 @@ export function paperRoomShapesWithWalls(
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
const poly = roomPoly(r);
if (poly && poly.length >= 3) {
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const grown = pr && pr.offsets.some((o) => o > 0)
? outsetContour(pr.poly, pr.offsets)
: null;
const use = grown || poly;
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
if (!walls?.length) return paperRoomShapes(rooms);
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (exterior) {
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
} catch {
// Safe fallback below: exact room centrelines never reproduce the known
// exterior Split spike, even when boolean offsetting rejected bad input.
}
return out;
return paperRoomShapes(rooms);
}
interface OpeningWallEdge {
+20 -1
View File
@@ -71,7 +71,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount'])
'isometric-large-warm-remount', 'split-corner-wall'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -80,6 +80,25 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('corner Split golden captures before, thin and thick facade states', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.cornerSplitWall);
assert.deepEqual(scenarios.map((scenario) => scenario.cornerSplitWall), ['before', 'thin', 'thick']);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.equal(space.settings.show_borders, true);
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
if (scenario.cornerSplitWall !== 'before') {
const divider = space.walls.find((wall) => (
wall.a?.[0] === 0.10 && wall.a?.[1] === 0.10
&& wall.b?.[0] === 0.90 && wall.b?.[1] === 0.50
));
assert.equal(divider?.cm, scenario.cornerSplitWall === 'thin' ? 15 : 100);
}
}
});
test('filled opening golden has a pixel-level seam detector', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
assert.ok(scenario);
+33 -1
View File
@@ -14,6 +14,7 @@ import {
checkReviewDocLimit,
checkSpecs,
classify,
commitsNeedingIssueStatus,
commitsUnderRuleOne,
evaluateCommit,
makeCommit,
@@ -134,8 +135,9 @@ test('a class A commit without a spec warns offline and fails with labels', () =
assert.equal(offline[0].level, 'warn');
assert.equal(offline[0].rule, 3);
// С метками: small оправдывает отсутствие файла, его отсутствие — нет.
// С метками: small и trivial оправдывают отсутствие файла, их отсутствие — нет.
assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []);
assert.deepEqual(checkSpecs([c], [], () => ['trivial', 'S5-ready']), []);
const strict = checkSpecs([c], [], () => ['S5-ready']);
assert.equal(strict.length, 1);
assert.equal(strict[0].level, 'fail');
@@ -169,6 +171,36 @@ test('only class A/B commits are held to the issue status', () => {
assert.deepEqual(commitsUnderRuleOne([reviewDoc]), []);
});
test('stable promotion skips status recheck only for commits already published in a prerelease', () => {
const published = makeCommit({
sha: 'a'.repeat(40), subject: 'Fix shipped in beta', body: 'Issue: #123', files: ['src/a.ts'],
});
const postBeta = makeCommit({
sha: 'b'.repeat(40), subject: 'New promotion work', body: 'Issue: #130', files: ['scripts/a.mjs'],
});
const publishedShas = new Set([published.sha]);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/main',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[postBeta.sha],
);
assert.deepEqual(
rules(checkIssueStatuses(['130'], () => ({
ok: true, json: { state: 'CLOSED', labels: [] },
}))),
[8],
);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/dev',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[published.sha, postBeta.sha],
);
});
test('issue status check is fail closed when the source of truth is unreachable', () => {
// AC3: недоступный gh должен давать отказ, а не молчаливый пропуск.
const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
+176 -5
View File
@@ -6,7 +6,7 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -14,8 +14,10 @@ import {
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { difference, union } from 'polyclip-ts';
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
@@ -23,6 +25,59 @@ const closeTo = (got, want, tol = 1e-6) =>
const pitch = 1 / 240; // normalised grid step
const cellCm = 5;
const closedGeometry = (poly) => {
const ring = [...poly, poly[0]].map((point) => [...point]);
return [[ring]];
};
const geometryBounds = (geom) => {
const points = geom.flat(2);
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
};
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]],
outerCm = 15,
dividerCm = 15,
outerOverrides = [],
} = {}) {
const original = { id: 'source', poly: poly.map((point) => [...point]) };
const split = splitRoomPath(original.poly, path);
assert.ok(split, 'fixture must be a valid corner split');
let walls = outerCm > 0
? applyWallThicknessToNewRoom([], [original], original.id, outerCm, pitch)
: [];
for (const [a, b, cm] of outerOverrides)
walls = setWallThickness(walls, a, b, cm, pitch);
const before = walls.length
? wallBodiesGeometry([original], walls, [], [], pitch, cellCm, GRID_PITCH)
: null;
walls = materializeWallIntervals([original], walls, [], pitch, cellCm, GRID_PITCH);
const rooms = [
{ id: 'source', poly: split[0] },
{ id: 'fresh', poly: split[1] },
];
const divider = sharedBoundary(rooms[0].poly, rooms[1].poly);
assert.equal(divider.length, 1);
walls = setWallThickness(
walls, divider[0].slice(0, 2), divider[0].slice(2), dividerCm, pitch,
);
walls = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const after = wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH);
assert.ok(after, `wall geometry missing for outer=${outerCm}, divider=${dividerCm}`);
return { original, rooms, walls, before, after };
}
// ------------------------------- key ----------------------------------------
test('wallKey is the same from either end of the wall', () => {
@@ -687,6 +742,123 @@ test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () =
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
});
test('corner Split keeps the original exterior wall body and paper', () => {
const { original, rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before);
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(before.paperGeom, after.paperGeom), 0, 1e-7);
closeTo(geometryDifferenceArea(after.paperGeom, before.paperGeom), 0, 1e-7);
const paper = paperRoomShapesWithWalls(
rooms, walls, [], pitch, cellCm, GRID_PITCH,
);
assert.equal(paper.length, 1);
assert.ok('path' in paper[0]);
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
const paperPoints = [];
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(before.geom));
const canonical = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(canonical?.paperD, 'canonical render pass must include its paper path');
assert.equal(canonical.paperD, paper[0].path);
});
test('corner Split clips every divider thickness when exterior walls are absent', () => {
for (const dividerCm of [1, 15, 100]) {
const { original, after } = cornerSplitFixture({ outerCm: 0, dividerCm });
closeTo(geometryArea(difference(after.geom, closedGeometry(original.poly))), 0, 1e-7);
}
});
test('corner Split preserves the facade for thin and thick outer/divider matrices', () => {
for (const outerCm of [1, 15, 100]) {
for (const dividerCm of [0, 1, 15, 100]) {
const { original, before, after } = cornerSplitFixture({ outerCm, dividerCm });
assert.ok(before);
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
}
}
});
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
const fixture = cornerSplitFixture({
outerOverrides: [
[[100, 100], [900, 100], 5],
[[100, 700], [100, 100], 40],
],
dividerCm: 100,
});
const shuffled = fixture.rooms
.map((room, at) => ({ id: `renamed-${at}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
shuffled, fixture.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split from a concave vertex does not turn the child mitre into facade', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const fixture = cornerSplitFixture({ poly, path: [[600, 400], [900, 250]], dividerCm: 100 });
const centre = closedGeometry(poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split with both endpoints at exterior vertices preserves both corners', () => {
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('corner Split clean floors are exactly the room union minus canonical walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
));
assert.ok(floors.every(Boolean));
const actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
closeTo(geometryDifferenceArea(actual, expected), 0, 1e-7);
closeTo(geometryDifferenceArea(expected, actual), 0, 1e-7);
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const rooms = structuredClone(fixture.rooms);
const walls = structuredClone(fixture.walls);
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH));
assert.ok(paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, GRID_PITCH).length);
assert.equal(JSON.stringify({ rooms, walls }), before);
});
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
@@ -698,9 +870,8 @@ test('paper with walls covers shared centreline; without walls matches paperRoom
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 }];
const grown = paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, pitch);
assert.equal(grown.length, 2);
// grown polys are still present (strings)
assert.ok('poly' in grown[0]);
assert.equal(grown.length, 1);
assert.ok('path' in grown[0], 'wall-aware paper is one canonical union path');
});
test('area of the room polygon is unchanged by thickness helpers', () => {