Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95375e03d5 | ||
|
|
59ae6b1064 | ||
|
|
80a664bc23 | ||
|
|
508945c088 | ||
|
|
95fba6dbab | ||
|
|
550be251a6 | ||
|
|
0cdf85d9e2 | ||
|
|
58f3acbbde | ||
|
|
c231602a2b | ||
|
|
4423d28579 | ||
|
|
ebbfe4588f | ||
|
|
2d70354345 | ||
|
|
3de2a4a4b5 | ||
|
|
60e125e960 | ||
|
|
2d1ca74a7d | ||
|
|
d33aa0a7e5 | ||
|
|
90a3313d92 | ||
|
|
04bb54aef3 | ||
|
|
294d7047f3 | ||
|
|
85636a65bb | ||
|
|
ddfca3a865 | ||
|
|
4e8e00fa73 | ||
|
|
658c955553 | ||
|
|
7aaf5a72b0 | ||
|
|
5fb4ce328b | ||
|
|
c90f5bf052 | ||
|
|
92f476556f | ||
|
|
3f73eced95 | ||
|
|
802604e0c3 | ||
|
|
ccadb7779e | ||
|
|
eff786fafa | ||
|
|
c755a0efc4 | ||
|
|
d0d6cf57a7 | ||
|
|
6540474ff5 | ||
|
|
2d4ace1b58 | ||
|
|
0a203649fc | ||
|
|
60f41a53af | ||
|
|
21d47c52ba | ||
|
|
5d292831f3 | ||
|
|
fffe9fb1f7 | ||
|
|
aa9444491d | ||
|
|
361ec47856 | ||
|
|
cab8d128bf | ||
|
|
024866842c | ||
|
|
e04cf7e573 | ||
|
|
ff9ac33149 | ||
|
|
eeae6752d9 | ||
|
|
1866c0921e | ||
|
|
e63b03a0ee | ||
|
|
2b1964f973 | ||
|
|
4800c44e51 | ||
|
|
2c20f2dc35 | ||
|
|
2c4b505f1a | ||
|
|
273b0d5ecb | ||
|
|
f2c2084dd4 | ||
|
|
26c6e87079 | ||
|
|
0824e51014 | ||
|
|
574fea98ab | ||
|
|
8f0b6b97e8 | ||
|
|
0bb42caeff | ||
|
|
e1df015d93 | ||
|
|
7017896eb2 | ||
|
|
ad3f998147 | ||
|
|
8945e04fe4 | ||
|
|
7a74a37c6e | ||
|
|
2b1f03be2d | ||
|
|
f514fb27fe | ||
|
|
7d15bd06e9 | ||
|
|
002795f7c8 | ||
|
|
4758767e0c | ||
|
|
214355f424 | ||
|
|
6fc57f2ae8 | ||
|
|
806f7f784c | ||
|
|
3120924e26 | ||
|
|
8205897149 | ||
|
|
73b665ede6 | ||
|
|
d67bb11de0 | ||
|
|
5266d2f6be | ||
|
|
8161394178 | ||
|
|
118062bb8c | ||
|
|
8b0ad08db2 | ||
|
|
6efc315ba6 | ||
|
|
b87322ed5f | ||
|
|
c5e145269b | ||
|
|
2294d46f3f | ||
|
|
722f26a3c5 | ||
|
|
4a833c5e13 | ||
|
|
ba66698f55 | ||
|
|
ef32d2326f | ||
|
|
536b750d03 | ||
|
|
c46c714c58 | ||
|
|
6e1aea3ae7 | ||
|
|
a3e329950a | ||
|
|
78850a8733 | ||
|
|
1ac91e43fd | ||
|
|
6a3ac52258 | ||
|
|
1a8b480355 | ||
|
|
ec957a7c88 | ||
|
|
557c44c392 | ||
|
|
7181535dec | ||
|
|
fb162953a9 | ||
|
|
244fddeb3b | ||
|
|
ad73280b1b | ||
|
|
5ad0c1cdc3 | ||
|
|
b8976415ac | ||
|
|
296df95e32 | ||
|
|
acc30c2da7 | ||
|
|
ca383febef | ||
|
|
39eda1e136 | ||
|
|
caf6e6c355 | ||
|
|
4d9542b138 | ||
|
|
69e410fea3 | ||
|
|
1edf5cb3a7 | ||
|
|
9b7a700fd6 | ||
|
|
780b7a6c7a | ||
|
|
674607694f | ||
|
|
fb8fdf479c | ||
|
|
3b915bd077 | ||
|
|
69facb9918 | ||
|
|
343af61a61 | ||
|
|
59a23de81b | ||
|
|
87ff906545 | ||
|
|
298030d47e | ||
|
|
0c3aacdd34 | ||
|
|
134afb658e | ||
|
|
6a96254956 | ||
|
|
de7554e9e0 | ||
|
|
8210af6472 | ||
|
|
376a2db599 | ||
|
|
6b9f5eea96 | ||
|
|
59bf4665f6 | ||
|
|
3a562f2bd6 | ||
|
|
aaf7611490 | ||
|
|
3d4e5580fa | ||
|
|
1c598a287a | ||
|
|
05ec0a1de8 | ||
|
|
b29472a887 | ||
|
|
0f36ef55d5 | ||
|
|
1d92193c5d | ||
|
|
dff476ff35 | ||
|
|
ee672dcd1a | ||
|
|
ffb232398a | ||
|
|
33a5b46990 | ||
|
|
f353be3d76 | ||
|
|
295548f0fd | ||
|
|
0f6583b51c | ||
|
|
5886069c3a | ||
|
|
6205c8b2d7 | ||
|
|
b6de84e528 | ||
|
|
5b7d37e911 | ||
|
|
f3fe86371c | ||
|
|
3ab6fa9f8a | ||
|
|
3d31d65735 | ||
|
|
9ff464fe09 | ||
|
|
8bfd56eb59 | ||
|
|
26ad7651fb | ||
|
|
9134d86266 | ||
|
|
f15bdc678b | ||
|
|
db4e0ef999 | ||
|
|
a66fb7ef53 | ||
|
|
5e35612bdd | ||
|
|
fa9d9b05e9 | ||
|
|
916a42e33a | ||
|
|
799289e73b | ||
|
|
73cf8ed01a | ||
|
|
1857cb69cf | ||
|
|
1fcbb0de6d | ||
|
|
b8a9f9b289 | ||
|
|
c821dad436 | ||
|
|
1f6aefd2a7 | ||
|
|
7273a3ad9a | ||
|
|
b542f44bd6 | ||
|
|
43f91a214b | ||
|
|
822e6223f7 | ||
|
|
1cdd4ed6de | ||
|
|
a5abfe8bd6 | ||
|
|
83ec6fb879 | ||
|
|
2710dca7d1 | ||
|
|
114dcec27c | ||
|
|
93054823c3 | ||
|
|
2364d6dd43 | ||
|
|
64e9bf8f6b | ||
|
|
069c4867ea | ||
|
|
ce18012fb9 | ||
|
|
0e18038c1b | ||
|
|
2347e8df88 | ||
|
|
5da7812099 | ||
|
|
7a73bcbc2b | ||
|
|
98a0a24bd4 | ||
|
|
676610c0cd | ||
|
|
9726f2dfdb | ||
|
|
ccdd47a6bd | ||
|
|
ed126d6a43 | ||
|
|
d34e56ba78 | ||
|
|
5e5dad277c | ||
|
|
ffe0f0c788 | ||
|
|
a78deb7ce7 | ||
|
|
05622d155b | ||
|
|
b4cccfdf63 | ||
|
|
2f9348adf1 | ||
|
|
793a6486d8 | ||
|
|
b6c3c5ced3 | ||
|
|
d27c3f023e | ||
|
|
735b98c8ca | ||
|
|
944a36ee67 | ||
|
|
839e3316c3 | ||
|
|
61c401b810 | ||
|
|
e246d32580 | ||
|
|
35bb2b58b7 | ||
|
|
5fdedf39d6 | ||
|
|
41325a852f |
@@ -31,6 +31,16 @@ fi
|
||||
|
||||
status=0
|
||||
|
||||
# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на
|
||||
# каждый пуш, включая пуши одной строки документации, — цена, которую стоит
|
||||
# платить осознанно. Документация: docs/TESTING.md.
|
||||
if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
|
||||
echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2
|
||||
if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
# Deleting a remote branch pushes nothing to examine.
|
||||
if [ "$local_sha" = "$zero" ]; then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Announce release
|
||||
name: Анонс релиза
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
@@ -36,6 +36,7 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
name: Docs screenshots
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -45,6 +46,29 @@ jobs:
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
# oxipng без потерь снимает с набора ~19% (замер в #345 на pyoxipng 9.1.1;
|
||||
# у 10.2.0 пресеты уровней перебалансированы, точная доля может отличаться,
|
||||
# но кадры остаются пиксельно идентичными в любой версии — это перепаковка).
|
||||
#
|
||||
# Пин версии и контрольной суммы намеренно, а не `apt-get install oxipng`:
|
||||
# пакет из образа раннера может пропасть или переехать, а падение шага
|
||||
# съёмки стоит целого цикла приёмки. Тот же урок, что с azure-зеркалом
|
||||
# Playwright (#175, #206).
|
||||
- name: Установить oxipng
|
||||
env:
|
||||
OXIPNG_VERSION: 10.2.0
|
||||
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$asset" \
|
||||
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
|
||||
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
- name: Capture
|
||||
@@ -57,16 +81,30 @@ jobs:
|
||||
# разницу рендеринга.
|
||||
- name: Вердикт
|
||||
run: |
|
||||
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
|
||||
# свежего. Читается одинаково для браузера и для упаковщика: оба
|
||||
# переписывают все кадры сразу, и различить их причины обязан вердикт,
|
||||
# а не человек по памяти (#345).
|
||||
field() {
|
||||
node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).$1||'')}catch{console.log('')}})"
|
||||
}
|
||||
git status --porcelain docs/images
|
||||
changed=$(git diff --name-only docs/images | grep -c png || true)
|
||||
before=$(git show HEAD:docs/images/screenshots.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
|
||||
before=$(git show HEAD:docs/images/screenshots.json | field chromium)
|
||||
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
|
||||
packer_before=$(git show HEAD:docs/images/screenshots.json | field oxipng)
|
||||
packer_after=$(node -e "console.log(require('./docs/images/screenshots.json').oxipng || '')")
|
||||
echo "--- изменившихся PNG: $changed"
|
||||
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
|
||||
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
|
||||
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
|
||||
echo "--- oxipng: было «${packer_before:-не записан}», стало «${packer_after:-нет}»"
|
||||
if [ "$packer_before" != "$packer_after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: изменился упаковщик, поэтому переписаны все кадры сразу."
|
||||
echo "Это перепаковка без потерь: пиксели те же, размер меньше на ~19%."
|
||||
echo "Ожидаемо один раз — при включении oxipng либо при смене его версии."
|
||||
echo "Проверить можно сравнением декодированных кадров, а не байтов."
|
||||
elif [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: тот же браузер и тот же упаковщик, а картинки изменились —"
|
||||
echo "изменился продукт. Смотрите на кадры: если изменение ожидаемое, принимайте."
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
|
||||
echo "Это ожидаемо один раз — при переходе на канонический прогон."
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
name: Mutation gate
|
||||
name: Мутационный гейт
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
|
||||
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
|
||||
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
|
||||
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
|
||||
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -31,9 +37,14 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
|
||||
runs-on: ubuntu-latest
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
|
||||
# Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -69,5 +80,8 @@ jobs:
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Full Performance
|
||||
name: Полные бенчмарки производительности
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
@@ -24,10 +24,21 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
# Base and candidate stay sequential on one hosted runner. Splitting them
|
||||
# across runners would turn machine variance into a false regression.
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all five pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
@@ -149,38 +160,78 @@ jobs:
|
||||
npm --prefix candidate run build
|
||||
(cd candidate && node scripts/bundle-sync.mjs)
|
||||
npm --prefix baseline run build
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
if [ -f baseline/scripts/bundle-sync.mjs ]; then
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
else
|
||||
echo "::notice::The comparison base predates bundle-sync.mjs; materializing its freshly built bundle with the legacy copy path."
|
||||
mkdir -p baseline/custom_components/houseplan/frontend baseline/demo/srv/assets
|
||||
cp baseline/dist/houseplan-card.js baseline/custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
fi
|
||||
|
||||
- name: Enforce relative and absolute performance budgets
|
||||
- name: Capture base and candidate profile
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
mkdir -p ../artifacts/performance
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance reports
|
||||
- name: Enforce relative and absolute performance budget
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance
|
||||
name: full-performance-${{ matrix.profile }}
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Process
|
||||
name: Ревью-конвейер
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
@@ -35,6 +35,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
@@ -162,6 +163,7 @@ jobs:
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
@@ -287,6 +289,16 @@ jobs:
|
||||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||||
|
||||
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
|
||||
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
|
||||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
@@ -315,6 +327,41 @@ jobs:
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
# ревью тратится на повторение CI вместо чтения кода.
|
||||
#
|
||||
# Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не
|
||||
# «недавно было зелено», а «completed success ровно на этом SHA». После
|
||||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||||
- name: Зелёные гейты на этом SHA
|
||||
id: validated
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
sha=$(git rev-parse HEAD)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \
|
||||
--commit "$sha" --limit 5 \
|
||||
--json status,conclusion,url \
|
||||
--jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty')
|
||||
{
|
||||
echo 'note<<EOF_NOTE'
|
||||
if [ -n "$row" ]; then
|
||||
url=$(printf '%s' "$row" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))')
|
||||
echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url"
|
||||
echo ""
|
||||
echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода."
|
||||
echo ""
|
||||
echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи."
|
||||
else
|
||||
echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат."
|
||||
fi
|
||||
echo 'EOF_NOTE'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
@@ -441,8 +488,10 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
${{ steps.validated.outputs.note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
@@ -701,14 +750,42 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev
|
||||
git checkout -q -B merge-into-dev "origin/$BRANCH"
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish prerelease
|
||||
name: Публикация пре-релиза (ручная)
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка и релизный контракт"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -198,6 +200,7 @@ jobs:
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -245,6 +248,7 @@ jobs:
|
||||
}
|
||||
|
||||
announce:
|
||||
name: Комментарий о публикации
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Attach HACS zip to release
|
||||
name: HACS-zip к релизу
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
@@ -19,6 +19,7 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
name: Собрать houseplan.zip и приложить к релизу
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release
|
||||
name: "Релиз: ассеты после зелёной проверки"
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка точного SHA тега"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -42,8 +43,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -71,6 +73,7 @@ jobs:
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Validate
|
||||
name: Проверка (CI)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -21,25 +21,42 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
# Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и
|
||||
# setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job.
|
||||
#
|
||||
# Независимость при этом сохранена намеренно: у каждого шага
|
||||
# `continue-on-error`, поэтому упавший провенанс не прячет результат
|
||||
# процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя
|
||||
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
|
||||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||||
preflight:
|
||||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
# ревизий. Полный клон стоит 215 МБ против 26 МБ, и 32% этого веса —
|
||||
# скриншоты документации, переснятые 196 раз.
|
||||
#
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate public documentation
|
||||
|
||||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-docs.mjs --external
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
process-workflow-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: process.yml идентичен в main и dev
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
@@ -52,14 +69,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
id: provenance
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -70,19 +82,14 @@ jobs:
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
|
||||
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
|
||||
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
|
||||
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в
|
||||
# dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее
|
||||
# место, где нарушение правила №1 ловится машиной. Откат — удалить шаг,
|
||||
# скрипт остаётся рабочим.
|
||||
- name: "Процессный гейт: диапазон, трейлеры, статусы issue"
|
||||
id: process_gate
|
||||
continue-on-error: true
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -96,6 +103,33 @@ jobs:
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
|
||||
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
|
||||
# втором».
|
||||
- name: Вердикт предполётных проверок
|
||||
env:
|
||||
DOCS: ${{ steps.docs.outcome }}
|
||||
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
|
||||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
if [ "$2" = "success" ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "FAIL $1 ($2)"
|
||||
echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
@@ -104,14 +138,17 @@ jobs:
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -158,6 +195,7 @@ jobs:
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -237,6 +275,7 @@ jobs:
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
name: "HACS: валидация репозитория"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -248,6 +287,7 @@ jobs:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
name: "Hassfest: манифест интеграции"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -257,6 +297,7 @@ jobs:
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -276,8 +317,23 @@ jobs:
|
||||
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
|
||||
- name: Card bundle snapshots in sync
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||||
# --noEmit` плюс rollup, около минуты на job.
|
||||
#
|
||||
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
|
||||
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
|
||||
# дерева, и каждая браузерная job делает это перед первым кадром.
|
||||
- name: Выложить бандл для браузерных job
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist/houseplan-card.js
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
@@ -310,8 +366,16 @@ jobs:
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build a fresh bundle for the smokes
|
||||
run: npm run bundle:sync
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
@@ -350,6 +414,7 @@ jobs:
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -370,6 +435,7 @@ jobs:
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
@@ -396,8 +462,16 @@ jobs:
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run bundle:sync
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -432,6 +506,7 @@ jobs:
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
@@ -462,14 +537,26 @@ jobs:
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run bundle:sync
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Разложить бандл по копиям
|
||||
run: node scripts/bundle-sync.mjs
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -494,6 +581,7 @@ jobs:
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -40,10 +40,16 @@ task records: problem, scope, acceptance criteria and discussion.
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
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
|
||||
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
|
||||
issue #338). `small` — the spec lives in the issue body and its review is a
|
||||
comment. Analysis names the `small` criterion the task *fails* when it takes the
|
||||
full track; "ordinary track" without a named criterion is not a justification.
|
||||
The threshold itself did not move — only which side carries the proof. The full
|
||||
track stays what it was for geometry, config migrations and public contracts,
|
||||
where a criterion is broken plainly and saying which one is easy.
|
||||
|
||||
`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.
|
||||
@@ -274,7 +280,9 @@ the owner splits the task, rejects it, or arbitrates.
|
||||
|
||||
On the light track (`small`: complexity ≤3, one surface, no config migration, no
|
||||
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
|
||||
body and the spec review is a comment. Code review is never skipped.
|
||||
body and the spec review is a comment. Code review is never skipped. This track is
|
||||
the default: taking the full one means naming the criterion above that the task
|
||||
does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
|
||||
@@ -9,6 +9,35 @@ User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Translations
|
||||
|
||||
A shipped UI language has three matching parts:
|
||||
|
||||
1. `src/i18n/<code>.json` for the card;
|
||||
2. `custom_components/houseplan/translations/<code>.json` for the Home Assistant
|
||||
integration;
|
||||
3. one static entry (dictionary import, code and native label) in
|
||||
`src/i18n/registry.ts`.
|
||||
|
||||
Use the canonical Home Assistant/BCP 47 language tag as `<code>` (for example,
|
||||
`fr` or `pt-BR`) and use that exact spelling for both JSON filenames. Lookup is
|
||||
case-insensitive and also accepts `_` from legacy locale sources.
|
||||
|
||||
The registry drives language resolution, the visual-editor selector and parity
|
||||
tests. The tests reject missing or extra locale files; frontend dictionaries
|
||||
also fail on mismatched keys, empty values and changed placeholders.
|
||||
Placeholders such as `{name}` and `{n}` are a contract: do not translate, add
|
||||
or remove them.
|
||||
|
||||
The current `subst()` helper does not implement plural rules. Phrase strings so
|
||||
their grammar does not depend on the numeric value (for example, use a neutral
|
||||
label followed by `{n}` rather than an English singular/plural pair).
|
||||
|
||||
Adding a UI locale does not automatically create another full documentation
|
||||
set; maintain the existing English and Russian documentation according to the
|
||||
project's normal rules. Right-to-left layout is a separate product project,
|
||||
because the plan canvas and editors cannot be mirrored by translations alone.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
@@ -28,7 +57,7 @@ issue, but they do not replace it or maintain a separate checklist.
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
npm ci # frontend toolchain
|
||||
npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
@@ -37,6 +66,24 @@ pip install pytest voluptuous && python -m pytest tests_backend -q # pure back
|
||||
npm install # also installs .githooks through the prepare script
|
||||
```
|
||||
|
||||
### Why `--filter=blob:none` (#345)
|
||||
|
||||
A full clone is **215 MB of `.git`**; a blobless one is **26 MB** — measured, not
|
||||
estimated. Both carry all 1611 commits and all 182 tags, so ranges, `merge-base`
|
||||
and `git diff` across history work identically; `git diff origin/dev~3..origin/dev`
|
||||
in a blobless clone takes about a second and grows `.git` by one megabyte.
|
||||
|
||||
The difference is that historical *file contents* are fetched only if something
|
||||
actually asks for them. That matters here because 32% of the pack is documentation
|
||||
screenshots — ten PNGs re-captured 196 times — and another sizeable share is the
|
||||
committed bundle, one 1.16 MB file per product change. Almost nobody ever reads an
|
||||
old revision of either.
|
||||
|
||||
Drop the flag if you work offline with history, or need `git log -p` over the whole
|
||||
tree repeatedly. Do **not** replace it with `--depth=1`: a shallow clone is about
|
||||
the same size but has no `merge-base`, so the process gate, `smoke-select` and
|
||||
every `origin/dev..HEAD` range stop working.
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
|
||||
every push — locally they are skipped when `homeassistant` is not importable.
|
||||
@@ -45,8 +92,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
|
||||
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
|
||||
@@ -101,7 +101,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
|
||||
называется критерий §5, который она не проходит: «обычный трек» без
|
||||
названного критерия обоснованием не является.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
@@ -194,6 +196,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
|
||||
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
|
||||
зафиксированным в начале разбора: во время ревью в ветку может прилететь
|
||||
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
|
||||
SHA материала ревью (допустим только собственный doc-коммит публикации
|
||||
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
@@ -281,7 +289,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите.
|
||||
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
|
||||
from ...)` должна оставаться выше финального блока трейлеров: перед push
|
||||
проверяем порядок через `git show -s --format=full HEAD`.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
@@ -340,9 +350,26 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`)
|
||||
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
|
||||
|
||||
**Критерии — все одновременно:**
|
||||
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
|
||||
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
|
||||
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
|
||||
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
|
||||
отдельного цикла.
|
||||
|
||||
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
|
||||
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
|
||||
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
|
||||
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
|
||||
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
|
||||
нарушенный несложно.
|
||||
|
||||
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
|
||||
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
|
||||
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
|
||||
|
||||
**Критерии — все одновременно; нарушенный называется явно:**
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -88,10 +88,11 @@ The [Background editor contract](docs/DECOR-EDITOR.md) and
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. In HACS open **⋮ → Custom repositories**.
|
||||
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
|
||||
3. Install House Plan and restart Home Assistant.
|
||||
4. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
House Plan is in the HACS default catalog — no custom repository needed.
|
||||
|
||||
1. In HACS search for **House Plan** and install it.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -91,10 +91,12 @@ Assistant — и устройства появятся на плане авто
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. В HACS откройте **⋮ → Пользовательские репозитории**.
|
||||
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
|
||||
3. Установите House Plan и перезапустите Home Assistant.
|
||||
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
House Plan входит в основной каталог HACS — пользовательский репозиторий
|
||||
добавлять не нужно.
|
||||
|
||||
1. Найдите **House Plan** в поиске HACS и установите.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
|
||||
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.67.0-rc.3"
|
||||
VERSION = "1.68.1"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 8
|
||||
PLAN_MODEL_VERSION = 9
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
|
||||
@@ -77,6 +77,7 @@ _REPORT_EXAMPLE_LIMIT = 24
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
|
||||
"zero_wall_style",
|
||||
)
|
||||
_SPACE_DISPLAY_FIELDS = (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Issue #329 — wall junction limits, Python mirror of src/junction-limits.ts.
|
||||
|
||||
The card refuses a WRITE that would ADD a junction violation; this module is
|
||||
the backend half of that contract, so a stale or hostile client cannot post
|
||||
what the editor refuses. It mirrors П1–П4 exactly, including the two legal
|
||||
incidences of П4 (a shared node and a T-joint into the middle of a foreign
|
||||
wall) and the П3 rule that measures the collinear same-thickness WALL RUN
|
||||
rather than one catalogue atom.
|
||||
|
||||
П5 (the room keeps at least 25 cm² of interior) is deliberately NOT mirrored:
|
||||
it is a statement about the rendered wall bodies, and reproducing the mitre /
|
||||
inset pipeline in Python would be a second geometry implementation whose drift
|
||||
is a larger risk than the rule it guards. A document that violates only П5 is
|
||||
ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import logging
|
||||
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MIN_JUNCTION_ANGLE_DEG = 15.0
|
||||
MAX_JUNCTION_VALENCE = 6
|
||||
MIN_SEGMENT_LENGTH_CM = 20.0
|
||||
MIN_NODE_DISTANCE_CM = 5.0
|
||||
|
||||
GRID_STEP_N = 1 / 240
|
||||
|
||||
_EPS = 1e-9
|
||||
# #331 §2.1: below this two points are ONE node / a node is ON the wall —
|
||||
# floating debris of pre-canonicalisation arithmetic, not a near miss.
|
||||
_INCIDENT_EPS = 2e-7
|
||||
_KEY_FACTOR = 1e7
|
||||
|
||||
|
||||
class JunctionLimitError(ValueError):
|
||||
"""A write introduced a wall-junction violation (#329)."""
|
||||
|
||||
def __init__(self, space_id: str, rule: str, subject: str,
|
||||
actual: float, limit: float) -> None:
|
||||
self.space_id = space_id
|
||||
self.rule = rule
|
||||
self.subject = subject
|
||||
self.actual = actual
|
||||
self.limit = limit
|
||||
self.code = f"junction_limit_{rule}"
|
||||
super().__init__(
|
||||
f"space={space_id}; rule={rule}; subject={subject}; "
|
||||
f"actual={actual:.12g}; limit={limit:.12g}"
|
||||
)
|
||||
|
||||
|
||||
def _finite_point(point: object) -> bool:
|
||||
return (
|
||||
isinstance(point, (list, tuple))
|
||||
and len(point) >= 2
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(value)
|
||||
for value in point[:2])
|
||||
)
|
||||
|
||||
|
||||
def _quantize_key_coord(value: float) -> float:
|
||||
"""#331 §2.1: the canonicalisation formula — sign*floor(|v|*f+0.5)/f.
|
||||
|
||||
Native round() is banker's rounding and parts ways with JS Math-style
|
||||
rounding on .5 ticks; coordinate canonicalisation already encodes this
|
||||
parity lesson, and node keys must follow it. -0 normalises to 0.
|
||||
"""
|
||||
rounded = math.copysign(
|
||||
math.floor(abs(value) * _KEY_FACTOR + 0.5), value,
|
||||
) / _KEY_FACTOR
|
||||
return 0.0 if rounded == 0 else rounded
|
||||
|
||||
|
||||
def _key(point) -> str:
|
||||
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
|
||||
|
||||
|
||||
def _length(a, b) -> float:
|
||||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||||
|
||||
|
||||
def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> float:
|
||||
return (cm / (cell_cm or 1)) * grid_pitch
|
||||
|
||||
|
||||
def limit_segments(space: dict) -> list[dict]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
segments: list[dict] = []
|
||||
for segment in space.get("wall_segments") or []:
|
||||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||||
segments.append({
|
||||
"id": str(segment.get("id") or ""),
|
||||
"a": segment["a"], "b": segment["b"],
|
||||
"cm": float(segment.get("cm") or 0),
|
||||
})
|
||||
for partition in space.get("partitions") or []:
|
||||
if _finite_point(partition.get("a")) and _finite_point(partition.get("b")):
|
||||
segments.append({
|
||||
"id": str(partition.get("id") or ""),
|
||||
"a": partition["a"], "b": partition["b"],
|
||||
"cm": float(partition.get("cm") or 0),
|
||||
})
|
||||
for draft in space.get("room_drafts") or []:
|
||||
points = draft.get("points") or []
|
||||
drafted = draft.get("segments") or []
|
||||
for index in range(max(len(points) - 1, 0)):
|
||||
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
|
||||
continue
|
||||
piece = drafted[index] if index < len(drafted) else {}
|
||||
segments.append({
|
||||
"id": str((piece or {}).get("id")
|
||||
or f"{draft.get('id') or 'draft'}-{index}"),
|
||||
"a": points[index], "b": points[index + 1],
|
||||
"cm": float((piece or {}).get("cm") or 0),
|
||||
})
|
||||
return [segment for segment in segments
|
||||
if _length(segment["a"], segment["b"]) > _EPS]
|
||||
|
||||
|
||||
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||||
"""П1 + П2: valence of a node and the narrowest wedge in it."""
|
||||
rays: dict[str, list[float]] = {}
|
||||
for segment in segments:
|
||||
for start, end in ((segment["a"], segment["b"]), (segment["b"], segment["a"])):
|
||||
rays.setdefault(_key(start), []).append(
|
||||
math.atan2(end[1] - start[1], end[0] - start[0])
|
||||
)
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
for node, angles in rays.items():
|
||||
if len(angles) > MAX_JUNCTION_VALENCE:
|
||||
violations.append(
|
||||
("valence", node, float(len(angles)), float(MAX_JUNCTION_VALENCE))
|
||||
)
|
||||
if len(angles) < 2:
|
||||
continue
|
||||
ordered = sorted(angles)
|
||||
smallest = math.inf
|
||||
for index, value in enumerate(ordered):
|
||||
delta = ordered[(index + 1) % len(ordered)] - value
|
||||
if index == len(ordered) - 1:
|
||||
delta += math.pi * 2
|
||||
degrees = (delta * 180) / math.pi
|
||||
# A ~0° pair is NOT a violation — and cannot be (#331 revision 4,
|
||||
# learned in the field): a shared wall of two adjacent rooms is
|
||||
# two co-located owner atoms ON ONE LINE, so every shared-wall
|
||||
# node carries a legitimate 0° pair by construction. An exact
|
||||
# duplicate stays a KNOWN LIMITATION of П1 at this level.
|
||||
if _EPS < degrees < smallest:
|
||||
smallest = degrees
|
||||
if smallest < MIN_JUNCTION_ANGLE_DEG - 1e-9:
|
||||
violations.append(("angle", node, smallest, MIN_JUNCTION_ANGLE_DEG))
|
||||
return violations
|
||||
|
||||
|
||||
def _axis_degrees(segment: dict) -> float:
|
||||
degrees = math.degrees(math.atan2(
|
||||
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
|
||||
))
|
||||
return (degrees % 180 + 180) % 180
|
||||
|
||||
|
||||
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
|
||||
delta = abs(_axis_degrees(left) - _axis_degrees(right))
|
||||
return min(delta, 180 - delta) <= tolerance_deg
|
||||
|
||||
|
||||
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
|
||||
by_node: dict[str, list[dict]] = {}
|
||||
for item in segments:
|
||||
for point in (item["a"], item["b"]):
|
||||
by_node.setdefault(_key(point), []).append(item)
|
||||
return by_node
|
||||
|
||||
|
||||
def collinear_run_length_units(
|
||||
segment: dict, segments: list[dict],
|
||||
by_node_index: dict[str, list[dict]] | None = None,
|
||||
) -> float:
|
||||
"""Length of the WALL a segment belongs to, not of the atom.
|
||||
|
||||
#331 §2.3/§2.4: an iterative edge walk over the collinear component — no
|
||||
recursion (a 10 000-atom chain must answer, not overflow), no
|
||||
combinatorial DFS (every atom joins at most once, O(E)), no silently
|
||||
dropped fork. Collinearity is measured against the BASE segment's axis,
|
||||
so an arc of small per-atom turns cannot pose as one straight wall.
|
||||
|
||||
#330 §4.3: the per-check caller builds the node index once.
|
||||
"""
|
||||
by_node = by_node_index if by_node_index is not None else _build_node_index(segments)
|
||||
visited = {id(segment)}
|
||||
total = _length(segment["a"], segment["b"])
|
||||
frontier = [segment["a"], segment["b"]]
|
||||
base_cm = float(segment.get("cm") or 0)
|
||||
while frontier:
|
||||
node = frontier.pop()
|
||||
for candidate in by_node.get(_key(node), ()):
|
||||
if id(candidate) in visited:
|
||||
continue
|
||||
if not _collinear(candidate, segment):
|
||||
continue
|
||||
if float(candidate.get("cm") or 0) != base_cm:
|
||||
continue
|
||||
visited.add(id(candidate))
|
||||
total += _length(candidate["a"], candidate["b"])
|
||||
frontier.append(candidate["a"])
|
||||
frontier.append(candidate["b"])
|
||||
return total
|
||||
|
||||
def check_segment_lengths(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
by_node = _build_node_index(segments)
|
||||
for segment in segments:
|
||||
units = collinear_run_length_units(segment, segments, by_node)
|
||||
cm = (units / grid_pitch) * (cell_cm or 1)
|
||||
thickness = float(segment.get("cm") or 0)
|
||||
limit = max(MIN_SEGMENT_LENGTH_CM, thickness if thickness > 0 else 0.0)
|
||||
if cm < limit - 1e-9:
|
||||
violations.append((
|
||||
"length", str(segment.get("id") or _key(segment["a"])), cm, limit,
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def _distance_to_segment(point, a, b) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length_sq = dx * dx + dy * dy
|
||||
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
|
||||
(point[0] - a[0]) * dx + (point[1] - a[1]) * dy
|
||||
) / length_sq))
|
||||
return math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t))
|
||||
|
||||
|
||||
def check_node_distances(
|
||||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||||
) -> list[tuple[str, str, float, float]]:
|
||||
"""П4: non-incident nodes and node-to-foreign-wall clearance.
|
||||
|
||||
#330 §4.5: the all-pairs form cost 372 ms on 576 atoms. Nodes and padded
|
||||
segment bounding boxes are hashed into a grid with the threshold as cell
|
||||
size; each node is compared only against its 9-cell neighbourhood.
|
||||
Verdicts are identical — pinned by the equivalence unit and the parity
|
||||
suite. Pair order inside the subject follows the lexicographic key order,
|
||||
which replaces the i<j of the all-pairs loop.
|
||||
"""
|
||||
nodes: dict[str, list] = {}
|
||||
for segment in segments:
|
||||
nodes[_key(segment["a"])] = segment["a"]
|
||||
nodes[_key(segment["b"])] = segment["b"]
|
||||
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
|
||||
size = min_units if min_units > _EPS else 1.0
|
||||
|
||||
node_grid: dict[tuple[int, int], list] = {}
|
||||
for node_key, point in nodes.items():
|
||||
node_grid.setdefault(
|
||||
(int(point[0] // size), int(point[1] // size)), []
|
||||
).append((node_key, point))
|
||||
segment_grid: dict[tuple[int, int], list] = {}
|
||||
for segment in segments:
|
||||
x0 = min(segment["a"][0], segment["b"][0]) - min_units
|
||||
x1 = max(segment["a"][0], segment["b"][0]) + min_units
|
||||
y0 = min(segment["a"][1], segment["b"][1]) - min_units
|
||||
y1 = max(segment["a"][1], segment["b"][1]) + min_units
|
||||
for cx in range(int(x0 // size), int(x1 // size) + 1):
|
||||
for cy in range(int(y0 // size), int(y1 // size) + 1):
|
||||
segment_grid.setdefault((cx, cy), []).append(segment)
|
||||
|
||||
violations: list[tuple[str, str, float, float]] = []
|
||||
for node_key, point in nodes.items():
|
||||
cx, cy = int(point[0] // size), int(point[1] // size)
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
for other_key, other in node_grid.get((cx + dx, cy + dy), ()):
|
||||
if node_key >= other_key:
|
||||
continue
|
||||
distance = _length(point, other)
|
||||
# #331 §2.1: raw debris within the incidence quantum is ONE
|
||||
# node on two neighbouring keys — never a near miss.
|
||||
if distance <= _INCIDENT_EPS:
|
||||
continue
|
||||
if distance < min_units - 1e-9:
|
||||
violations.append((
|
||||
"distance", f"{node_key} ↔ {other_key}",
|
||||
(distance / grid_pitch) * (cell_cm or 1),
|
||||
MIN_NODE_DISTANCE_CM,
|
||||
))
|
||||
for segment in segment_grid.get((cx, cy), ()):
|
||||
# A node that belongs to the wall is a legal corner or T-joint.
|
||||
if _key(segment["a"]) == node_key or _key(segment["b"]) == node_key:
|
||||
continue
|
||||
distance = _distance_to_segment(point, segment["a"], segment["b"])
|
||||
# Sitting exactly ON the wall is the other legal incidence.
|
||||
if distance <= _INCIDENT_EPS:
|
||||
continue
|
||||
if distance < min_units - 1e-9:
|
||||
violations.append((
|
||||
"distance",
|
||||
f"{node_key} → {segment.get('id') or _key(segment['a'])}",
|
||||
(distance / grid_pitch) * (cell_cm or 1),
|
||||
MIN_NODE_DISTANCE_CM,
|
||||
))
|
||||
return violations
|
||||
|
||||
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||||
"""П1–П4 over one space, in the frontend's order."""
|
||||
segments = limit_segments(space)
|
||||
cell_cm = float(space.get("cell_cm") or 1)
|
||||
return [
|
||||
*check_nodes(segments),
|
||||
*check_segment_lengths(segments, cell_cm),
|
||||
*check_node_distances(segments, cell_cm),
|
||||
]
|
||||
|
||||
|
||||
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
|
||||
"""Spaces of one document AFTER the wall-segment migration, by id.
|
||||
|
||||
The limits read `wall_segments`, so a document that predates the catalogue
|
||||
reports NO walls at all — a legacy space would answer "no violations"
|
||||
regardless of its geometry. Comparing such a baseline against a candidate
|
||||
the client already migrated counts every inherited violation as new and
|
||||
refuses an unrelated edit (spec §3 forbids exactly that). Both sides are
|
||||
therefore judged after the SAME migration, mirroring the frontend barrier.
|
||||
|
||||
A document that cannot be migrated is not a reason to refuse the write:
|
||||
the wall-model barrier owns that verdict and reports it with its own code.
|
||||
Here it simply means there is no baseline to inherit from.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
# #330 §4.6: a document that already carries the current catalogue is
|
||||
# used as-is. The migration's purpose here is to never compare a
|
||||
# catalogue-less legacy baseline against a migrated candidate; a
|
||||
# current-version document has the catalogue, so the purpose is met with
|
||||
# zero work — while a no-op re-migration costs 0.8 s of _atomize on a
|
||||
# 576-atom plan. Equivalence "as-is == migrated" for current-version
|
||||
# documents is pinned by a parity case.
|
||||
if int(config.get("model_version") or 0) >= WALL_SEGMENT_MODEL_VERSION:
|
||||
migrated = config
|
||||
else:
|
||||
try:
|
||||
migrated, _ = commit_wall_segment_model(config)
|
||||
except (WallSegmentMigrationError, ValueError) as error:
|
||||
# The wall-model barrier owns this verdict and reports it with its
|
||||
# own code; here it merely means "no catalogue to read".
|
||||
_LOGGER.debug("junction limits: %s migration fell back: %s", side, error)
|
||||
migrated = config
|
||||
except Exception:
|
||||
# #331 §2.6 (AC6): a genuine migration bug on the CANDIDATE side
|
||||
# must surface as an honest WS error, not a silent "no
|
||||
# violations". The PREVIOUS side keeps the wide fallback — an
|
||||
# unprovable inheritance is no reason to block an unrelated write
|
||||
# (the exact P1 symptom this task fixes).
|
||||
if side == "candidate":
|
||||
raise
|
||||
_LOGGER.debug("junction limits: previous-side migration bug swallowed by design")
|
||||
migrated = config
|
||||
return {
|
||||
str(space.get("id")): space
|
||||
for space in (migrated or {}).get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
}
|
||||
|
||||
|
||||
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
|
||||
"""Violation counts per space per rule — the shape the barrier consumes
|
||||
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
for space_id, space in spaces.items():
|
||||
counts: dict[str, int] = {}
|
||||
for rule, _subject, _actual, _limit in space_violations(space):
|
||||
counts[rule] = counts.get(rule, 0) + 1
|
||||
result[space_id] = counts
|
||||
return result
|
||||
|
||||
|
||||
def validate_junction_limits(
|
||||
config: dict, previous: dict | None = None, *,
|
||||
baseline_counts: dict[str, dict[str, int]] | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Refuse a write that ADDS a junction violation; inherit the rest.
|
||||
|
||||
Counted per RULE, not per subject: a structural write re-atomises and
|
||||
re-keys contour segments, so subject identity is not stable across the
|
||||
barrier and matching by it would report an inherited violation as new
|
||||
(the mistake that refused legitimate resizes on the frontend). Spec §3:
|
||||
a write may keep existing violations, it may never add one.
|
||||
|
||||
A LEGACY document still goes through `commit_wall_segment_model` first,
|
||||
so a catalogue-less baseline is compared in the same terms as the
|
||||
candidate; a current-version document is used as-is (#330 §4.6).
|
||||
|
||||
`baseline_counts` (#330 §4.2) replaces the previous document entirely:
|
||||
the caller caches the counts of the stored document by config_rev, so a
|
||||
repeated write does not even touch `previous`. Returns the candidate's
|
||||
counts — the value to cache after a successful write.
|
||||
"""
|
||||
if baseline_counts is not None:
|
||||
old_counts = baseline_counts
|
||||
else:
|
||||
old_counts = space_violation_counts(_migrated_spaces(previous))
|
||||
new_spaces = _migrated_spaces(config, side="candidate")
|
||||
candidate_counts: dict[str, dict[str, int]] = {}
|
||||
for space_id, space in new_spaces.items():
|
||||
# A brand-new space has nothing to inherit from — but neither is a
|
||||
# first write allowed to arrive already broken.
|
||||
before = old_counts.get(space_id) or {}
|
||||
after: dict[str, list[tuple[str, str, float, float]]] = {}
|
||||
for violation in space_violations(space):
|
||||
after.setdefault(violation[0], []).append(violation)
|
||||
candidate_counts[space_id] = {
|
||||
rule: len(items) for rule, items in after.items()
|
||||
}
|
||||
for rule, items in after.items():
|
||||
if len(items) > before.get(rule, 0):
|
||||
_, subject, actual, limit = items[0]
|
||||
raise JunctionLimitError(space_id, rule, subject, actual, limit)
|
||||
return candidate_counts
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.67.0-rc.3"
|
||||
"version": "1.68.1"
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ class HouseplanData:
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# #330 §4.2: junction-limit violation counts of the STORED document,
|
||||
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
# not re-judged on every write. Counts only — documents are never held.
|
||||
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -102,8 +102,13 @@ def _legacy_wall_model_projection(config: dict) -> dict:
|
||||
return projected
|
||||
|
||||
|
||||
def _legacy_wall_geometry_projection(config: dict) -> list[dict]:
|
||||
"""Only geometry whose edit requires a matching v8 identity update."""
|
||||
def _catalog_coupled_wall_geometry_projection(config: dict) -> list[dict]:
|
||||
"""Legacy geometry whose edit must also update the v8 contour catalogue.
|
||||
|
||||
Drafts, partitions and columns carry their own ids. Openings carry an
|
||||
explicit v8 host and are checked against it by CONFIG_SCHEMA. None of those
|
||||
independent objects requires a different contour wall_segments catalogue.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
for space in config.get("spaces") or []:
|
||||
rooms = []
|
||||
@@ -111,28 +116,11 @@ def _legacy_wall_geometry_projection(config: dict) -> list[dict]:
|
||||
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
|
||||
"id", "poly", "x", "y", "w", "h", "open_to"
|
||||
) if key in room})
|
||||
openings = []
|
||||
for opening in space.get("openings") or []:
|
||||
openings.append({key: copy.deepcopy(opening.get(key)) for key in (
|
||||
"id", "type", "x", "y", "angle", "length"
|
||||
) if key in opening})
|
||||
drafts = []
|
||||
for draft in space.get("room_drafts") or []:
|
||||
drafts.append({
|
||||
"id": draft.get("id"),
|
||||
"points": copy.deepcopy(draft.get("points")),
|
||||
"segments": [
|
||||
{"cm": segment.get("cm")} for segment in draft.get("segments") or []
|
||||
],
|
||||
})
|
||||
result.append({
|
||||
"id": space.get("id"),
|
||||
"rooms": rooms,
|
||||
"walls": copy.deepcopy(space.get("walls") or []),
|
||||
"open_spans": copy.deepcopy(space.get("open_spans") or []),
|
||||
"room_drafts": drafts,
|
||||
"partitions": copy.deepcopy(space.get("partitions") or []),
|
||||
"openings": openings,
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -186,12 +174,12 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
except (TypeError, ValueError):
|
||||
return # CONFIG_SCHEMA owns malformed values.
|
||||
previous = previous or {}
|
||||
geometry_changed = (
|
||||
_legacy_wall_geometry_projection(config)
|
||||
!= _legacy_wall_geometry_projection(previous)
|
||||
contour_geometry_changed = (
|
||||
_catalog_coupled_wall_geometry_projection(config)
|
||||
!= _catalog_coupled_wall_geometry_projection(previous)
|
||||
)
|
||||
if old_model >= 8 and new_model < 8:
|
||||
if not geometry_changed:
|
||||
if not contour_geometry_changed:
|
||||
_restore_wall_model_fields(config, previous or {})
|
||||
# Re-run semantic parity after hydration; previous identity is
|
||||
# accepted only when it still matches the submitted projections.
|
||||
@@ -202,12 +190,19 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; submitted model={new_model}"
|
||||
)
|
||||
if old_model >= 8 and new_model >= 8 and geometry_changed:
|
||||
if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed:
|
||||
# The realistic stale-client case echoes model_version and the unknown
|
||||
# catalogue verbatim while changing rooms/walls/openings. Let the
|
||||
# catalogue verbatim while changing room/contour geometry. Let the
|
||||
# frontend show the dedicated reload guidance instead of a generic
|
||||
# schema error. A current writer necessarily changes the catalogue in
|
||||
# the same transaction.
|
||||
# schema error. Independent drafts, partitions, columns and explicitly
|
||||
# hosted openings are validated by CONFIG_SCHEMA without requiring a
|
||||
# contour catalogue change (#314).
|
||||
#
|
||||
# A submitted model ABOVE the stored one is excluded on purpose (#319):
|
||||
# a stale client can only echo the stored version, never raise it. The
|
||||
# first write of a newer client legitimately drops legacy projection
|
||||
# fields (an orphan open_span/open_to) without touching the catalogue;
|
||||
# treating that as "outdated" wedged every structural write forever.
|
||||
if _wall_catalog_projection(config) == _wall_catalog_projection(previous):
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; unchanged wall catalogue"
|
||||
@@ -1433,7 +1428,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Required("segments"): vol.All(
|
||||
[vol.Schema({
|
||||
vol.Optional("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(min=1, max=499),
|
||||
@@ -1456,7 +1451,7 @@ PARTITION_SCHEMA = vol.All(
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("a"): POINT,
|
||||
vol.Required("b"): POINT,
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -1636,6 +1631,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("wall_segments"): vol.All(
|
||||
[WALL_SEGMENT_SCHEMA], vol.Length(max=MAX_WALL_SEGMENTS)
|
||||
),
|
||||
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
),
|
||||
@@ -1774,7 +1770,7 @@ def _canonical_segment_key(a: list, b: list) -> tuple:
|
||||
|
||||
|
||||
def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
"""Fail closed when a v8 writer sends stale ids or a stale walls projection."""
|
||||
"""Fail closed when a v8+ writer sends stale ids or wall projections."""
|
||||
try:
|
||||
model = int(value.get("model_version", 0))
|
||||
except (TypeError, ValueError):
|
||||
@@ -1783,9 +1779,12 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
return value
|
||||
|
||||
for space in value.get("spaces", []):
|
||||
if model >= 9:
|
||||
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
|
||||
raise vol.Invalid("v9 config must not contain legacy open boundaries")
|
||||
segments = space.get("wall_segments")
|
||||
if segments is None:
|
||||
raise vol.Invalid("v8 space requires wall_segments")
|
||||
raise vol.Invalid("v8+ space requires wall_segments")
|
||||
by_id = {segment["id"]: segment for segment in segments}
|
||||
if len(by_id) != len(segments):
|
||||
raise vol.Invalid("wall segment ids must be unique")
|
||||
@@ -1795,7 +1794,7 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
poly = room.get("poly")
|
||||
wall_ids = room.get("wall_ids")
|
||||
if not poly or not wall_ids or len(poly) != len(wall_ids):
|
||||
raise vol.Invalid("v8 room wall_ids must match poly edges")
|
||||
raise vol.Invalid("v8+ room wall_ids must match poly edges")
|
||||
room_id = room["id"]
|
||||
for index, segment_id in enumerate(wall_ids):
|
||||
segment = by_id.get(segment_id)
|
||||
@@ -1815,31 +1814,38 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
actual_walls: dict[tuple, float] = {}
|
||||
for wall in space.get("walls", []):
|
||||
if "a" not in wall or "b" not in wall:
|
||||
raise vol.Invalid("v8 compatibility walls require exact endpoints")
|
||||
raise vol.Invalid("wall compatibility projection requires exact endpoints")
|
||||
key = _canonical_segment_key(wall["a"], wall["b"])
|
||||
if key in actual_walls:
|
||||
raise vol.Invalid("v8 compatibility walls must be unique")
|
||||
raise vol.Invalid("wall compatibility projection must be unique")
|
||||
actual_walls[key] = float(wall["cm"])
|
||||
if actual_walls != expected_walls:
|
||||
raise vol.Invalid("v8 compatibility walls must match wall_segments")
|
||||
raise vol.Invalid("wall compatibility projection must match wall_segments")
|
||||
|
||||
for draft in space.get("room_drafts", []):
|
||||
if any(not segment.get("id") for segment in draft.get("segments", [])):
|
||||
raise vol.Invalid("v8 draft wall segments require ids")
|
||||
raise vol.Invalid("v8+ draft wall segments require ids")
|
||||
|
||||
partitions = {item["id"]: item for item in space.get("partitions", [])}
|
||||
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
for opening in space.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
raise vol.Invalid("v8 opening requires an explicit host")
|
||||
# #316 §3.3: a degraded migration may leave a contour opening
|
||||
# unhosted. It is inert (no body/tunnel/cut) and excluded from
|
||||
# the interval checks; a later edit can re-place it.
|
||||
continue
|
||||
if host["kind"] == "partition":
|
||||
if host["id"] not in partitions:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
if float(partitions[host["id"]]["cm"]) <= 0:
|
||||
raise vol.Invalid("opening host must have positive thickness")
|
||||
continue
|
||||
segment = by_id.get(host["id"])
|
||||
if segment is None:
|
||||
raise vol.Invalid("wall opening host must exist in the same space")
|
||||
if float(segment["cm"]) <= 0:
|
||||
raise vol.Invalid("opening host must have positive thickness")
|
||||
t = float(host["t"])
|
||||
x = float(segment["a"][0]) + (float(segment["b"][0]) - float(segment["a"][0])) * t
|
||||
y = float(segment["a"][1]) + (float(segment["b"][1]) - float(segment["a"][1])) * t
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Deterministic persisted contour-wall identity (model v8, issue #282).
|
||||
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
|
||||
|
||||
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
|
||||
a server-side structural writer, so it cannot depend on a browser being open
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 8
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
|
||||
@@ -182,9 +182,96 @@ def _atomize(
|
||||
rooms = space.get("rooms") or []
|
||||
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
|
||||
global_breaks: list[list[float]] = []
|
||||
for span in space.get("open_spans") or []:
|
||||
explicit_spans = space.get("open_spans") or []
|
||||
legacy_segments: list[list[float]] = []
|
||||
for span in explicit_spans:
|
||||
if isinstance(span, dict) and isinstance(span.get("a"), list) and isinstance(span.get("b"), list):
|
||||
global_breaks.extend((span["a"], span["b"]))
|
||||
legacy_segments.append([
|
||||
float(span["a"][0]), float(span["a"][1]),
|
||||
float(span["b"][0]), float(span["b"][1]),
|
||||
])
|
||||
if not legacy_segments:
|
||||
linked = lambda first, second: (
|
||||
str(second.get("id", "")) in (first.get("open_to") or [])
|
||||
or str(first.get("id", "")) in (second.get("open_to") or [])
|
||||
)
|
||||
for first_index, first in enumerate(rooms):
|
||||
for second in rooms[first_index + 1:]:
|
||||
if not linked(first, second):
|
||||
continue
|
||||
for shared in _shared_boundaries(
|
||||
room_polys.get(str(first.get("id", "")), []),
|
||||
room_polys.get(str(second.get("id", "")), []),
|
||||
):
|
||||
legacy_segments.append(shared)
|
||||
global_breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
|
||||
canonical_zero_segments: list[list[float]] = []
|
||||
for room in rooms:
|
||||
poly = room_polys.get(str(room.get("id", ""))) or []
|
||||
wall_ids = room.get("wall_ids") or []
|
||||
if len(wall_ids) != len(poly):
|
||||
continue
|
||||
for index, segment_id in enumerate(wall_ids):
|
||||
previous = old.get(str(segment_id)) if isinstance(segment_id, str) else None
|
||||
if previous is None or float(previous.get("cm", 0)) != 0:
|
||||
continue
|
||||
following = (index + 1) % len(poly)
|
||||
canonical_zero_segments.append([
|
||||
float(poly[index][0]), float(poly[index][1]),
|
||||
float(poly[following][0]), float(poly[following][1]),
|
||||
])
|
||||
for segment in canonical_zero_segments:
|
||||
global_breaks.extend((segment[:2], segment[2:]))
|
||||
zero_segments = canonical_zero_segments + legacy_segments
|
||||
|
||||
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
|
||||
# carries an existing contour opening; its edges become atom boundaries so
|
||||
# the zero run continues on both sides of the opening.
|
||||
legacy_era_openings: list[dict[str, Any]] = []
|
||||
if legacy_segments:
|
||||
for opening in space.get("openings") or []:
|
||||
if not isinstance(opening, dict):
|
||||
continue
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
continue
|
||||
try:
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle = float(opening["angle"])
|
||||
half = float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if half <= 0:
|
||||
continue
|
||||
# Only an opening that actually stands on a legacy cut changes the
|
||||
# atomization; unrelated openings must not churn the catalogue.
|
||||
if not any(
|
||||
_distance_to_segment(centre, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in legacy_segments
|
||||
):
|
||||
continue
|
||||
legacy_era_openings.append(
|
||||
{"centre": centre, "angle": angle, "half": half}
|
||||
)
|
||||
direction = (math.cos(math.radians(angle)), math.sin(math.radians(angle)))
|
||||
global_breaks.extend((
|
||||
[centre[0] - direction[0] * half, centre[1] - direction[1] * half],
|
||||
[centre[0] + direction[0] * half, centre[1] + direction[1] * half],
|
||||
))
|
||||
|
||||
def _atom_carries_opening(a: list[float], b: list[float]) -> bool:
|
||||
for entry in legacy_era_openings:
|
||||
if not _angle_matches(a, b, entry["angle"]):
|
||||
continue
|
||||
if _distance_to_segment(entry["centre"], a, b) > GRID_STEP_N * 0.02:
|
||||
continue
|
||||
span = _length(a, b)
|
||||
tc = _project_t(entry["centre"], a, b) * span
|
||||
if tc + entry["half"] >= -EPS and tc - entry["half"] <= span + EPS:
|
||||
return True
|
||||
return False
|
||||
|
||||
for wall in space.get("walls") or []:
|
||||
if isinstance(wall, dict) and isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
|
||||
global_breaks.extend((wall["a"], wall["b"]))
|
||||
@@ -253,13 +340,23 @@ def _atomize(
|
||||
"key": key, "a": _canonical_span(a, b)[0], "b": _canonical_span(a, b)[1],
|
||||
"owners": set(), "preferred": set(), "positional": set(),
|
||||
"preferred_carriers": {},
|
||||
"parent_keys": set(),
|
||||
"parent_keys": set(), "zero_wall": False,
|
||||
})
|
||||
atom["owners"].add(room_id)
|
||||
if len(atom["owners"]) > 2:
|
||||
raise WallSegmentMigrationError("third-owner", key)
|
||||
parent_index = parents[index]
|
||||
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
|
||||
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
covered_by = lambda cuts: any(
|
||||
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
|
||||
for cut in cuts
|
||||
)
|
||||
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
|
||||
# that carries an opening (#316 §3.1).
|
||||
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
|
||||
covered_by(legacy_segments) and not _atom_carries_opening(a, b)
|
||||
)
|
||||
if indexed_lineage and isinstance(old_ids[parent_index], str) and old_ids[parent_index]:
|
||||
previous = old.get(old_ids[parent_index])
|
||||
if rigid_indexed_lineage or previous is None or _collinear_overlap(
|
||||
@@ -281,6 +378,8 @@ def _atomize(
|
||||
|
||||
|
||||
def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str, Any] | None) -> float:
|
||||
if atom.get("zero_wall"):
|
||||
return 0.0
|
||||
candidates: list[float] = []
|
||||
query_key = _wall_key(atom["a"], atom["b"])
|
||||
query_length = _length(atom["a"], atom["b"])
|
||||
@@ -431,7 +530,9 @@ def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
|
||||
return difference <= 8
|
||||
|
||||
|
||||
def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> None:
|
||||
def _host_openings(
|
||||
space: dict[str, Any], segments: list[dict[str, Any]], initial_migration: bool,
|
||||
) -> None:
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host")
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
@@ -440,9 +541,15 @@ def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> Non
|
||||
centre = [float(opening["x"]), float(opening["y"])]
|
||||
angle, half = float(opening["angle"]), float(opening["length"]) / 2
|
||||
except (KeyError, TypeError, ValueError):
|
||||
if initial_migration:
|
||||
# #316 §3.4: the initial migration never throws over an opening.
|
||||
opening.pop("host", None)
|
||||
continue
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
|
||||
|
||||
def eligible(segment: dict[str, Any]) -> bool:
|
||||
if float(segment.get("cm", 0)) <= 0:
|
||||
return False
|
||||
t = _project_t(centre, segment["a"], segment["b"])
|
||||
span = _length(segment["a"], segment["b"])
|
||||
return (-EPS <= t <= 1 + EPS
|
||||
@@ -452,19 +559,49 @@ def _host_openings(space: dict[str, Any], segments: list[dict[str, Any]]) -> Non
|
||||
and half >= 0 and t * span - half >= -EPS
|
||||
and t * span + half <= span + EPS)
|
||||
|
||||
def materialize(carrier: dict[str, Any]) -> None:
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
}
|
||||
|
||||
current = None
|
||||
if isinstance(host, dict) and host.get("kind") == "wall":
|
||||
current = next((segment for segment in segments if segment["id"] == host.get("id")), None)
|
||||
candidates = [current] if current is not None and eligible(current) else [
|
||||
segment for segment in segments if eligible(segment)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
if len(candidates) == 1:
|
||||
materialize(candidates[0])
|
||||
continue
|
||||
# #316 §3.3: an unhosted opening is a valid degraded v9 state. A later
|
||||
# write keeps it, may self-heal it, and never fails over it.
|
||||
if host is None and not initial_migration:
|
||||
continue
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
|
||||
carrier = candidates[0]
|
||||
opening["host"] = {
|
||||
"kind": "wall", "id": carrier["id"],
|
||||
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
|
||||
}
|
||||
|
||||
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
if not pool:
|
||||
return None
|
||||
if current is not None and any(item is current for item in pool):
|
||||
return current
|
||||
return sorted(pool, key=lambda segment: (
|
||||
_distance_to_segment(centre, segment["a"], segment["b"]),
|
||||
-float(segment.get("cm", 0)),
|
||||
str(segment.get("id", "")),
|
||||
))[0]
|
||||
|
||||
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
|
||||
# H1): a host away from the opening's own x/y would violate the
|
||||
# geometry-match invariant of CONFIG_SCHEMA and wedge the write on the
|
||||
# schema layer; without an in-place carrier the opening goes straight
|
||||
# to the unhosted degraded state.
|
||||
carrier = pick([segment for segment in segments if eligible(segment)])
|
||||
if carrier is not None:
|
||||
materialize(carrier)
|
||||
else:
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
@@ -498,6 +635,9 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
space["walls"] = walls
|
||||
else:
|
||||
space.pop("walls", None)
|
||||
space.pop("open_spans", None)
|
||||
for room in space.get("rooms") or []:
|
||||
room.pop("open_to", None)
|
||||
draft_used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
|
||||
@@ -527,7 +667,7 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
else:
|
||||
segment["id"] = _fresh_wall_segment_id(draft_used)
|
||||
draft_used.add(segment["id"])
|
||||
_host_openings(space, segments)
|
||||
_host_openings(space, segments, initial_migration)
|
||||
return sum(1 for segment in segments if segment["id"] not in old)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
@@ -65,6 +66,7 @@ from .wall_segment_model import (
|
||||
WallSegmentMigrationError,
|
||||
commit_wall_segment_model,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
@@ -1157,6 +1159,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1316,19 +1321,35 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# Marker-to-marker light links are a semantic graph layered on top of
|
||||
# the lossless controls array. Validate only edges introduced by this
|
||||
# write so an unrelated edit can still round-trip a legacy broken ref.
|
||||
try:
|
||||
# #330 §4.1/§4.2: the CPU part of validation runs in the executor —
|
||||
# schema + semantic validators cost seconds on a large plan and the
|
||||
# event loop must not carry that (HA guideline: >50 ms). write_lock
|
||||
# is still held across the await: writes stay serialised, only the
|
||||
# loop is freed. The junction baseline of the STORED document is
|
||||
# cached by rev, so a repeated write does not re-judge `previous`.
|
||||
baseline = rt.junction_baseline
|
||||
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
|
||||
|
||||
def _validate_config_cpu():
|
||||
validate_wall_model_transition(msg["config"], data.get("config"))
|
||||
validated_config = CONFIG_SCHEMA(msg["config"])
|
||||
checked = CONFIG_SCHEMA(msg["config"])
|
||||
msg["config"].clear()
|
||||
msg["config"].update(validated_config)
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
return validate_junction_limits(
|
||||
msg["config"], data.get("config"),
|
||||
baseline_counts=baseline_counts,
|
||||
)
|
||||
try:
|
||||
candidate_counts = await hass.async_add_executor_job(_validate_config_cpu)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
@@ -1370,6 +1391,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
new_rev,
|
||||
previous_rev=int(current_rev),
|
||||
)
|
||||
# The candidate just became the stored document: its counts are the
|
||||
# next write's baseline (#330 §4.2).
|
||||
rt.junction_baseline = (int(new_rev), candidate_counts)
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
@@ -1636,7 +1660,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# Optimization is a normal configuration write with an additional
|
||||
# layout transaction. It must enforce the same marker-link semantics
|
||||
# as config/set; otherwise a crafted client can persist a new cycle.
|
||||
try:
|
||||
# #330 §4.1: the same executor treatment as config/set — Optimize
|
||||
# carries schema + a possible full migration, the costliest CPU path
|
||||
# of all writers, and it used to run on the event loop.
|
||||
def _validate_optimize_cpu():
|
||||
validate_wall_model_transition(msg["config"], config_data.get("config"))
|
||||
try:
|
||||
submitted_model = int(msg["config"].get("model_version", 0) or 0)
|
||||
@@ -1655,17 +1682,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_opening_passages(candidate_config, config_data.get("config"))
|
||||
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
|
||||
candidate_config, _ = commit_wall_segment_model(candidate_config)
|
||||
validated_config = CONFIG_SCHEMA(candidate_config)
|
||||
migrated_size = len(json.dumps(validated_config, separators=(",", ":")))
|
||||
checked = CONFIG_SCHEMA(candidate_config)
|
||||
migrated_size = len(json.dumps(checked, separators=(",", ":")))
|
||||
if migrated_size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {migrated_size // 1024} KB, "
|
||||
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
return migrated_size
|
||||
msg["config"].clear()
|
||||
msg["config"].update(validated_config)
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
@@ -1674,9 +1696,20 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
oversize = await hass.async_add_executor_job(_validate_optimize_cpu)
|
||||
if oversize is not None:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {oversize // 1024} KB, "
|
||||
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
WallSegmentMigrationError,
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
|
||||
@@ -87,7 +87,7 @@ const viewport = { width: 1280, height: 900 };
|
||||
const { page, browser } = await launch(
|
||||
viewport, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
{}, resolve(targetRoot, 'demo/srv'), targetRoot,
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* #330 §5: the performance contract of the junction limits (#329).
|
||||
*
|
||||
* Guards the CLASS of regression — a quadratic path returning — not runner
|
||||
* noise: budgets are 2-3x the measured post-fix numbers. Measures the exact
|
||||
* code the write barrier runs: the pure П1-П5 checks over a 576-atom plan
|
||||
* (the S2 reproduction grid of #330), for both mirrors. The python half runs
|
||||
* through a child process with the real custom_components module.
|
||||
*
|
||||
* Runs on test-build/ like benchmark:wall-model — no browser, no bundle.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
checkNodes, checkSegmentLengths, checkNodeDistances, checkRoomClearance,
|
||||
cmToUnits,
|
||||
} from '../test-build/junction-limits.js';
|
||||
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
|
||||
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const CELL = 5;
|
||||
const GRID_N = 12; // 576 contour atoms — the #330 S2 grid
|
||||
const WARMUPS = 2;
|
||||
const SAMPLES = 5;
|
||||
|
||||
// Budgets are calibrated from the SLOWEST machine observed, not the
|
||||
// author's: the CI review runner measured tsFullCandidateMs at 169-171 ms
|
||||
// where the dev sandbox saw 88 (code-review 330-r1 H2). Each budget keeps
|
||||
// the spec's 2-3x allowance over that worst observation, so the bench turns
|
||||
// red for the O(n²) class (which costs seconds), not for a slower runner.
|
||||
const BUDGETS = {
|
||||
tsSegmentLengthsMs: 60,
|
||||
tsNodeDistancesMs: 80,
|
||||
tsFullCandidateMs: 400,
|
||||
pyWarmValidateMs: 300,
|
||||
pyColdValidateMs: 5000,
|
||||
};
|
||||
|
||||
const u = (cm) => cmToUnits(cm, CELL, GRID_STEP_N);
|
||||
const rooms = []; const walls = []; let k = 0;
|
||||
for (let i = 0; i < GRID_N; i++) {
|
||||
for (let j = 0; j < GRID_N; j++) {
|
||||
const x0 = u(310) * i; const y0 = u(310) * j;
|
||||
const poly = [[x0, y0], [x0 + u(300), y0], [x0 + u(300), y0 + u(300)], [x0, y0 + u(300)]];
|
||||
rooms.push({ id: `r${i}-${j}`, name: `r${i}${j}`, area: null, poly });
|
||||
for (let e = 0; e < 4; e++) {
|
||||
walls.push({ key: `w${k++}`, a: poly[e], b: poly[(e + 1) % 4], cm: 15 });
|
||||
}
|
||||
}
|
||||
}
|
||||
const legacy = {
|
||||
spaces: [{
|
||||
id: 's', title: 's', cell_cm: CELL, view_box: [0, 0, 1, 1],
|
||||
rooms, walls, openings: [], room_drafts: [], partitions: [], wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const { config: v9 } = commitWallSegmentModel(JSON.parse(JSON.stringify(legacy)));
|
||||
const space = v9.spaces[0];
|
||||
const segments = (space.wall_segments || []).map((item) => ({
|
||||
id: item.id, a: item.a, b: item.b, cm: Number(item.cm),
|
||||
}));
|
||||
|
||||
/** The same П1-П5 set the card's write barrier computes for one candidate. */
|
||||
const fullCandidate = () => {
|
||||
const violations = [
|
||||
...checkNodes(segments),
|
||||
...checkSegmentLengths(segments, CELL, GRID_STEP_N),
|
||||
...checkNodeDistances(segments, CELL, GRID_STEP_N),
|
||||
];
|
||||
// #330 §4.7: shared passes exactly as the card computes them.
|
||||
const sharedNodes = multiWallNodesForGeometry(
|
||||
space.rooms || [], space.walls || [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
||||
);
|
||||
let sharedRoomGeometry;
|
||||
if (sharedNodes.nodes.length) {
|
||||
const geometry = wallBodiesGeometry(
|
||||
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
||||
);
|
||||
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
|
||||
? geometry.roomGeom : undefined;
|
||||
}
|
||||
for (const room of space.rooms || []) {
|
||||
let inner = null;
|
||||
try {
|
||||
inner = innerContourForRoom(
|
||||
space.rooms || [], String(room.id), space.walls || [], [],
|
||||
GRID_STEP_N, CELL, GRID_STEP_N, 1, sharedRoomGeometry, sharedNodes,
|
||||
);
|
||||
} catch { inner = null; }
|
||||
violations.push(...checkRoomClearance(String(room.id), inner, CELL, GRID_STEP_N));
|
||||
}
|
||||
return violations.length;
|
||||
};
|
||||
|
||||
const median = (fn) => {
|
||||
for (let i = 0; i < WARMUPS; i++) fn();
|
||||
const times = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
times.push(performance.now() - start);
|
||||
}
|
||||
times.sort((left, right) => left - right);
|
||||
return times[Math.floor(times.length / 2)];
|
||||
};
|
||||
|
||||
const tsSegmentLengthsMs = median(() => checkSegmentLengths(segments, CELL, GRID_STEP_N));
|
||||
const tsNodeDistancesMs = median(() => checkNodeDistances(segments, CELL, GRID_STEP_N));
|
||||
const tsFullCandidateMs = median(fullCandidate);
|
||||
|
||||
// Python mirror: cold (legacy both sides) and warm (v9 + baseline counts).
|
||||
const pyScript = `
|
||||
import importlib.util, sys, types, os, json, time
|
||||
root = ${JSON.stringify(REPO_ROOT)}
|
||||
pr = os.path.join(root, 'custom_components'); hr = os.path.join(pr, 'houseplan')
|
||||
pkg = types.ModuleType('custom_components'); pkg.__path__ = [pr]; sys.modules['custom_components'] = pkg
|
||||
p2 = types.ModuleType('custom_components.houseplan'); p2.__path__ = [hr]; sys.modules['custom_components.houseplan'] = p2
|
||||
spec = importlib.util.spec_from_file_location('custom_components.houseplan.junction_limits', os.path.join(hr, 'junction_limits.py'))
|
||||
jl = importlib.util.module_from_spec(spec); sys.modules[spec.name] = jl; spec.loader.exec_module(jl)
|
||||
legacy = json.load(sys.stdin)
|
||||
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
|
||||
v9, _ = commit_wall_segment_model(json.loads(json.dumps(legacy)))
|
||||
prev = json.loads(json.dumps(v9)); cand = json.loads(json.dumps(v9))
|
||||
cand['spaces'][0]['rooms'][0]['name'] = 'x'
|
||||
t0 = time.perf_counter()
|
||||
jl.validate_junction_limits(json.loads(json.dumps(legacy)), json.loads(json.dumps(legacy)))
|
||||
cold = 1000 * (time.perf_counter() - t0)
|
||||
counts = jl.validate_junction_limits(json.loads(json.dumps(cand)), json.loads(json.dumps(prev)))
|
||||
best = None
|
||||
for _ in range(3):
|
||||
t0 = time.perf_counter()
|
||||
jl.validate_junction_limits(json.loads(json.dumps(cand)), None, baseline_counts=counts)
|
||||
warm = 1000 * (time.perf_counter() - t0)
|
||||
best = warm if best is None else min(best, warm)
|
||||
print(json.dumps({'pyColdValidateMs': cold, 'pyWarmValidateMs': best}))
|
||||
`;
|
||||
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
||||
const pyRun = spawnSync(python, ['-c', pyScript], {
|
||||
input: JSON.stringify(legacy), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (pyRun.status !== 0) {
|
||||
console.error('python mirror failed:', (pyRun.stderr || '').slice(-2000));
|
||||
process.exit(1);
|
||||
}
|
||||
const py = JSON.parse(pyRun.stdout.trim().split('\n').pop());
|
||||
|
||||
const results = {
|
||||
tsSegmentLengthsMs, tsNodeDistancesMs, tsFullCandidateMs,
|
||||
pyWarmValidateMs: py.pyWarmValidateMs, pyColdValidateMs: py.pyColdValidateMs,
|
||||
};
|
||||
const pass = Object.entries(BUDGETS).every(([name, budget]) => results[name] <= budget);
|
||||
console.log(JSON.stringify({
|
||||
issue: 330,
|
||||
fixture: { atoms: segments.length, grid: `${GRID_N}x${GRID_N}` },
|
||||
warmups: WARMUPS, samples: SAMPLES,
|
||||
results: Object.fromEntries(Object.entries(results).map(
|
||||
([name, value]) => [name, Math.round(value * 10) / 10],
|
||||
)),
|
||||
budgets: BUDGETS,
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -43,6 +43,7 @@ const { page, browser } = await launch(
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{},
|
||||
resolve(targetRoot, 'demo/srv'),
|
||||
targetRoot,
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
@@ -345,7 +346,7 @@ try {
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._rszDrag?.plan;
|
||||
const plan = card._resize?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -17,6 +18,47 @@ const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/h
|
||||
const SCRIPT = fileURLToPath(import.meta.url);
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
/**
|
||||
* Перепаковка кадра без потерь (#345).
|
||||
*
|
||||
* Замер на этом наборе: 2096 КБ превращаются в 1689 КБ, минус 19.4%, и все
|
||||
* десять кадров остаются ПИКСЕЛЬНО идентичными — декодированные RGBA совпадают
|
||||
* по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование:
|
||||
* визуального решения здесь нет вовсе.
|
||||
*
|
||||
* Почему внутри съёмки, а не отдельным проходом по закоммиченным файлам.
|
||||
* Манифест хранит `imageSha256` каждого кадра, поэтому оптимизировать файлы в
|
||||
* репозитории руками нельзя — `check-docs` покраснеет; а если жать после
|
||||
* подсчёта хешей, следующая же съёмка вернёт неоптимизированные байты.
|
||||
*
|
||||
* Отсутствие инструмента не ошибка: локальная съёмка и без него полезна для
|
||||
* глаз, а приёмка всё равно идёт только из артефакта CI, где `oxipng` стоит
|
||||
* пином (`.github/workflows/docs-screenshots.yml`). Но молчать об этом нельзя —
|
||||
* байты кадра зависят от того, был ли инструмент, поэтому его версия попадает
|
||||
* в манифест рядом с версией браузера, по той же причине.
|
||||
*/
|
||||
const oxipngVersion = (() => {
|
||||
const probe = spawnSync('oxipng', ['--version'], { encoding: 'utf8' });
|
||||
if (probe.status !== 0) {
|
||||
console.log('oxipng не найден: кадры пишутся как есть, без перепаковки');
|
||||
return null;
|
||||
}
|
||||
return String(probe.stdout || '').trim().split('\n')[0];
|
||||
})();
|
||||
|
||||
/** Пожать файл на месте и вернуть его новые байты. */
|
||||
const shrinkPng = (path, before) => {
|
||||
if (!oxipngVersion) return before;
|
||||
const run = spawnSync('oxipng', ['-o', '4', '--strip', 'safe', '--quiet', path]);
|
||||
if (run.status !== 0) {
|
||||
throw new Error(`oxipng не смог обработать ${path}: код ${run.status}`
|
||||
+ `${run.stderr ? ` · ${run.stderr}` : ''}`);
|
||||
}
|
||||
const after = readFileSync(path);
|
||||
console.log(` ${(before.length / 1024).toFixed(0)} КБ -> ${(after.length / 1024).toFixed(0)} КБ`);
|
||||
return after;
|
||||
};
|
||||
|
||||
|
||||
const roomCardClip = (page) => page.evaluate(() => {
|
||||
const card = window.__goldenCard;
|
||||
@@ -129,14 +171,18 @@ try {
|
||||
const image = await page.screenshot({
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
writeFileSync(resolve(OUTPUT, scenario.file), image);
|
||||
const path = resolve(OUTPUT, scenario.file);
|
||||
writeFileSync(path, image);
|
||||
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
|
||||
// которые лежат на диске, иначе приёмка отвергнет свой же кандидат.
|
||||
const stored = shrinkPng(path, image);
|
||||
scenarios[scenario.id] = {
|
||||
file: scenario.file,
|
||||
viewport: scenario.viewport,
|
||||
theme: scenario.theme,
|
||||
language: scenario.language,
|
||||
sourceSha256: fingerprint,
|
||||
imageSha256: sha256(image),
|
||||
imageSha256: sha256(stored),
|
||||
};
|
||||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||||
}
|
||||
@@ -147,6 +193,8 @@ try {
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||||
oxipng: oxipngVersion,
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* #314 anonymised dense-plan fixture.
|
||||
*
|
||||
* It preserves the structural shape of the owner's report without names,
|
||||
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
|
||||
* 24 intentional partitions, and one pre-existing two-point draft. The last
|
||||
* object is known debt and must remain the only invariant finding before and
|
||||
* after drawing another room.
|
||||
*/
|
||||
import { WALL_KEY_PITCH } from './wall-key.mjs';
|
||||
|
||||
export function makeV8DraftRegressionFixture() {
|
||||
const step = WALL_KEY_PITCH;
|
||||
const point = (x, y) => [x * step, y * step];
|
||||
const segmentIds = new Map();
|
||||
const wallSegments = [];
|
||||
const keyOf = (a, b) => {
|
||||
const left = `${a[0]},${a[1]}`;
|
||||
const right = `${b[0]},${b[1]}`;
|
||||
return left < right ? `${left}|${right}` : `${right}|${left}`;
|
||||
};
|
||||
const wallId = (a, b) => {
|
||||
const key = keyOf(a, b);
|
||||
let id = segmentIds.get(key);
|
||||
if (!id) {
|
||||
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
|
||||
segmentIds.set(key, id);
|
||||
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const rooms = Array.from({ length: 13 }, (_, index) => {
|
||||
const x0 = 4 + index * 12;
|
||||
const x1 = x0 + 12;
|
||||
const y0 = 4;
|
||||
const y1 = 16;
|
||||
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
|
||||
// Four split top edges make the catalogue population exactly 44 while
|
||||
// keeping all rooms valid and non-overlapping.
|
||||
if (index < 4) poly.push(point(x0 + 6, y1));
|
||||
poly.push(point(x0, y1));
|
||||
return {
|
||||
id: `room-314-${String(index + 1).padStart(2, '0')}`,
|
||||
name: `Room ${index + 1}`,
|
||||
poly,
|
||||
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
|
||||
};
|
||||
});
|
||||
|
||||
const partitions = Array.from({ length: 24 }, (_, index) => {
|
||||
const row = Math.floor(index / 8);
|
||||
const x = 4 + (index % 8) * 18;
|
||||
const y = 28 + row * 8;
|
||||
return {
|
||||
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
|
||||
a: point(x, y),
|
||||
b: point(x + 8, y + 2),
|
||||
cm: 15 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
if (wallSegments.length !== 44) {
|
||||
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
model_version: 8,
|
||||
spaces: [{
|
||||
id: 'space-314-dense',
|
||||
title: 'Dense regression fixture',
|
||||
cell_cm: 5,
|
||||
view_box: [0, 0, 400, 400],
|
||||
rooms,
|
||||
wall_segments: wallSegments,
|
||||
partitions,
|
||||
room_drafts: [{
|
||||
id: 'draft-314-known-debt',
|
||||
points: [point(4, 56), point(14, 64)],
|
||||
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
|
||||
}],
|
||||
}],
|
||||
markers: [],
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,12 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
- `scripts/golden-accept.mjs` wraps `accept` and additionally requires the
|
||||
reviewer to declare intent: every scenario whose capture differs from its
|
||||
accepted baseline must be named in `--expect-change=<id,id>`. Anything else
|
||||
that differs refuses the whole acceptance, before a single file is copied.
|
||||
This is what makes a local capture admissible (see below) and it
|
||||
simultaneously blocks the one-command "accept everything so CI turns green".
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -43,17 +49,57 @@ Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
|
||||
```bash
|
||||
npm run golden:accept -- --reviewed
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=wall-junctions-plan-t-dark
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
Future updates must still use the `golden-images` artifact produced by the Linux
|
||||
CI job as the review set: desktop font rasterisation can differ from the CI
|
||||
environment even with the same pinned Chromium. Pass its unpacked root via
|
||||
`--from=...` when accepting it locally.
|
||||
|
||||
## Capturing candidates without a second CI round trip (#334)
|
||||
|
||||
Accepting from the `golden-images` CI artifact still works and is still the
|
||||
safest route: unpack it and pass `--from=...`. It costs two full CI runs per
|
||||
visual fix, though — one to produce the artifact and one to verify the accepted
|
||||
baseline — and at matrix version 48 that toll is paid often.
|
||||
|
||||
A local capture is admissible instead, because admissibility is now *proved*
|
||||
rather than assumed. Desktop font rasterisation can differ from the runner, but
|
||||
it cannot differ quietly: it would move every text-bearing scenario, not only
|
||||
the ones under edit. So the rule is simply that the capture must reproduce every
|
||||
accepted baseline the reviewer did not intend to change:
|
||||
|
||||
```bash
|
||||
node scripts/golden-container.mjs # capture in the pinned image
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=<the scenarios you changed>
|
||||
```
|
||||
|
||||
If the environment is not pixel-equivalent, unrelated scenarios come out
|
||||
`different`, the wrapper names them and refuses. A wrong container tag or a
|
||||
mismatched font set therefore cannot corrupt baselines — it can only fail.
|
||||
|
||||
`scripts/golden-container.mjs` derives the image tag from the `playwright`
|
||||
version locked in `package-lock.json`, so the container Chromium equals the
|
||||
runner's; `--image=` overrides it when a distro-specific tag is needed
|
||||
(`...:v1.62.0-jammy`). The host `node_modules` is shadowed by an anonymous
|
||||
volume: the repository copy may be built for Windows, and `npm ci` inside the
|
||||
container would otherwise replace it with Linux binaries. The run does write
|
||||
`dist/` and the three bundle copies, exactly as a local `npm run bundle:sync`
|
||||
would.
|
||||
|
||||
Docker is not a requirement of the rule, only a convenience: any Linux
|
||||
environment that satisfies the parity condition qualifies, WSL included. The
|
||||
`chromium` string recorded in the manifest keeps the browser build itself
|
||||
pinned, and `golden:verify` rejects a manifest captured by a different build.
|
||||
|
||||
`scripts/golden-accept.mjs` deliberately wraps `demo/golden/accept.mjs` instead
|
||||
of replacing its checks: every `.mjs` under `demo/golden` belongs to
|
||||
`sourceFingerprint`, so editing the acceptance tool itself would declare the
|
||||
committed bundle, the documentation screenshot manifest and the baseline
|
||||
manifest stale — the very double round trip this change removes. Narrowing that
|
||||
corpus is worthwhile but separate: `scripts/source-fingerprint.mjs` is itself a
|
||||
build input, so any change to it forces one bundle rebuild.
|
||||
|
||||
Scenarios may also declare a semantic pixel region (for example, a receiving
|
||||
room that must contain warm light). `golden:capture` and `golden:verify` reject
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,138 +1,140 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 46,
|
||||
"acceptedAt": "2026-08-25T20:12:50.448Z",
|
||||
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
|
||||
"matrixVersion": 48,
|
||||
"acceptedAt": "2026-08-27T19:14:12.216Z",
|
||||
"sourceFingerprint": "d3735b5ee1f41b4fbf52f95046ab81f8a4ca9e15b02b8b9ca4ff8e7eff3bb179",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
|
||||
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
|
||||
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
|
||||
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
|
||||
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
|
||||
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
|
||||
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
|
||||
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
|
||||
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
|
||||
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
|
||||
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
|
||||
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
|
||||
"geometry-view-dark-fit": "1b80d2d0fe84cfe15f1fa29ccc2a2dcc66b4c5e62d1722c06c00442c78979be0",
|
||||
"geometry-view-light-fit": "91caf6621af00eb0cd16589e2fc79976cdca1e8467d0e86151ecbc46948612d9",
|
||||
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
|
||||
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
|
||||
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
|
||||
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
|
||||
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
|
||||
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "de7733e6aeef7cf7b05cca9c8f6bb4aa627fd16ab2d4198659e24ff9fa3b5bda",
|
||||
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
|
||||
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
|
||||
"room-label-parity-view-dark": "5ff9dff2da5c747cfab83ade2a929afadcf2eb9340182862da38ab36a35cd3c1",
|
||||
"room-label-parity-plan-dark": "6528f1edf07e51e470b1c05dcf5292888a14ef6e44d04f7119cfb11b06155c2b",
|
||||
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
|
||||
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "3f32bc1ed042475516f7e239aedd7fee11717b00fa2103ae9f1231e006d64015",
|
||||
"day-cycle-day-dark": "1a0f53c948ae669c7ab4e1dcb931c11e7c878974862637dd1f596011876a7ed4",
|
||||
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
|
||||
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
|
||||
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
|
||||
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
|
||||
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
|
||||
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
|
||||
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
|
||||
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
|
||||
"plan-snap-line-gaps-dark": "ff1b6bbf2e1b4272bb2f0e96d92136aa6960ee24acef1b63b4478e54441a4b58",
|
||||
"wall-junctions-plan-preview-light": "fa81fa6c6dba92578a4beee5ed29f407443a3bc61affa25c44fcfefab0c7070c",
|
||||
"wall-junctions-plan-t-dark": "f8a517d316a99ca9ee5b4ff56f515be15d440adfc551bbdaaa5e9d9a411a79bc",
|
||||
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
|
||||
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
|
||||
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
|
||||
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
|
||||
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
|
||||
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
|
||||
"safe-resize-handles-clamp-dark": "9cb3cb83d2aea4260fdbf1bc1d4281deecedeb9c9dbb0adbed921470b9aaf397",
|
||||
"space-tab-drop-before-light": "19c699fd035030318ce578fdb6427d6243cd3f1d955ea0fbf958cad385e9c7db",
|
||||
"space-tab-drop-after-dark": "1f802274c3015ad6422ee9b50a8d67a412407513da09de188be3db2f7a3873e4",
|
||||
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
|
||||
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
|
||||
"wall-junctions-plan-preview-light": "d92aad64d7aa8641c6743b51a7d13d9166b15c49f3d8aee17b8e61ff263a57a6",
|
||||
"wall-junctions-plan-t-dark": "45a98164ff93b01fbb7f3ac936ee8636c36dd791c09ac3b160b7c7ad803696d1",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "57ea7522c438e52292f226f975bb112930976b6e072b72322180d52ec0277f4f",
|
||||
"junction-patch-resilience-view-dark": "1f0df26ed72bcc5bef06a8366d7043ad40b4a2bb744fa5a64248c29eae968424",
|
||||
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
|
||||
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
|
||||
"multiwall-junction-bevel-view-dark": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
|
||||
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
|
||||
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
|
||||
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
|
||||
"junction-patch-resilience-plan-dark": "90191f637f4d1345d82c63977513524fde353c4e3f87de1a723bf7d3b1f8e91f",
|
||||
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
|
||||
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
|
||||
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
|
||||
"multiwall-junction-bevel-view-dark": "6b02d1d0c5af8b386791cc5764b3bc407129f6576b6d9591c64fd388a8a6865d",
|
||||
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
|
||||
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
|
||||
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "1157139aac7325b40e3869b8c3b5444509b4bc24a415ddf6f8d5402624f3a04a",
|
||||
"opening-placement-passage-thick-wall-dark": "6d4a9a953b70401ca11cd6ce96c2e0e0781bbfff71e758dc57958a4163e2712d",
|
||||
"opening-placement-passage-thick-wall-light": "b004b1395652c8a8eef5f5002b471ecf8f2f0347f158a14a056ff2b7382e4ea3",
|
||||
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
|
||||
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
|
||||
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
|
||||
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
|
||||
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
|
||||
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
|
||||
"geometry-devices-editor-dark": "4b91bdc00082e33d2529ea3cf7ce7af36383a84838f4a2c909463df24483b47b",
|
||||
"geometry-decor-editor-dark": "8b03bc87d2353cb6ef8fb4e2dec3f0d3be85de903ac9346171ea4aaa31170a66",
|
||||
"tray-wide-selection-en": "d140cf6a51c634a38d2e3661c4b61835d63673174fd934b85f9da0db4fa20d1a",
|
||||
"tray-wide-tool-ru": "806ff7c5ae3d2e708a4682833af7dc28a88001ef09e4ca564c91a5e6023b297d",
|
||||
"tray-medium-group-en": "5a1adbaa64d2b73f294003e2752a79008f7ec313ab41dd0514b6b38918b8c00e",
|
||||
"tray-medium-selection-ru": "53c7ea5099772fd14c2e27ad414c4d57d87c020866992b7a0b0dd62a22fbeb7e",
|
||||
"tray-narrow-palette-en": "5bc029bb7a4b077a45abc0c18f54360f781caa3684befb63cfa714efcfaf7edd",
|
||||
"tray-narrow-tool-ru": "32e27435ec9f0ce6db2e2800ffd71b6a5fcb1ab7dde17454a3ba7dda680b8a45",
|
||||
"geometry-diagonal-45-opening-dark": "a0167f3028cd8ef8283c92ae7e803940cf0eb0db45f1b137108a4e009741a5df",
|
||||
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
|
||||
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
|
||||
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
|
||||
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
|
||||
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
|
||||
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
|
||||
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
|
||||
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
|
||||
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
|
||||
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
|
||||
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "a57c8528f2075252dbc0ea11af23a6c33a3b533b4f80b43ab5eebaa918e12816",
|
||||
"geometry-decor-editor-dark": "2a0d4368e49b51fc4fcf5ee72679efb7b7e094fd8b6f47682b0696ec909c423e",
|
||||
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
|
||||
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
|
||||
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
|
||||
"tray-medium-selection-ru": "bd97bee66778a65ebb849331724a307d0b089d6cdf5f3b85c6c905c413d5290f",
|
||||
"tray-narrow-palette-en": "10073be9fdf658275f77643505c3523bcddce6a1890deda5b1820958b90613d1",
|
||||
"tray-narrow-tool-ru": "95fa0f072efb595e1f9fe3f3287e7ce346fdee640e90dd70e4f5e5b31049ef29",
|
||||
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
|
||||
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
|
||||
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
|
||||
"decor-over-opaque-hover-light": "eae79026256367a4cfaa5f99393ec9c66dbf23908c50ccc0f541c139d1baeda0",
|
||||
"decor-over-glow-base-dark": "72a83f08f832c219d8e0e1b6f94d689c250342aa95d30e8c1f79cf9f9548ccab",
|
||||
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
|
||||
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
|
||||
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
|
||||
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
|
||||
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
|
||||
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
|
||||
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
|
||||
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
|
||||
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
|
||||
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
|
||||
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
|
||||
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
|
||||
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
|
||||
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
|
||||
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
|
||||
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
|
||||
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
|
||||
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
|
||||
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
|
||||
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
|
||||
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
|
||||
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
|
||||
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
|
||||
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
|
||||
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
|
||||
"lighting-opaque-glow-two-doorways-dark": "ee3c53a408f6b62aa6b770925fa6948934e0c1c6e73877e379695bc260b0ceac",
|
||||
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
|
||||
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
|
||||
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
|
||||
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
|
||||
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
|
||||
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
|
||||
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
|
||||
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
|
||||
"junction-x-45-alternating-dark": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
|
||||
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
|
||||
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
|
||||
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
|
||||
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
|
||||
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
|
||||
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
|
||||
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
|
||||
"sharp-apex-legacy-dark": "93a46c66f7263a0325d0c4d50fa0677f3c902647ea5880960b99dc8fa04a01a5",
|
||||
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
|
||||
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
|
||||
"large-house-zoom-250-dark": "3f7abdd66a7b33301275983dbf83862bd24f91c65a7d731c411ebd3ed01b381d",
|
||||
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
|
||||
"device-dialog-desktop-en": "a6637997458ef1def3d30ffaccb6bbc9728dbc4ba2f02400a7334915a8101bbe",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
|
||||
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
|
||||
"decor-color-popover-mobile-ru": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
|
||||
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
|
||||
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
|
||||
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
|
||||
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "f3c01ae191af41fcc815d27586fe40f965ae3fddbf782553076703ce695457d6",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
|
||||
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
|
||||
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
|
||||
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
|
||||
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
|
||||
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
|
||||
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
|
||||
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 96 KiB |