Compare commits

..
Author SHA1 Message Date
claude[bot] aaa696cf38 docs: review document for #266
Issue: #266
User-Visible: no
2026-08-25 23:26:58 +00:00
Codex 7fd0ae4b2d docs: refresh the screenshot fingerprint after the cascade fix (#266)
Кадры прежние — golden и смоки подтверждают пиксельную идентичность.

Issue: #266
User-Visible: no
2026-08-26 02:04:17 +03:00
276 changed files with 2931 additions and 17956 deletions
+28 -78
View File
@@ -24,20 +24,10 @@ concurrency:
jobs:
performance:
# 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.
# Base and candidate stay sequential on one hosted runner. Splitting them
# across runners would turn machine variance into a false regression.
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
@@ -159,78 +149,38 @@ jobs:
npm --prefix candidate run build
(cd candidate && node scripts/bundle-sync.mjs)
npm --prefix baseline run build
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
(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
fi
- name: Capture base and candidate profile
- name: Enforce relative and absolute performance budgets
working-directory: candidate
env:
PROFILE: ${{ matrix.profile }}
run: |
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
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
- 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
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v7
with:
name: full-performance-${{ matrix.profile }}
name: full-performance
path: artifacts/performance
+2 -40
View File
@@ -287,16 +287,6 @@ 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 —
# тоже смена метки, и автор не ждёт впустую.
@@ -711,42 +701,14 @@ 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 "$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"
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
-6
View File
@@ -194,12 +194,6 @@ 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).
+5 -6
View File
@@ -1,6 +1,6 @@
# 🏠 House Plan — a live home map for Home Assistant
[![HACS Default](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
@@ -88,11 +88,10 @@ The [Background editor contract](docs/DECOR-EDITOR.md) and
[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
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**.
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**.
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
+5 -7
View File
@@ -1,6 +1,6 @@
# 🏠 House Plan — живой план дома для Home Assistant
[![HACS Default](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
@@ -91,12 +91,10 @@ Assistant — и устройства появятся на плане авто
[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
House Plan входит в основной каталог HACS — пользовательский репозиторий
добавлять не нужно.
1. Найдите **House Plan** в поиске HACS и установите.
2. Перезапустите Home Assistant.
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
+2 -2
View File
@@ -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.68.0"
VERSION = "1.67.0-rc.3"
# 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 = 9
PLAN_MODEL_VERSION = 7
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
@@ -136,10 +136,6 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for segment in _records(space.get("wall_segments")):
_lattice_point(segment.get("a"))
_lattice_point(segment.get("b"))
for opening in _records(space.get("openings")):
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
File diff suppressed because one or more lines are too long
+11 -70
View File
@@ -36,10 +36,6 @@ from .const import (
VERSION,
)
from .store import HouseplanData
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
@@ -68,8 +64,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_PLAN_ONLY_DASH = "—"
_IMPORT_ID_NAMESPACES = {
"space", "room", "marker", "partition", "wall", "opening", "decor", "draft",
"draft_segment", "column",
"space", "room", "marker", "partition", "opening", "decor", "draft", "column",
}
_MAX_IMPORT_LINEAGE_DEPTH = 16
_REPORT_EXAMPLE_LIMIT = 24
@@ -77,7 +72,6 @@ _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",
@@ -86,7 +80,7 @@ _SPACE_DISPLAY_FIELDS = (
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
"sun_rays",
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
)
@@ -255,7 +249,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
]
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
("walls", ("key", "cm", "a", "b")),
("wall_segments", ("id", "a", "b", "cm")),
("room_drafts", ("id", "points", "segments")),
("partitions", ("id", "a", "b", "cm")),
("wall_columns", ("id", "shape", "center", "cm", "angle")),
@@ -269,7 +262,7 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
selected = _pick_fields(item, fields)
if name == "room_drafts" and "segments" in selected:
selected["segments"] = [
_pick_fields(segment, ("id", "cm"))
_pick_fields(segment, ("cm",))
for segment in selected.get("segments") or []
]
values.append(selected)
@@ -591,14 +584,7 @@ def parse_document(raw: bytes) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ImportFailure("invalid_format", "Missing payload")
try:
config_candidate = _json_copy(payload.get("config"))
# The envelope is authoritative. Export deliberately omits this field
# inside payload.config, but v8 semantic validation still has to run
# before an import token can be issued.
if model > 0:
config_candidate["model_version"] = model
config = CONFIG_SCHEMA(config_candidate)
config.pop("model_version", None)
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
except (vol.Invalid, TypeError, ValueError) as err:
raise ImportFailure("invalid_config", str(err)) from err
if document["kind"] == "full":
@@ -1168,8 +1154,7 @@ def build_space_merge(
for sp in current_config.get("spaces") or []
for collection in (
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
sp.get("partitions") or [], sp.get("wall_segments") or [],
sp.get("wall_columns") or [],
sp.get("partitions") or [], sp.get("wall_columns") or [],
sp.get("openings") or [], sp.get("decor") or [],
)
for item in collection
@@ -1179,19 +1164,13 @@ def build_space_merge(
} | {
str(m.get("id")) for m in current_config.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for sp in current_config.get("spaces") or []
for draft in sp.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
} | {
str(value)
for incoming_space in spaces
for collection in (
(incoming_space,), incoming_space.get("rooms") or [],
incoming_space.get("room_drafts") or [],
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
incoming_space.get("partitions") or [],
incoming_space.get("wall_columns") or [],
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
)
@@ -1202,12 +1181,6 @@ def build_space_merge(
} | {
str(m.get("id")) for m in incoming.get("markers") or []
if m.get("id") is not None
} | {
str(segment.get("id"))
for incoming_space in spaces
for draft in incoming_space.get("room_drafts") or []
for segment in draft.get("segments") or []
if isinstance(segment, dict) and segment.get("id") is not None
}
old_space_id = str(space.get("id"))
_record_bounded_lineage(
@@ -1221,7 +1194,6 @@ def build_space_merge(
id_maps["space"][old_space_id] = new_space_id
for collection, prefix in (
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
("wall_segments", "wall"),
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
):
for item in space.get(collection) or []:
@@ -1233,18 +1205,6 @@ def build_space_merge(
id_map[old] = _fresh(prefix, old, used)
id_maps[prefix][old] = id_map[old]
item["id"] = id_map[old]
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if not isinstance(segment, dict) or segment.get("id") is None:
continue
old = str(segment["id"])
_record_bounded_lineage(
reference_report, bounded_seen, "draft_segment", old,
)
new = _fresh("draft_segment", old, used)
id_map[old] = new
id_maps["draft_segment"][old] = new
segment["id"] = new
old_room_ids = id_maps["room"]
for room in space.get("rooms") or []:
if room.get("open_to"):
@@ -1259,18 +1219,13 @@ def build_space_merge(
str(room.get("id", "?")), old_value,
)
room["open_to"] = remapped_open_to
if room.get("wall_ids"):
room["wall_ids"] = [
id_maps["wall"].get(str(value), str(value))
for value in room["wall_ids"]
]
# Opening ownership is part of the same space-local id graph. Remap the
# nested reference together with the partition itself; otherwise the
# invariant validator correctly rejects a copied space whose host.id still
# names the source partition.
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") in ("partition", "wall"):
if isinstance(host, dict) and host.get("kind") == "partition":
old_host_id = str(host.get("id"))
if old_host_id in id_map:
host["id"] = id_map[old_host_id]
@@ -1636,22 +1591,6 @@ def _materialize_import_candidate(
imported_config = prepared["payload"]["config"]
if confirmation_required:
_detach_missing(imported_config, content)
incoming_model = int(prepared.get("model_version", 0) or 0)
target_model = int(current_config.get("model_version", 0) or 0)
requires_v8 = incoming_model >= 8 or target_model >= 8
if requires_v8:
try:
# A v8 target cannot be downgraded by a v7 backup, while an
# incoming v8 graph must retain its already-persisted IDs. When
# both sides are v7 the accepted compatibility contract keeps the
# import v7 until a later structural write or explicit Optimize.
imported_config["model_version"] = incoming_model
imported_config, _ = commit_wall_segment_model(imported_config)
prepared["payload"]["config"] = imported_config
if prepared["kind"] == "space":
current_config, _ = commit_wall_segment_model(current_config)
except WallSegmentMigrationError as err:
raise ImportFailure(err.code, str(err)) from err
if prepared["kind"] == "space":
config, layout, details = build_space_merge(
prepared, current_config, current_layout, duplicate_policy,
@@ -1659,9 +1598,11 @@ def _materialize_import_candidate(
)
else:
config = imported_config
if not requires_v8 and incoming_model > 0:
config["model_version"] = incoming_model
_materialize_global_background(config)
model_version = prepared.get("model_version", 0)
if isinstance(model_version, int) and not isinstance(model_version, bool) \
and model_version > 0:
config["model_version"] = model_version
layout = _json_copy(prepared["payload"]["layout"])
if not same_source:
settings = config.get("settings") or {}
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.68.0"
"version": "1.67.0-rc.3"
}
+24 -333
View File
@@ -5,7 +5,6 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
from __future__ import annotations
from collections import Counter
import copy
import math
import re
@@ -78,137 +77,6 @@ class PartitionOpeningJambMarginError(ValueError):
)
class WallModelClientOutdatedError(ValueError):
"""A stale client tried to replace an already-migrated v8 document."""
code = "wall_model_client_outdated"
def _legacy_wall_model_projection(config: dict) -> dict:
"""Structural view an older client can faithfully round-trip."""
projected = copy.deepcopy(config)
projected.pop("model_version", None)
for space in projected.get("spaces") or []:
space.pop("wall_segments", None)
for room in space.get("rooms") or []:
room.pop("wall_ids", None)
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
segment.pop("id", None)
for opening in space.get("openings") or []:
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") == "wall":
opening.pop("host", None)
return projected
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 = []
for room in space.get("rooms") or []:
rooms.append({key: copy.deepcopy(room.get(key)) for key in (
"id", "poly", "x", "y", "w", "h", "open_to"
) if key in room})
result.append({
"id": space.get("id"),
"rooms": rooms,
"walls": copy.deepcopy(space.get("walls") or []),
"open_spans": copy.deepcopy(space.get("open_spans") or []),
})
return result
def _wall_catalog_projection(config: dict) -> list[dict]:
"""Authoritative part a current v8 structural writer must update."""
return [{
"id": space.get("id"),
"wall_segments": copy.deepcopy(space.get("wall_segments")),
} for space in config.get("spaces") or []]
def _restore_wall_model_fields(config: dict, previous: dict) -> None:
"""Hydrate only v8 identity fields after an exact legacy round-trip."""
config["model_version"] = previous["model_version"]
old_spaces = {str(space.get("id")): space for space in previous.get("spaces") or []}
for space in config.get("spaces") or []:
old_space = old_spaces.get(str(space.get("id")))
if old_space is None:
continue
space["wall_segments"] = copy.deepcopy(old_space.get("wall_segments") or [])
old_rooms = {str(room.get("id")): room for room in old_space.get("rooms") or []}
for room in space.get("rooms") or []:
old_room = old_rooms.get(str(room.get("id")))
if old_room is not None and "wall_ids" in old_room:
room["wall_ids"] = copy.deepcopy(old_room["wall_ids"])
old_drafts = {str(draft.get("id")): draft for draft in old_space.get("room_drafts") or []}
for draft in space.get("room_drafts") or []:
old_draft = old_drafts.get(str(draft.get("id")))
if old_draft is None:
continue
old_segments = old_draft.get("segments") or []
for index, segment in enumerate(draft.get("segments") or []):
if index < len(old_segments) and old_segments[index].get("id"):
segment["id"] = old_segments[index]["id"]
old_openings = {
str(opening.get("id")): opening for opening in old_space.get("openings") or []
}
for opening in space.get("openings") or []:
old_opening = old_openings.get(str(opening.get("id")))
old_host = (old_opening or {}).get("host")
if isinstance(old_host, dict) and old_host.get("kind") == "wall":
opening["host"] = copy.deepcopy(old_host)
def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
"""Do not let omission of model_version bypass v8 semantic validation."""
try:
old_model = int((previous or {}).get("model_version", 0))
new_model = int(config.get("model_version", 0))
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
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 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.
validated = CONFIG_SCHEMA(config)
config.clear()
config.update(validated)
return
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted model={new_model}"
)
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 room/contour geometry. Let the
# frontend show the dedicated reload guidance instead of a generic
# 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"
)
# One normalized canvas width contains this many physical grid cells. Keep in
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
# user setting.
@@ -652,39 +520,34 @@ def validate_partition_opening_hosts(
for opening in space.get("openings") or []:
opening_id = str(opening.get("id", ""))
old = old_openings.get(opening_id)
old_host = (old or {}).get("host")
host = opening.get("host")
if (old and isinstance(old_host, dict)
and old_host.get("kind") == "partition"):
same_partition = (
isinstance(host, dict) and host.get("kind") == "partition"
and str(host.get("id", "")) == str(old_host.get("id", ""))
)
residual_rehost = (
isinstance(host, dict) and host.get("kind") == "partition"
and not same_partition
)
proved = same_partition or (
allow_optimize_rehost and old_space is not None
and (_safe_optimize_residual_rehost(
space, old_space, opening, old
) if residual_rehost else _safe_optimize_partition_rehost(
space, old_space, opening, old
))
)
if not proved:
if old and old.get("host") is not None and opening.get("host") is None:
if not (allow_optimize_rehost and old_space
and _safe_optimize_partition_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; host changed"
f"space={space_id}; opening={opening_id}; host removed"
)
host = opening.get("host")
if host is None:
continue
if host.get("kind") != "partition":
continue
partition = partitions.get(str(host.get("id", "")))
if partition is None:
# SPACE_SCHEMA owns missing-host diagnostics.
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
if (allow_optimize_rehost and old and old_space
and isinstance(old_host, dict)
and old_host.get("kind") == "partition"
and isinstance(host, dict) and host.get("kind") == "partition"
and old_host.get("id") != host.get("id")
and not _safe_optimize_residual_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; residual host changed"
)
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
@@ -1047,11 +910,7 @@ MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
# later constant declaration.
MAX_WALLS = 200_000
MAX_WALL_SEGMENTS = 200_000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
@@ -1216,10 +1075,6 @@ ROOM_SCHEMA = vol.All(
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
vol.Optional("wall_ids"): vol.All(
[vol.All(str, vol.Length(min=1, max=64))],
vol.Length(min=3, max=MAX_POLY_POINTS),
),
},
extra=vol.ALLOW_EXTRA,
),
@@ -1391,26 +1246,6 @@ WALL_SCHEMA = vol.All(
)
def _wall_segment_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("wall segment endpoints must differ")
return value
WALL_SEGMENT_SCHEMA = vol.All(
vol.Schema(
{
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=0, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_segment_nonzero,
)
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
@@ -1426,10 +1261,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
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=0, max=100)),
},
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
@@ -1451,7 +1283,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=0, max=100)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -1493,22 +1325,11 @@ PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
extra=vol.PREVENT_EXTRA,
)
WALL_OPENING_HOST_SCHEMA = vol.Schema(
{
vol.Required("kind"): vol.Equal("wall"),
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
},
extra=vol.PREVENT_EXTRA,
)
OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_SCHEMA)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
@@ -1521,14 +1342,6 @@ def _space_geometry_invariants(value: dict) -> dict:
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
for draft in value.get("room_drafts", []):
for segment in draft.get("segments", []):
segment_id = segment.get("id")
if not segment_id:
continue
if segment_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(segment_id)
partitions = {
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
}
@@ -1537,8 +1350,6 @@ def _space_geometry_invariants(value: dict) -> dict:
host = opening.get("host")
if host is None:
continue
if host["kind"] != "partition":
continue
partition = partitions.get(host["id"])
if partition is None:
raise vol.Invalid("partition opening host must exist in the same space")
@@ -1619,7 +1430,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
vol.Optional("host"): OPENING_HOST_SCHEMA,
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
)
@@ -1628,10 +1439,6 @@ SPACE_SCHEMA = vol.All(vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
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)
),
@@ -1760,124 +1567,9 @@ MARKER_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
def _canonical_segment_key(a: list, b: list) -> tuple:
"""Endpoint-order-independent exact key after the shared canonicalizer."""
pa = (round(float(a[0]), 12), round(float(a[1]), 12))
pb = (round(float(b[0]), 12), round(float(b[1]), 12))
return (pa, pb) if pa <= pb else (pb, pa)
def _config_wall_segment_invariants(value: dict) -> dict:
"""Fail closed when a v8+ writer sends stale ids or wall projections."""
try:
model = int(value.get("model_version", 0))
except (TypeError, ValueError):
raise vol.Invalid("model_version must be an integer") from None
if model < 8:
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")
by_id = {segment["id"]: segment for segment in segments}
if len(by_id) != len(segments):
raise vol.Invalid("wall segment ids must be unique")
owners: dict[str, set[str]] = {segment_id: set() for segment_id in by_id}
for room in space.get("rooms", []):
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")
room_id = room["id"]
for index, segment_id in enumerate(wall_ids):
segment = by_id.get(segment_id)
if segment is None:
raise vol.Invalid("room wall id must reference the same space")
edge_key = _canonical_segment_key(poly[index], poly[(index + 1) % len(poly)])
if edge_key != _canonical_segment_key(segment["a"], segment["b"]):
raise vol.Invalid("room wall reference geometry must match its edge")
owners[segment_id].add(room_id)
if any(len(room_ids) not in (1, 2) for room_ids in owners.values()):
raise vol.Invalid("wall segment must have one or two room owners")
expected_walls = {
_canonical_segment_key(segment["a"], segment["b"]): float(segment["cm"])
for segment in segments if float(segment["cm"]) > 0
}
actual_walls: dict[tuple, float] = {}
for wall in space.get("walls", []):
if "a" not in wall or "b" not in wall:
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("wall compatibility projection must be unique")
actual_walls[key] = float(wall["cm"])
if actual_walls != expected_walls:
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")
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:
# #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
if abs(x - float(opening["x"])) > 2e-8 or abs(y - float(opening["y"])) > 2e-8:
raise vol.Invalid("wall opening geometry must match its host")
expected_angle = math.degrees(math.atan2(
float(segment["b"][1]) - float(segment["a"][1]),
float(segment["b"][0]) - float(segment["a"][0]),
))
if _angle_delta_mod_180(float(opening["angle"]), expected_angle) > 8:
raise vol.Invalid("wall opening angle must match its host")
span = ((segment["b"][0] - segment["a"][0]) ** 2
+ (segment["b"][1] - segment["a"][1]) ** 2) ** 0.5
along = t * span
length = float(opening["length"])
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
raise vol.Invalid("wall opening must fit inside its host")
lo, hi = along - length / 2, along + length / 2
occupied = wall_opening_intervals.setdefault(host["id"], [])
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9
for old_lo, old_hi in occupied):
raise vol.Invalid("wall openings must not overlap")
occupied.append((lo, hi))
return value
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional("model_version"): vol.All(
vol.Coerce(int), vol.Range(min=0, max=1_000_000)
),
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
@@ -1912,5 +1604,4 @@ CONFIG_SCHEMA = vol.All(
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
)
@@ -1,684 +0,0 @@
"""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
to upgrade v7 backups before validating/remapping them.
"""
from __future__ import annotations
import base64
import copy
import hashlib
import math
import uuid
from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
GRID_STEP_N = 1 / 240
EPS = 1e-9
class WallSegmentMigrationError(ValueError):
"""The candidate cannot be upgraded without guessing ownership."""
code = "wall_model_migration_blocked"
def __init__(self, reason: str, detail: str = "") -> None:
self.reason = reason
super().__init__(f"{reason}: {detail}" if detail else reason)
def _point_key(point: list[float]) -> str:
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
def _span_key(a: list[float], b: list[float]) -> str:
ka, kb = _point_key(a), _point_key(b)
return f"{ka}|{kb}" if ka < kb else f"{kb}|{ka}"
def _canonical_span(a: list[float], b: list[float]) -> tuple[list[float], list[float]]:
left, right = ([float(a[0]), float(a[1])], [float(b[0]), float(b[1])])
return (left, right) if _point_key(left) <= _point_key(right) else (right, left)
def _length(a: list[float], b: list[float]) -> float:
return math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
def _project_t(point: list[float], a: list[float], b: list[float]) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
denominator = dx * dx + dy * dy
if denominator <= EPS * EPS:
return 0
return ((float(point[0]) - float(a[0])) * dx
+ (float(point[1]) - float(a[1])) * dy) / denominator
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
t = max(0.0, min(1.0, _project_t(point, a, b)))
return math.hypot(
float(point[0]) - (float(a[0]) + (float(b[0]) - float(a[0])) * t),
float(point[1]) - (float(a[1]) + (float(b[1]) - float(a[1])) * t),
)
def _collinear_overlap(
a: list[float], b: list[float], c: list[float], d: list[float], epsilon: float = EPS,
) -> float:
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
length = math.hypot(dx, dy)
if length <= epsilon:
return 0
cross_c = abs((float(c[0]) - float(a[0])) * dy
- (float(c[1]) - float(a[1])) * dx) / length
cross_d = abs((float(d[0]) - float(a[0])) * dy
- (float(d[1]) - float(a[1])) * dx) / length
if cross_c > epsilon or cross_d > epsilon:
return 0
tc, td = _project_t(c, a, b), _project_t(d, a, b)
return max(0.0, min(1.0, max(tc, td)) - max(0.0, min(tc, td))) * length
def _room_poly(room: dict[str, Any]) -> list[list[float]]:
poly = room.get("poly")
if isinstance(poly, list) and len(poly) >= 3:
return [[float(point[0]), float(point[1])] for point in poly]
if all(isinstance(room.get(key), (int, float)) and not isinstance(room.get(key), bool)
for key in ("x", "y", "w", "h")):
x, y, width, height = (float(room[key]) for key in ("x", "y", "w", "h"))
return [[x, y], [x + width, y], [x + width, y + height], [x, y + height]]
return []
def _shared_boundaries(first: list[list[float]], second: list[list[float]]) -> list[list[float]]:
result: list[list[float]] = []
epsilon = GRID_STEP_N * 0.04
for index, start in enumerate(first):
end = first[(index + 1) % len(first)]
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length < epsilon:
continue
ux, uy = dx / length, dy / length
for other_index, other_start in enumerate(second):
other_end = second[(other_index + 1) % len(second)]
tolerance = max(epsilon, length * 1e-6)
distances = [
abs((point[0] - start[0]) * uy - (point[1] - start[1]) * ux)
for point in (other_start, other_end)
]
if max(distances) > tolerance:
continue
t1 = (other_start[0] - start[0]) * ux + (other_start[1] - start[1]) * uy
t2 = (other_end[0] - start[0]) * ux + (other_end[1] - start[1]) * uy
lo, hi = max(0.0, min(t1, t2)), min(length, max(t1, t2))
if hi - lo > epsilon:
result.append([
start[0] + ux * lo, start[1] + uy * lo,
start[0] + ux * hi, start[1] + uy * hi,
])
return result
def _wall_direction(a: list[float], b: list[float]) -> tuple[float, float]:
dx, dy = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dy)
if length < 1e-12:
return 1.0, 0.0
dx, dy = dx / length, dy / length
if dx < -1e-12 or (abs(dx) <= 1e-12 and dy < 0):
dx, dy = -dx, -dy
return dx, dy
def _js_round(value: float) -> int:
return math.floor(value + 0.5)
def _quantize(value: float, pitch: float = GRID_STEP_N) -> float:
return _js_round(value / pitch) * pitch
def _wall_key(a: list[float], b: list[float]) -> str:
midpoint_x = _quantize((float(a[0]) + float(b[0])) / 2)
midpoint_y = _quantize((float(a[1]) + float(b[1])) / 2)
dx, dy = _wall_direction(a, b)
angle = math.atan2(dy, dx)
if angle < 0:
angle += math.pi
angle = _js_round(angle * 1800) / 1800
return f"{midpoint_x:.6f},{midpoint_y:.6f}@{angle:.4f}"
def deterministic_wall_segment_id(
space_id: str, a: list[float], b: list[float], owners: list[str], salt: str = "",
) -> str:
ca, cb = _canonical_span(a, b)
seed = (f"{space_id}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(owners))}{salt}")
encoded = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
return f"wall-{encoded[:20]}"
def _translated_segment_delta(
a: list[float], b: list[float], previous: dict[str, Any],
) -> tuple[float, float] | None:
def matches(pa: list[float], pb: list[float]) -> tuple[float, float] | None:
dx, dy = a[0] - pa[0], a[1] - pa[1]
if abs((b[0] - pb[0]) - dx) <= EPS and abs((b[1] - pb[1]) - dy) <= EPS:
return dx, dy
return None
return matches(previous["a"], previous["b"]) or matches(previous["b"], previous["a"])
def _atomize(
space: dict[str, Any], old: dict[str, dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
rooms = space.get("rooms") or []
room_polys = {str(room.get("id", "")): _room_poly(room) for room in rooms}
global_breaks: list[list[float]] = []
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"]))
atoms_by_key: dict[str, dict[str, Any]] = {}
next_rooms: list[dict[str, Any]] = []
epsilon = GRID_STEP_N * 0.04
for raw_room in rooms:
room_id = str(raw_room.get("id", ""))
original = room_polys.get(room_id) or []
if not room_id or len(original) < 3:
raise WallSegmentMigrationError("invalid-room", room_id)
breaks = list(global_breaks)
for other_id, other_poly in room_polys.items():
if other_id == room_id:
continue
for shared in _shared_boundaries(original, other_poly):
breaks.extend(([shared[0], shared[1]], [shared[2], shared[3]]))
poly: list[list[float]] = []
parents: list[int] = []
for index, a in enumerate(original):
b = original[(index + 1) % len(original)]
poly.append(list(a))
parents.append(index)
length = _length(a, b)
if length < epsilon * 2:
raise WallSegmentMigrationError("zero-length", room_id)
gap = min(0.499, epsilon * 2 / length)
positions: list[float] = []
for breakpoint in breaks:
if _distance_to_segment(breakpoint, a, b) > epsilon:
continue
t = _project_t(breakpoint, a, b)
if t <= gap or t >= 1 - gap:
continue
if any(abs(existing - t) * length <= epsilon * 2 for existing in positions):
continue
positions.append(t)
for t in sorted(positions):
poly.append([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
parents.append(index)
old_ids = raw_room.get("wall_ids") if isinstance(raw_room.get("wall_ids"), list) else []
indexed_lineage = len(old_ids) == len(original)
rigid_delta: tuple[float, float] | None = None
rigid_indexed_lineage = indexed_lineage
if rigid_indexed_lineage:
for index, a in enumerate(original):
previous = old.get(old_ids[index])
delta = _translated_segment_delta(
a, original[(index + 1) % len(original)], previous,
) if previous else None
if delta is None or (rigid_delta is not None and (
abs(delta[0] - rigid_delta[0]) > EPS
or abs(delta[1] - rigid_delta[1]) > EPS
)):
rigid_indexed_lineage = False
break
rigid_delta = delta
wall_keys: list[str] = []
for index, a in enumerate(poly):
b = poly[(index + 1) % len(poly)]
key = _span_key(a, b)
atom = atoms_by_key.setdefault(key, {
"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(), "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(
a, b, previous["a"], previous["b"]
) > EPS:
atom["preferred"].add(old_ids[parent_index])
else:
atom["positional"].add(old_ids[parent_index])
atom["preferred_carriers"][old_ids[parent_index]] = {
"a": list(original[parent_index]),
"b": list(original[(parent_index + 1) % len(original)]),
}
wall_keys.append(key)
next_room = {key: copy.deepcopy(value) for key, value in raw_room.items() if key != "wall_ids"}
next_room["poly"] = poly
next_room["wall_ids"] = wall_keys
next_rooms.append(next_room)
return sorted(atoms_by_key.values(), key=lambda atom: atom["key"]), next_rooms
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"])
for wall in space.get("walls") or []:
try:
cm = float(wall.get("cm", 0))
except (TypeError, ValueError):
continue
if cm <= 0:
continue
exact_key = wall.get("key") == query_key or wall.get("key") in atom["parent_keys"]
covers = False
if isinstance(wall.get("a"), list) and isinstance(wall.get("b"), list):
overlap = _collinear_overlap(atom["a"], atom["b"], wall["a"], wall["b"])
covers = overlap >= query_length - EPS
if exact_key or covers:
candidates.append(max(1.0, min(100.0, cm)))
unique = {round(value, 9) for value in candidates}
if len(unique) > 1:
raise WallSegmentMigrationError("thickness-conflict", atom["key"])
if candidates:
return candidates[0]
if previous is not None and float(previous.get("cm", 0)) > 0:
return float(previous["cm"])
return 0.0
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
result: set[str] = set()
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in space.get(name) or []:
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
result.add(item["id"])
for draft in space.get("room_drafts") or []:
for segment in draft.get("segments") or []:
if isinstance(segment, dict) and isinstance(segment.get("id"), str) and segment["id"]:
result.add(segment["id"])
return result
def _fresh_wall_segment_id(used: set[str]) -> str:
for _attempt in range(1000):
segment_id = f"wall-{uuid.uuid4()}"
if segment_id not in used:
return segment_id
raise WallSegmentMigrationError("duplicate-id", "id factory exhausted")
def _assign_lineage(
space: dict[str, Any], atoms: list[dict[str, Any]], old: dict[str, dict],
initial_migration: bool,
) -> None:
old_by_key = {_span_key(segment["a"], segment["b"]): segment for segment in old.values()}
host_counts: dict[str, int] = {}
for opening in space.get("openings") or []:
host = opening.get("host") if isinstance(opening, dict) else None
if isinstance(host, dict) and host.get("kind") == "wall":
host_counts[str(host.get("id"))] = host_counts.get(str(host.get("id")), 0) + 1
proposals: dict[str, dict[str, Any]] = {}
for atom in atoms:
if len(atom["preferred"]) > 1:
raise WallSegmentMigrationError("duplicate-id", ",".join(sorted(atom["preferred"])))
preferred_id = next(iter(atom["preferred"]), None)
preferred = old.get(preferred_id) if preferred_id else None
if preferred:
proposals[atom["key"]] = preferred
continue
carrier = atom["preferred_carriers"].get(preferred_id) if preferred_id else None
if preferred_id and carrier:
proposals[atom["key"]] = {
"id": preferred_id, "a": carrier["a"], "b": carrier["b"], "cm": 0,
}
continue
if atom["key"] in old_by_key:
proposals[atom["key"]] = old_by_key[atom["key"]]
continue
overlaps = [segment for segment in old.values()
if _collinear_overlap(atom["a"], atom["b"], segment["a"], segment["b"]) > EPS]
overlaps.sort(key=lambda segment: (
-host_counts.get(str(segment["id"]), 0),
-_length(segment["a"], segment["b"]), str(segment["id"]),
))
if overlaps:
proposals[atom["key"]] = overlaps[0]
elif len(atom["positional"]) == 1:
positional = old.get(next(iter(atom["positional"])))
if positional:
proposals[atom["key"]] = positional
by_id: dict[str, list[dict[str, Any]]] = {}
for atom in atoms:
proposal = proposals.get(atom["key"])
if proposal:
by_id.setdefault(str(proposal["id"]), []).append(atom)
for segment_id, candidates in by_id.items():
old_segment = old.get(segment_id) or proposals.get(candidates[0]["key"])
if old_segment is None:
raise WallSegmentMigrationError("duplicate-id", segment_id)
midpoint = [
(old_segment["a"][0] + old_segment["b"][0]) / 2,
(old_segment["a"][1] + old_segment["b"][1]) / 2,
]
candidates.sort(key=lambda atom: (
0 if _distance_to_segment(midpoint, atom["a"], atom["b"]) <= EPS else 1,
0 if _distance_to_segment(old_segment["a"], atom["a"], atom["b"]) <= EPS else 1,
atom["key"],
))
candidates[0]["id"] = segment_id
used = _non_catalog_ids(space)
for atom in atoms:
if not atom.get("id"):
continue
if atom["id"] in used:
raise WallSegmentMigrationError("duplicate-id", atom["id"])
used.add(atom["id"])
unassigned = [atom for atom in atoms if not atom.get("id")]
if initial_migration:
seeds: list[tuple[str, str, str, dict[str, Any]]] = []
for atom in unassigned:
ca, cb = _canonical_span(atom["a"], atom["b"])
seed = (f"{space.get('id', '')}|{_point_key(ca)}|{_point_key(cb)}|"
f"{','.join(sorted(atom['owners']))}")
digest = base64.b32encode(hashlib.sha256(seed.encode()).digest()).decode().lower()
seeds.append((digest, atom["key"], seed, atom))
full_digests: dict[str, str] = {}
for digest, _key, seed, atom in sorted(seeds):
if digest in full_digests and full_digests[digest] != seed:
raise WallSegmentMigrationError("duplicate-id", digest)
full_digests[digest] = seed
base = f"wall-{digest[:20]}"
suffix, segment_id = 1, base
while segment_id in used:
suffix += 1
segment_id = f"{base}-{suffix}"
atom["id"] = segment_id
used.add(segment_id)
else:
for atom in unassigned:
atom["id"] = _fresh_wall_segment_id(used)
used.add(atom["id"])
def _angle_matches(a: list[float], b: list[float], angle: float) -> bool:
dx, dy = _wall_direction(a, b)
wall_angle = math.degrees(math.atan2(dy, dx))
difference = abs((wall_angle - angle + 90) % 180 - 90)
return difference <= 8
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":
continue
try:
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
and _distance_to_segment(centre, segment["a"], segment["b"])
<= GRID_STEP_N * 0.02
and _angle_matches(segment["a"], segment["b"], angle)
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:
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", "")))
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:
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
if not segment_id or segment_id in old:
raise WallSegmentMigrationError("duplicate-id", segment_id)
old[segment_id] = segment
atoms, rooms = _atomize(space, old)
_assign_lineage(space, atoms, old, initial_migration)
segments = []
for atom in atoms:
previous = old.get(atom["id"])
segment = copy.deepcopy(previous) if previous else {}
segment.update({
"id": atom["id"], "a": list(atom["a"]), "b": list(atom["b"]),
"cm": _thickness(space, atom, previous),
})
segments.append(segment)
id_by_key = {atom["key"]: atom["id"] for atom in atoms}
for room in rooms:
room["wall_ids"] = [id_by_key[key] for key in room["wall_ids"]]
space["rooms"] = rooms
space["wall_segments"] = segments
walls = [{
"key": _wall_key(segment["a"], segment["b"]),
"cm": segment["cm"], "a": list(segment["a"]), "b": list(segment["b"]),
} for segment in segments if float(segment["cm"]) > 0]
if walls:
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",
"wall_columns", "wall_segments")
for item in space.get(name) or []
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
}
for draft in space.get("room_drafts") or []:
for index, segment in enumerate(draft.get("segments") or []):
if isinstance(segment.get("id"), str) and segment["id"]:
if segment["id"] in draft_used:
raise WallSegmentMigrationError("duplicate-id", segment["id"])
draft_used.add(segment["id"])
continue
try:
a, b = draft["points"][index], draft["points"][index + 1]
except (KeyError, IndexError, TypeError):
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
if initial_migration:
base = deterministic_wall_segment_id(
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
)
suffix, segment["id"] = 1, base
while segment["id"] in draft_used:
suffix += 1
segment["id"] = f"{base}-{suffix}"
else:
segment["id"] = _fresh_wall_segment_id(draft_used)
draft_used.add(segment["id"])
_host_openings(space, segments, initial_migration)
return sum(1 for segment in segments if segment["id"] not in old)
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
"""Return one migrated deep copy and the count of newly assigned wall ids."""
candidate = canonicalize_config_geometry(copy.deepcopy(config))
if not isinstance(candidate, dict):
raise WallSegmentMigrationError("invalid-room")
migrated = 0
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
for space in candidate.get("spaces") or []:
migrated += _migrate_space(space, initial_migration)
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
return canonicalize_config_geometry(candidate), migrated
+6 -58
View File
@@ -24,7 +24,6 @@ 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,
@@ -61,19 +60,13 @@ from .virtual_lights import (
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError, sanitize_filename,
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
validate_marker_value_badges, valid_space_id,
)
@@ -1158,9 +1151,6 @@ 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,
},
)
@@ -1269,9 +1259,7 @@ def _missing_internal_attachments(
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
# Semantic stale-client detection needs the stored v8 document and
# therefore runs inside the write lock before CONFIG_SCHEMA.
vol.Required("config"): dict,
vol.Required("config"): CONFIG_SCHEMA,
vol.Optional("expected_rev"): int,
}
)
@@ -1321,10 +1309,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_wall_model_transition(msg["config"], data.get("config"))
validated_config = CONFIG_SCHEMA(msg["config"])
msg["config"].clear()
msg["config"].update(validated_config)
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"))
@@ -1332,13 +1316,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
validate_partition_opening_hosts(msg["config"], data.get("config"))
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -1596,7 +1577,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): dict,
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
@@ -1641,35 +1622,6 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_wall_model_transition(msg["config"], config_data.get("config"))
try:
submitted_model = int(msg["config"].get("model_version", 0) or 0)
except (TypeError, ValueError):
submitted_model = WALL_SEGMENT_MODEL_VERSION
# Optimize is an explicit structural writer and therefore the
# server-side v7 -> v8 barrier as well. Current v8 candidates are
# validated verbatim: the backend must never invent lineage for a
# graph already authored by a current client.
candidate_config = CONFIG_SCHEMA(msg["config"])
# Keep the established public validation contract ahead of the
# structural migration barrier. A malformed passage can also make
# wall-host materialisation impossible, but callers must still get
# the actionable `invalid_passage_fields` code rather than the
# generic wall-model blocker.
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=(",", ":")))
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
msg["config"].clear()
msg["config"].update(validated_config)
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"))
@@ -1680,14 +1632,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
)
except (
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
WallSegmentMigrationError,
PartitionOpeningJambMarginError,
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
+1 -1
View File
@@ -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'), targetRoot,
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
-1
View File
@@ -43,7 +43,6 @@ 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({
-86
View File
@@ -1,86 +0,0 @@
/**
* #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: {},
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+101 -102
View File
@@ -1,139 +1,138 @@
{
"schema": 1,
"matrixVersion": 47,
"acceptedAt": "2026-08-26T20:41:35.180774Z",
"sourceFingerprint": "80bd29ef9692067b706f221eaf8d62b4030e3a51b91bc9cff8d854aaee373700",
"matrixVersion": 46,
"acceptedAt": "2026-08-25T20:12:50.448Z",
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
"chromium": "151.0.7922.34",
"scenarios": {
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"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",
"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",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"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",
"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",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"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",
"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",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"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-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-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"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",
"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",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"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",
"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",
"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": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
"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",
"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",
"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": "fcdd138e396654e3517927c27d2062663c61860e8b9903471fa44651a4cbdefd",
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "f3c01ae191af41fcc815d27586fe40f965ae3fddbf782553076703ce695457d6",
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0"
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More