Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2294d46f3f | ||
|
|
722f26a3c5 | ||
|
|
4a833c5e13 | ||
|
|
ba66698f55 | ||
|
|
ef32d2326f | ||
|
|
536b750d03 | ||
|
|
c46c714c58 | ||
|
|
6e1aea3ae7 | ||
|
|
a3e329950a | ||
|
|
78850a8733 | ||
|
|
1ac91e43fd | ||
|
|
6a3ac52258 | ||
|
|
1a8b480355 | ||
|
|
ec957a7c88 | ||
|
|
557c44c392 | ||
|
|
7181535dec | ||
|
|
fb162953a9 | ||
|
|
244fddeb3b | ||
|
|
ad73280b1b | ||
|
|
5ad0c1cdc3 | ||
|
|
b8976415ac | ||
|
|
296df95e32 | ||
|
|
acc30c2da7 | ||
|
|
ca383febef | ||
|
|
39eda1e136 | ||
|
|
caf6e6c355 | ||
|
|
4d9542b138 | ||
|
|
69e410fea3 | ||
|
|
1edf5cb3a7 | ||
|
|
9b7a700fd6 | ||
|
|
780b7a6c7a | ||
|
|
674607694f | ||
|
|
fb8fdf479c | ||
|
|
3b915bd077 | ||
|
|
69facb9918 | ||
|
|
343af61a61 | ||
|
|
59a23de81b | ||
|
|
87ff906545 | ||
|
|
298030d47e | ||
|
|
0c3aacdd34 | ||
|
|
134afb658e | ||
|
|
6a96254956 | ||
|
|
de7554e9e0 | ||
|
|
8210af6472 | ||
|
|
376a2db599 | ||
|
|
6b9f5eea96 | ||
|
|
59bf4665f6 | ||
|
|
3a562f2bd6 | ||
|
|
aaf7611490 | ||
|
|
3d4e5580fa | ||
|
|
1c598a287a | ||
|
|
05ec0a1de8 | ||
|
|
b29472a887 | ||
|
|
0f36ef55d5 | ||
|
|
1d92193c5d | ||
|
|
dff476ff35 | ||
|
|
ee672dcd1a | ||
|
|
ffb232398a | ||
|
|
33a5b46990 | ||
|
|
f353be3d76 | ||
|
|
295548f0fd | ||
|
|
0f6583b51c | ||
|
|
5886069c3a | ||
|
|
6205c8b2d7 | ||
|
|
b6de84e528 | ||
|
|
5b7d37e911 | ||
|
|
f3fe86371c | ||
|
|
3ab6fa9f8a | ||
|
|
3d31d65735 | ||
|
|
9ff464fe09 | ||
|
|
8bfd56eb59 | ||
|
|
26ad7651fb | ||
|
|
9134d86266 | ||
|
|
f15bdc678b | ||
|
|
db4e0ef999 | ||
|
|
a66fb7ef53 | ||
|
|
5e35612bdd | ||
|
|
fa9d9b05e9 | ||
|
|
916a42e33a | ||
|
|
799289e73b | ||
|
|
73cf8ed01a | ||
|
|
1857cb69cf | ||
|
|
1fcbb0de6d | ||
|
|
b8a9f9b289 | ||
|
|
c821dad436 | ||
|
|
1f6aefd2a7 | ||
|
|
7273a3ad9a | ||
|
|
b542f44bd6 | ||
|
|
43f91a214b | ||
|
|
822e6223f7 | ||
|
|
1cdd4ed6de | ||
|
|
a5abfe8bd6 | ||
|
|
83ec6fb879 | ||
|
|
2710dca7d1 | ||
|
|
114dcec27c | ||
|
|
93054823c3 | ||
|
|
2364d6dd43 | ||
|
|
64e9bf8f6b | ||
|
|
069c4867ea | ||
|
|
ce18012fb9 | ||
|
|
0e18038c1b | ||
|
|
2347e8df88 | ||
|
|
5da7812099 | ||
|
|
7a73bcbc2b | ||
|
|
98a0a24bd4 | ||
|
|
676610c0cd | ||
|
|
9726f2dfdb | ||
|
|
ccdd47a6bd | ||
|
|
ed126d6a43 | ||
|
|
d34e56ba78 | ||
|
|
5e5dad277c | ||
|
|
ffe0f0c788 | ||
|
|
a78deb7ce7 | ||
|
|
05622d155b | ||
|
|
b4cccfdf63 | ||
|
|
7518cb2d4d | ||
|
|
f37efdad8a | ||
|
|
fb6cc3ca2d | ||
|
|
95fa23ca79 | ||
|
|
063026bc0b | ||
|
|
ad54716d21 | ||
|
|
cfc1bb063e | ||
|
|
20f5c39699 | ||
|
|
c75c77d571 | ||
|
|
0f54daf37b | ||
|
|
07402d64cc | ||
|
|
71ac4a6639 | ||
|
|
fc9f513082 | ||
|
|
3fff527e22 | ||
|
|
e1059e2e29 | ||
|
|
2f155a83b9 | ||
|
|
ce01d095a8 | ||
|
|
b336eee996 | ||
|
|
245466d167 | ||
|
|
4b414e26f7 | ||
|
|
b72b56ea45 | ||
|
|
7a6331d359 | ||
|
|
5f0a7f5065 | ||
|
|
fd4bf0e95e | ||
|
|
0b782ee714 | ||
|
|
0d62a48b1e | ||
|
|
3a49b14274 | ||
|
|
0d9320fe1d | ||
|
|
55f7126afd | ||
|
|
bf52524de3 | ||
|
|
7d094fd2ea | ||
|
|
c684f178cb | ||
|
|
cbdc248987 | ||
|
|
34335d1e83 | ||
|
|
afb6e151e5 | ||
|
|
5c466b6c01 | ||
|
|
4c93f28e73 | ||
|
|
30698d6ec4 | ||
|
|
b1b8fb9fab | ||
|
|
89ac6024bc | ||
|
|
2e3874f10d | ||
|
|
0e6fbfb00b | ||
|
|
de0867b001 | ||
|
|
9ec3a77ffa | ||
|
|
8d41651b1e | ||
|
|
0d328f6c0c | ||
|
|
c8d56b6c4d | ||
|
|
f2460dc81d | ||
|
|
a3a75ccbf3 | ||
|
|
6c1534f170 | ||
|
|
e85bae70f0 | ||
|
|
101cf70952 | ||
|
|
787891c4e7 | ||
|
|
2ade43520b | ||
|
|
cee4718ffa | ||
|
|
f0ea376aa9 | ||
|
|
bbac40eb8e | ||
|
|
ff623cd49d | ||
|
|
dc68868bd8 | ||
|
|
1134345444 | ||
|
|
729e2d05af | ||
|
|
489f68eea9 | ||
|
|
31d7a70e34 | ||
|
|
96318fb94f | ||
|
|
d3927f2e56 | ||
|
|
b2b2640751 | ||
|
|
d8c7652d0e | ||
|
|
8a82189368 | ||
|
|
8eef4e9c5a | ||
|
|
cc937de0b1 | ||
|
|
d3ed299d2a | ||
|
|
9c396935dc | ||
|
|
ec8dc6fde1 | ||
|
|
58c096cef1 | ||
|
|
e88abcb138 | ||
|
|
9dbdbec03d | ||
|
|
c179a89876 | ||
|
|
df38c5c93b | ||
|
|
b6f80c56f0 | ||
|
|
aac501516e | ||
|
|
8af5ebefd2 | ||
|
|
ef22c2c6fd | ||
|
|
a6ef664bef | ||
|
|
76d60b3e1b | ||
|
|
a1361974a3 | ||
|
|
f1c76aa415 | ||
|
|
3df1dcf5a3 | ||
|
|
eef4e0d6d4 | ||
|
|
1575b1e890 | ||
|
|
e603c5496f | ||
|
|
33dcd1ce62 | ||
|
|
7310ce0134 | ||
|
|
a2b4d32b2d | ||
|
|
9c5e1e2749 | ||
|
|
2143e88824 | ||
|
|
1f83f35a66 | ||
|
|
e7f2f2c174 | ||
|
|
9f54cbab75 | ||
|
|
51810164c2 | ||
|
|
9c0b276f43 | ||
|
|
26af611af1 | ||
|
|
19f536f361 | ||
|
|
67bcce8efb | ||
|
|
ab2f37aa4d | ||
|
|
2cf47fc133 | ||
|
|
ca58f0e3eb | ||
|
|
93cd45569d | ||
|
|
36992d68bd | ||
|
|
0f71d869e3 | ||
|
|
56dbb48868 | ||
|
|
13219307d5 | ||
|
|
8b4730df05 | ||
|
|
19332a915d | ||
|
|
fd4fc801ef | ||
|
|
6d094ca01b | ||
|
|
65e55872e8 | ||
|
|
68498e526e | ||
|
|
4b8f17bae5 | ||
|
|
136e0cb773 | ||
|
|
641c4e199e | ||
|
|
8a3a115f42 | ||
|
|
5526447070 | ||
|
|
5696e274fe | ||
|
|
ba63fccbb7 | ||
|
|
6558728519 | ||
|
|
7b66b1e9b3 | ||
|
|
2805c2c8a0 | ||
|
|
1b7c150dda | ||
|
|
2523c38a4e | ||
|
|
5ecfebcf67 | ||
|
|
e13e8ff9e9 | ||
|
|
01992a9a87 | ||
|
|
2d19ce04ad | ||
|
|
390f77bd65 | ||
|
|
d5670ad141 | ||
|
|
3cf8ed74db | ||
|
|
b9db5d4750 | ||
|
|
2cad797d2c | ||
|
|
e4dfef19e1 | ||
|
|
8d165fd1c2 | ||
|
|
23d208f30c | ||
|
|
e4139da3e6 | ||
|
|
3fb2edc904 | ||
|
|
4969f46e0e | ||
|
|
ed28951b9d | ||
|
|
2b7c98e6bc | ||
|
|
8187dd9bda | ||
|
|
89ac96a8af | ||
|
|
033ad11bf7 | ||
|
|
f7a19a35f1 | ||
|
|
451932c8e9 | ||
|
|
ab9a83e755 | ||
|
|
55db16df6e | ||
|
|
8221ad9b84 | ||
|
|
45066631ae | ||
|
|
4feeebc355 | ||
|
|
158601d67d | ||
|
|
671d1af4c3 | ||
|
|
67777e47c0 | ||
|
|
953f6756bc | ||
|
|
578eb5ebd3 | ||
|
|
874838c954 | ||
|
|
0ac92cb551 | ||
|
|
142ec8d1c7 | ||
|
|
1816042a1a | ||
|
|
8c03656412 | ||
|
|
fc81bd8a32 | ||
|
|
74d306b510 | ||
|
|
80dc5860ca | ||
|
|
04110bf741 | ||
|
|
55fed02d24 | ||
|
|
2fda6fa086 | ||
|
|
cd88633535 | ||
|
|
314b498e12 | ||
|
|
e03238ec1c | ||
|
|
474dbd62ca | ||
|
|
e1f0d2310a | ||
|
|
1d4bf9b018 | ||
|
|
0a52b6757e | ||
|
|
97acb0b415 | ||
|
|
e2c1dbec0d | ||
|
|
08dce8603d | ||
|
|
c4694acea9 | ||
|
|
6c5163c3c9 | ||
|
|
f2e8fe5e93 | ||
|
|
8750708c5b | ||
|
|
e6e80934e0 | ||
|
|
8eb351baa3 | ||
|
|
a4ef0e2088 | ||
|
|
3c754729f2 | ||
|
|
3f6338889e | ||
|
|
35ab1dc0c3 | ||
|
|
4ccf4a3ccc | ||
|
|
c8a3878568 | ||
|
|
0146d5787e | ||
|
|
feff436942 | ||
|
|
374081b490 | ||
|
|
e9bf11a8e3 | ||
|
|
66193c5862 | ||
|
|
894a4e5803 | ||
|
|
1d220628ff | ||
|
|
52c74301f9 | ||
|
|
01512081db | ||
|
|
1245beaaf6 | ||
|
|
d33ae88da4 | ||
|
|
f6cb957bba | ||
|
|
f44df5d05d | ||
|
|
6983f2b088 | ||
|
|
f6cf2c93ae | ||
|
|
edf8b068ed | ||
|
|
6551c5d53d | ||
|
|
e102f34156 | ||
|
|
7fd9ca0f6a | ||
|
|
6e1007a363 | ||
|
|
10fa0dc7fb | ||
|
|
f2c0721a9d | ||
|
|
e8a4db7972 | ||
|
|
406e46ecb1 | ||
|
|
4201644d33 | ||
|
|
17086d3cf2 | ||
|
|
74fdf2182c | ||
|
|
2d8ade3b2b | ||
|
|
6f67d53665 | ||
|
|
e333f0b2f1 | ||
|
|
f472af88a3 | ||
|
|
a7618151cc | ||
|
|
10ca913fad | ||
|
|
849b3e5f76 | ||
|
|
bca3bfd52c | ||
|
|
2ad20dca2b | ||
|
|
0bca6dfb14 | ||
|
|
8877e8c6d6 | ||
|
|
6a4e665d33 | ||
|
|
5272287671 | ||
|
|
c14dcecb98 | ||
|
|
523190d8f4 | ||
|
|
d5659478aa | ||
|
|
c654e0ec42 | ||
|
|
d8696315eb | ||
|
|
ed606c0f4a | ||
|
|
89658c20fe | ||
|
|
a988f7c6f1 | ||
|
|
d73790aaab | ||
|
|
07bd1a6781 | ||
|
|
0963f5b4dd | ||
|
|
a3e5f30a3c | ||
|
|
38ea8a9b9e | ||
|
|
93fad8cfb4 | ||
|
|
c0c30d645a | ||
|
|
cd9916d72a | ||
|
|
8aa7fc970c | ||
|
|
bb81ede47c | ||
|
|
79285ab1d2 | ||
|
|
c123757f50 | ||
|
|
495043791e | ||
|
|
598e7437f2 | ||
|
|
df1a8330f4 | ||
|
|
8156f80140 | ||
|
|
930a911a36 | ||
|
|
4dd074d9a3 | ||
|
|
bdd4f38cb9 | ||
|
|
4412f905bd | ||
|
|
e68b494555 | ||
|
|
a548d4f17d | ||
|
|
9c5b66c3e4 | ||
|
|
448ceea399 | ||
|
|
241821c40e | ||
|
|
b02a37123d | ||
|
|
03b45f2e37 | ||
|
|
a5a0f6da2c | ||
|
|
2e22ed7938 | ||
|
|
cd0a682ad7 | ||
|
|
6335b0e519 | ||
|
|
8b227962b1 | ||
|
|
1bbd53ee52 | ||
|
|
7be7f8cd99 | ||
|
|
9e35650470 | ||
|
|
29a0305558 | ||
|
|
ff2f2063cf | ||
|
|
b5519e997b | ||
|
|
82fe98bcd3 | ||
|
|
57fdfdbd83 | ||
|
|
496e79a6df | ||
|
|
434995beb2 | ||
|
|
a605611105 | ||
|
|
ec315ad7c1 | ||
|
|
cfc8f2114d | ||
|
|
d5a0452fb5 | ||
|
|
ca5166430b | ||
|
|
5c2c2578e1 | ||
|
|
a06c94b967 | ||
|
|
240316a639 | ||
|
|
678b1996fc | ||
|
|
ca8390366f | ||
|
|
8c4dd16754 | ||
|
|
7cef3014a2 | ||
|
|
7bcc5b285b | ||
|
|
29f6ad2969 | ||
|
|
3bf5a1db26 | ||
|
|
423c20cd23 | ||
|
|
62dd02d823 | ||
|
|
e04e168ac4 | ||
|
|
762e9f4b32 | ||
|
|
1daa852622 | ||
|
|
8d2a0505e3 | ||
|
|
b05eb68c3a | ||
|
|
22d0f05650 | ||
|
|
4266b625fe | ||
|
|
57a967b9a5 | ||
|
|
2f9348adf1 | ||
|
|
793a6486d8 | ||
|
|
b6c3c5ced3 | ||
|
|
d27c3f023e | ||
|
|
735b98c8ca | ||
|
|
944a36ee67 | ||
|
|
839e3316c3 | ||
|
|
61c401b810 | ||
|
|
e246d32580 | ||
|
|
35bb2b58b7 | ||
|
|
5fdedf39d6 | ||
|
|
41325a852f |
@@ -1,4 +1,4 @@
|
||||
name: Announce release
|
||||
name: Анонс релиза
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
@@ -36,6 +36,7 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
name: Docs screenshots
|
||||
name: Скриншоты документации
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -24,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Mutation gate
|
||||
name: Мутационный гейт
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
@@ -31,6 +31,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты"
|
||||
runs-on: ubuntu-latest
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Full Performance
|
||||
name: Полные бенчмарки производительности
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
@@ -24,10 +24,21 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
# Base and candidate stay sequential on one hosted runner. Splitting them
|
||||
# across runners would turn machine variance into a false regression.
|
||||
name: Бенчмарки рендера и геометрии
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all five pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- blend
|
||||
- overlay
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
@@ -149,38 +160,78 @@ jobs:
|
||||
npm --prefix candidate run build
|
||||
(cd candidate && node scripts/bundle-sync.mjs)
|
||||
npm --prefix baseline run build
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
if [ -f baseline/scripts/bundle-sync.mjs ]; then
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
else
|
||||
echo "::notice::The comparison base predates bundle-sync.mjs; materializing its freshly built bundle with the legacy copy path."
|
||||
mkdir -p baseline/custom_components/houseplan/frontend baseline/demo/srv/assets
|
||||
cp baseline/dist/houseplan-card.js baseline/custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
fi
|
||||
|
||||
- name: Enforce relative and absolute performance budgets
|
||||
- name: Capture base and candidate profile
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
mkdir -p ../artifacts/performance
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance reports
|
||||
- name: Enforce relative and absolute performance budget
|
||||
working-directory: candidate
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
run: |
|
||||
case "$PROFILE" in
|
||||
large-house)
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
overlay)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance
|
||||
name: full-performance-${{ matrix.profile }}
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Process
|
||||
name: Ревью-конвейер
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
@@ -35,6 +35,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
@@ -162,6 +163,7 @@ jobs:
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
@@ -287,6 +289,16 @@ jobs:
|
||||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||||
|
||||
# Материал ревью — конкретный SHA (#312). Вердикт применим только к
|
||||
# нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан
|
||||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
@@ -701,14 +713,42 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev
|
||||
git checkout -q -B merge-into-dev "origin/$BRANCH"
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish prerelease
|
||||
name: Публикация пре-релиза (ручная)
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка и релизный контракт"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -198,6 +200,7 @@ jobs:
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -245,6 +248,7 @@ jobs:
|
||||
}
|
||||
|
||||
announce:
|
||||
name: Комментарий о публикации
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Attach HACS zip to release
|
||||
name: HACS-zip к релизу
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
@@ -19,6 +19,7 @@ permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
name: Собрать houseplan.zip и приложить к релизу
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release
|
||||
name: "Релиз: ассеты после зелёной проверки"
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка точного SHA тега"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -42,8 +43,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -71,6 +73,7 @@ jobs:
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Validate
|
||||
name: Проверка (CI)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,6 +22,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -34,6 +35,7 @@ jobs:
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
process-workflow-sync:
|
||||
name: "Процесс: process.yml идентичен в main и dev"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -53,6 +55,7 @@ jobs:
|
||||
fi
|
||||
|
||||
provenance:
|
||||
name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -76,6 +79,7 @@ jobs:
|
||||
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
|
||||
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
|
||||
process-gate:
|
||||
name: "Процессный гейт: диапазон, трейлеры, статусы issue"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -104,6 +108,7 @@ jobs:
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
@@ -158,6 +163,7 @@ jobs:
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -237,6 +243,7 @@ jobs:
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
name: "HACS: валидация репозитория"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -248,6 +255,7 @@ jobs:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
name: "Hassfest: манифест интеграции"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -257,6 +265,7 @@ jobs:
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -278,6 +287,7 @@ jobs:
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
@@ -350,6 +360,7 @@ jobs:
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -370,6 +381,7 @@ jobs:
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
@@ -432,6 +444,7 @@ jobs:
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
@@ -494,6 +507,7 @@ jobs:
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -194,6 +194,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
|
||||
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
|
||||
зафиксированным в начале разбора: во время ревью в ветку может прилететь
|
||||
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
|
||||
SHA материала ревью (допустим только собственный doc-коммит публикации
|
||||
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
@@ -281,7 +287,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите.
|
||||
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
|
||||
from ...)` должна оставаться выше финального блока трейлеров: перед push
|
||||
проверяем порядок через `git show -s --format=full HEAD`.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -88,10 +88,11 @@ The [Background editor contract](docs/DECOR-EDITOR.md) and
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. In HACS open **⋮ → Custom repositories**.
|
||||
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
|
||||
3. Install House Plan and restart Home Assistant.
|
||||
4. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
House Plan is in the HACS default catalog — no custom repository needed.
|
||||
|
||||
1. In HACS search for **House Plan** and install it.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
@@ -91,10 +91,12 @@ Assistant — и устройства появятся на плане авто
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
|
||||
1. В HACS откройте **⋮ → Пользовательские репозитории**.
|
||||
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
|
||||
3. Установите House Plan и перезапустите Home Assistant.
|
||||
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
House Plan входит в основной каталог HACS — пользовательский репозиторий
|
||||
добавлять не нужно.
|
||||
|
||||
1. Найдите **House Plan** в поиске HACS и установите.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
|
||||
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.67.0-beta.5"
|
||||
VERSION = "1.68.1"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 7
|
||||
PLAN_MODEL_VERSION = 9
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, scalar formula and field allow-list in lockstep; a shared
|
||||
Keep the precision, lattice formula and field allow-list in lockstep; a shared
|
||||
fixture is exercised by both runtimes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -13,10 +13,12 @@ from typing import Any
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed number."""
|
||||
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
@@ -32,6 +34,22 @@ def canonicalize_number(value: Any) -> Any:
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_lattice_coordinate(value: Any) -> Any:
|
||||
"""Collapse near-node noise while preserving authored off-grid values."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
scaled = number * LATTICE_GRID_N
|
||||
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
|
||||
nearest = math.floor(scaled + 0.5)
|
||||
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
|
||||
result = nearest / LATTICE_GRID_N
|
||||
return 0.0 if result == 0 else result
|
||||
return canonicalize_number(number)
|
||||
|
||||
|
||||
def _record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
@@ -42,37 +60,43 @@ def _records(value: Any) -> list[dict[str, Any]]:
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _point(value: Any) -> None:
|
||||
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_lattice_coordinate(record[name])
|
||||
|
||||
|
||||
def _lattice_point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_number(value[index])
|
||||
value[index] = canonicalize_lattice_coordinate(value[index])
|
||||
|
||||
|
||||
def _points(value: Any) -> None:
|
||||
def _lattice_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_point(point)
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise x/y in one layout record without touching its metadata."""
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_fields(record, ("x", "y"))
|
||||
_lattice_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise x/y in every layout record, preserving future fields."""
|
||||
"""Canonicalise lattice x/y in every layout record."""
|
||||
result = copy.deepcopy(layout)
|
||||
record = _record(result)
|
||||
if record is None:
|
||||
@@ -80,7 +104,7 @@ def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
for position in record.values():
|
||||
item = _record(position)
|
||||
if item is not None:
|
||||
_fields(item, ("x", "y"))
|
||||
_lattice_fields(item, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
@@ -92,7 +116,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
return result
|
||||
|
||||
for space in _records(root.get("spaces")):
|
||||
_fields(
|
||||
_scalar_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
@@ -105,45 +129,52 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_fields(room, ("x", "y", "w", "h"))
|
||||
_points(room.get("poly"))
|
||||
_lattice_fields(room, ("x", "y", "w", "h"))
|
||||
_lattice_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_point(wall.get("a"))
|
||||
_point(wall.get("b"))
|
||||
_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")):
|
||||
_fields(opening, ("x", "y", "angle", "length"))
|
||||
_lattice_fields(opening, ("x", "y"))
|
||||
_scalar_fields(opening, ("angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_fields(host, ("t",))
|
||||
_scalar_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_fields(decor, ("x", "y", "w", "h", "angle"))
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
_fields(decor, ("x", "y", "scale", "angle"))
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_points(draft.get("points"))
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_point(partition.get("a"))
|
||||
_point(partition.get("b"))
|
||||
_lattice_point(partition.get("a"))
|
||||
_lattice_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_point(column.get("center"))
|
||||
_lattice_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_fields(column, ("angle",))
|
||||
_scalar_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_point(span.get("a"))
|
||||
_point(span.get("b"))
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_fields(marker, ("angle",))
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
|
||||
@@ -36,6 +36,10 @@ 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,
|
||||
@@ -63,10 +67,17 @@ _LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_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",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_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",
|
||||
@@ -75,7 +86,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")
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
)
|
||||
@@ -244,6 +255,7 @@ 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")),
|
||||
@@ -257,7 +269,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, ("cm",))
|
||||
_pick_fields(segment, ("id", "cm"))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
@@ -579,7 +591,14 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ImportFailure("invalid_format", "Missing payload")
|
||||
try:
|
||||
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
|
||||
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)
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
if document["kind"] == "full":
|
||||
@@ -801,8 +820,31 @@ def _unique_title(base: str, existing: list[dict[str, Any]]) -> str:
|
||||
return f"{base} ({n})"
|
||||
|
||||
|
||||
def canonical_import_root(prefix: str, value: str) -> tuple[str, int, bool]:
|
||||
"""Return the bounded root of ids generated by a previous space import.
|
||||
|
||||
Only the exact ``<same namespace>_<stem>_<8 lowercase hex>`` envelope is
|
||||
reversible. Similar user ids, other namespaces and uppercase/short hashes
|
||||
remain literal. The layer count and bounded flag are shared with the
|
||||
TypeScript helper through one conformance fixture. The flag reports that
|
||||
another valid layer remained after the safety bound.
|
||||
"""
|
||||
root = str(value)
|
||||
pattern = re.compile(rf"^{re.escape(prefix)}_(.+)_([0-9a-f]{{8}})$")
|
||||
layers = 0
|
||||
for _depth in range(_MAX_IMPORT_LINEAGE_DEPTH):
|
||||
match = pattern.fullmatch(root)
|
||||
if match is None:
|
||||
return root, layers, False
|
||||
root = match.group(1)
|
||||
layers += 1
|
||||
return root, layers, pattern.fullmatch(root) is not None
|
||||
|
||||
|
||||
def _fresh(prefix: str, old: str, used: set[str]) -> str:
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", old).strip("_")[:35] or prefix
|
||||
root, _layers, _bounded = canonical_import_root(prefix, old) \
|
||||
if prefix in _IMPORT_ID_NAMESPACES else (old, 0, False)
|
||||
stem = re.sub(r"[^a-zA-Z0-9_-]+", "_", root).strip("_")[:35] or prefix
|
||||
while True:
|
||||
value = f"{prefix}_{stem}_{secrets.token_hex(4)}"
|
||||
if value not in used:
|
||||
@@ -822,53 +864,281 @@ def _orphan_marker(key: str, manifest: dict[str, Any] | None, space_id: str, use
|
||||
}
|
||||
|
||||
|
||||
def _empty_reference_report() -> dict[str, Any]:
|
||||
return {
|
||||
"remapped": {"incoming": {}, "target": {}},
|
||||
"collisions": {},
|
||||
"preservedUnresolved": {},
|
||||
"droppedIncomingLinks": {},
|
||||
"boundedLineages": 0,
|
||||
"examples": [],
|
||||
}
|
||||
|
||||
|
||||
def _report_reference(
|
||||
report: dict[str, Any], bucket: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault(bucket, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": bucket, "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _report_remap(
|
||||
report: dict[str, Any], side: str, category: str, owner: str, reference: str,
|
||||
) -> None:
|
||||
section = report.setdefault("remapped", {}).setdefault(side, {})
|
||||
section[category] = int(section.get(category, 0)) + 1
|
||||
examples = report.setdefault("examples", [])
|
||||
if len(examples) < _REPORT_EXAMPLE_LIMIT:
|
||||
examples.append({
|
||||
"bucket": f"remapped.{side}", "category": category,
|
||||
"owner": str(owner)[:160], "reference": str(reference)[:160],
|
||||
})
|
||||
|
||||
|
||||
def _record_bounded_lineage(
|
||||
report: dict[str, Any], seen: set[tuple[str, str]], prefix: str, value: str,
|
||||
) -> None:
|
||||
_root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
if bounded:
|
||||
seen.add((prefix, value))
|
||||
report["boundedLineages"] = len(seen)
|
||||
|
||||
|
||||
def _lineage_resolver(
|
||||
prefix: str,
|
||||
exact_map: dict[str, str],
|
||||
live_ids: set[str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> Callable[[str], tuple[str | None, str]]:
|
||||
"""Resolve only dead exact or uniquely provable cross-generation refs."""
|
||||
imported: dict[str, set[str]] = {}
|
||||
live: dict[str, set[str]] = {}
|
||||
for old, new in exact_map.items():
|
||||
root, _layers, bounded = canonical_import_root(prefix, old)
|
||||
imported.setdefault(root, set()).add(new)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, old)
|
||||
for value in live_ids:
|
||||
root, _layers, bounded = canonical_import_root(prefix, value)
|
||||
live.setdefault(root, set()).add(value)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, value)
|
||||
|
||||
def resolve(reference: str) -> tuple[str | None, str]:
|
||||
if reference in live_ids:
|
||||
return None, "live"
|
||||
if reference in exact_map:
|
||||
return exact_map[reference], "exact"
|
||||
root, _layers, bounded = canonical_import_root(prefix, reference)
|
||||
if bounded:
|
||||
_record_bounded_lineage(report, bounded_seen, prefix, reference)
|
||||
candidates = imported.get(root, set())
|
||||
if not candidates:
|
||||
return None, "unrelated"
|
||||
if len(candidates) == 1 and not live.get(root):
|
||||
return next(iter(candidates)), "lineage"
|
||||
return None, "ambiguous"
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def _repair_target_space_refs(
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
old_space_id: str,
|
||||
new_space_id: str,
|
||||
old_room_ids: dict[str, str],
|
||||
id_maps: dict[str, dict[str, str]],
|
||||
marker_link_map: dict[str, str],
|
||||
report: dict[str, Any],
|
||||
bounded_seen: set[tuple[str, str]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], int]:
|
||||
"""Apply the exact map of this import to pre-existing orphan target refs."""
|
||||
"""Repair target refs by exact map, then by one safe lineage candidate."""
|
||||
config = _json_copy(current_config)
|
||||
layout = _json_copy(current_layout)
|
||||
if any(str(space.get("id")) == old_space_id for space in config.get("spaces") or []):
|
||||
return config, layout, 0
|
||||
|
||||
spaces = config.get("spaces") or []
|
||||
markers = config.get("markers") or []
|
||||
live_spaces = {str(item.get("id")) for item in spaces if item.get("id") is not None}
|
||||
live_rooms = {
|
||||
str(room.get("id")) for space in spaces for room in space.get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
live_partitions = {
|
||||
str(item.get("id")) for space in spaces for item in space.get("partitions") or []
|
||||
if item.get("id") is not None
|
||||
}
|
||||
live_markers = {
|
||||
str(marker.get("id")) for marker in markers if marker.get("id") is not None
|
||||
}
|
||||
resolve_space = _lineage_resolver(
|
||||
"space", id_maps.get("space", {}), live_spaces, report, bounded_seen,
|
||||
)
|
||||
resolve_room = _lineage_resolver(
|
||||
"room", id_maps.get("room", {}), live_rooms, report, bounded_seen,
|
||||
)
|
||||
resolve_partition = _lineage_resolver(
|
||||
"partition", id_maps.get("partition", {}), live_partitions, report, bounded_seen,
|
||||
)
|
||||
resolve_marker = _lineage_resolver(
|
||||
"marker", id_maps.get("marker", {}), live_markers, report, bounded_seen,
|
||||
)
|
||||
resolve_marker_link = _lineage_resolver(
|
||||
"marker", marker_link_map, live_markers, report, bounded_seen,
|
||||
)
|
||||
imported_space_ids = set(id_maps.get("space", {}).values())
|
||||
repaired = 0
|
||||
for marker in config.get("markers") or []:
|
||||
if marker.get("space") == old_space_id:
|
||||
marker["space"] = new_space_id
|
||||
repaired += 1
|
||||
room_id = marker.get("room_id")
|
||||
if room_id is not None and str(room_id) in old_room_ids:
|
||||
marker["room_id"] = old_room_ids[str(room_id)]
|
||||
unresolved: set[tuple[str, str, str]] = set()
|
||||
|
||||
def replace(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> str | None:
|
||||
nonlocal repaired
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return None
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None:
|
||||
repaired += 1
|
||||
_report_remap(report, "target", category, owner, reference)
|
||||
return mapped
|
||||
if reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
return None
|
||||
|
||||
def preserve_once(owner: str, category: str, reference: str) -> None:
|
||||
key = (owner, category, reference)
|
||||
if key in unresolved:
|
||||
return
|
||||
unresolved.add(key)
|
||||
_report_reference(
|
||||
report, "preservedUnresolved", category, owner, reference,
|
||||
)
|
||||
|
||||
def preserve_related(
|
||||
owner: str, category: str, reference: Any,
|
||||
resolver: Callable[[str], tuple[str | None, str]],
|
||||
) -> None:
|
||||
if not isinstance(reference, str) or not reference:
|
||||
return
|
||||
mapped, reason = resolver(reference)
|
||||
if mapped is not None or reason == "ambiguous":
|
||||
preserve_once(owner, category, reference)
|
||||
|
||||
for space in spaces:
|
||||
space_id = str(space.get("id", "?"))
|
||||
for room in space.get("rooms") or []:
|
||||
room_id = str(room.get("id", "?"))
|
||||
values = room.get("open_to")
|
||||
if isinstance(values, list):
|
||||
for value in values:
|
||||
preserve_related(
|
||||
f"{space_id}:{room_id}", "room.open_to", value, resolve_room,
|
||||
)
|
||||
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") == "partition":
|
||||
# A partition host is space-local. An imported partition lives
|
||||
# in the new independent copy, so a pre-existing opening in a
|
||||
# different target space cannot be rebound to it safely.
|
||||
preserve_related(
|
||||
f"{space_id}:{opening.get('id', '?')}", "opening.host",
|
||||
host.get("id"), resolve_partition,
|
||||
)
|
||||
|
||||
for marker in markers:
|
||||
marker_id = str(marker.get("id", "?"))
|
||||
mapped = replace(marker_id, "marker.space", marker.get("space"), resolve_space)
|
||||
if mapped is not None:
|
||||
marker["space"] = mapped
|
||||
may_rebind_room = marker.get("space") in imported_space_ids
|
||||
if may_rebind_room:
|
||||
mapped = replace(marker_id, "marker.room_id", marker.get("room_id"), resolve_room)
|
||||
if mapped is not None:
|
||||
marker["room_id"] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.room_id", marker.get("room_id"), resolve_room,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
segment_map = vacuum.get("segment_map") if isinstance(vacuum, dict) else None
|
||||
if isinstance(segment_map, dict):
|
||||
for key, room_id in list(segment_map.items()):
|
||||
mapped = old_room_ids.get(str(room_id))
|
||||
if may_rebind_room:
|
||||
mapped = replace(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
for index, ref in enumerate(controls):
|
||||
if not isinstance(ref, str) or not ref.startswith("marker:"):
|
||||
continue
|
||||
old_target = ref[len("marker:"):]
|
||||
mapped = replace(
|
||||
marker_id, "marker.controls", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
segment_map[key] = mapped
|
||||
repaired += 1
|
||||
controls[index] = "marker:" + mapped
|
||||
else:
|
||||
# A duplicate may have been virtualised or otherwise lost
|
||||
# light-source semantics. Such a marker remains a valid
|
||||
# layout owner, but it is not a valid controls target.
|
||||
preserve_related(
|
||||
marker_id, "marker.controls", old_target, resolve_marker,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
old_target = ref[len("marker:"):] \
|
||||
if isinstance(ref, str) and ref.startswith("marker:") else None
|
||||
mapped = replace(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker_link,
|
||||
)
|
||||
if mapped is not None:
|
||||
source["ref"] = "marker:" + mapped
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.value_badge", old_target, resolve_marker,
|
||||
)
|
||||
|
||||
# Rewrite ownership before rekeying labels. A valid destination wins a
|
||||
# collision inside the target; the imported document itself is overlaid
|
||||
# later and has final priority for its own label position.
|
||||
for position in layout.values():
|
||||
if isinstance(position, dict) and position.get("s") == old_space_id:
|
||||
position["s"] = new_space_id
|
||||
repaired += 1
|
||||
for old_room_id, new_room_id in old_room_ids.items():
|
||||
old_key = "rl_" + old_room_id
|
||||
if old_key not in layout:
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
for key in list(layout):
|
||||
position = layout[key]
|
||||
if isinstance(position, dict):
|
||||
mapped_space = replace(key, "layout.space", position.get("s"), resolve_space)
|
||||
if mapped_space is not None:
|
||||
position["s"] = mapped_space
|
||||
new_key: str | None = None
|
||||
category = ""
|
||||
if key.startswith("rl_"):
|
||||
if isinstance(position, dict) and position.get("s") in imported_space_ids:
|
||||
mapped_room = replace(key, "layout.room_label", key[3:], resolve_room)
|
||||
if mapped_room is not None:
|
||||
new_key, category = "rl_" + mapped_room, "layout.room_label"
|
||||
else:
|
||||
preserve_related(key, "layout.room_label", key[3:], resolve_room)
|
||||
elif key not in live_markers:
|
||||
mapped_marker = replace(key, "layout.marker", key, resolve_marker)
|
||||
if mapped_marker is not None:
|
||||
new_key, category = mapped_marker, "layout.marker"
|
||||
if new_key is None or new_key == key:
|
||||
continue
|
||||
new_key = "rl_" + new_room_id
|
||||
if new_key not in layout:
|
||||
layout[new_key] = layout[old_key]
|
||||
del layout[old_key]
|
||||
repaired += 1
|
||||
if new_key in layout:
|
||||
_report_reference(report, "collisions", category, key, new_key)
|
||||
else:
|
||||
layout[new_key] = position
|
||||
del layout[key]
|
||||
return config, layout, repaired
|
||||
|
||||
|
||||
@@ -889,42 +1159,125 @@ def build_space_merge(
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
reference_report = _empty_reference_report()
|
||||
bounded_seen: set[tuple[str, str]] = set()
|
||||
target_config = _json_copy(current_config)
|
||||
target_layout = _json_copy(current_layout)
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
for value in [sp.get("id")]
|
||||
} | {str(m.get("id")) for m in current_config.get("markers") 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("openings") or [], sp.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
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("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
for value in [item.get("id")]
|
||||
if value is not None
|
||||
} | {
|
||||
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(
|
||||
reference_report, bounded_seen, "space", old_space_id,
|
||||
)
|
||||
new_space_id = _fresh("space", old_space_id, used)
|
||||
id_map: dict[str, str] = {old_space_id: new_space_id}
|
||||
id_maps: dict[str, dict[str, str]] = {
|
||||
prefix: {} for prefix in _IMPORT_ID_NAMESPACES
|
||||
}
|
||||
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 []:
|
||||
if isinstance(item, dict) and item.get("id") is not None:
|
||||
old = str(item["id"])
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, prefix, old,
|
||||
)
|
||||
id_map[old] = _fresh(prefix, old, used)
|
||||
id_maps[prefix][old] = id_map[old]
|
||||
item["id"] = id_map[old]
|
||||
old_room_ids = {
|
||||
old: new for old, new in id_map.items() if old != old_space_id and new.startswith("room_")
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, old_space_id, new_space_id, old_room_ids,
|
||||
)
|
||||
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"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
remapped_open_to = []
|
||||
for value in room["open_to"]:
|
||||
old_value = str(value)
|
||||
mapped_value = old_room_ids.get(old_value, old_value)
|
||||
remapped_open_to.append(mapped_value)
|
||||
if mapped_value != old_value:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "room.open_to",
|
||||
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") == "partition":
|
||||
if isinstance(host, dict) and host.get("kind") in ("partition", "wall"):
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
_report_remap(
|
||||
reference_report, "incoming", "opening.host",
|
||||
str(opening.get("id", "?")), old_host_id,
|
||||
)
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -947,22 +1300,40 @@ def build_space_merge(
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
for marker in incoming.get("markers") or []:
|
||||
old_id = str(marker.get("id"))
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "marker", old_id,
|
||||
)
|
||||
binding = _binding_key(marker)
|
||||
if binding in duplicate and duplicate_policy == "skip":
|
||||
skipped.add(old_id)
|
||||
continue
|
||||
new_id = _fresh("marker", old_id, used)
|
||||
marker_map[old_id] = new_id
|
||||
id_maps["marker"][old_id] = new_id
|
||||
marker["id"] = new_id
|
||||
marker["space"] = new_space_id
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.space", new_id, old_space_id,
|
||||
)
|
||||
if marker.get("room_id") is not None:
|
||||
marker["room_id"] = old_room_ids.get(str(marker["room_id"]), marker["room_id"])
|
||||
old_room_id = str(marker["room_id"])
|
||||
marker["room_id"] = old_room_ids.get(old_room_id, marker["room_id"])
|
||||
if marker["room_id"] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
|
||||
)
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
|
||||
vacuum["segment_map"] = {
|
||||
key: old_room_ids.get(str(value), value)
|
||||
for key, value in vacuum["segment_map"].items()
|
||||
}
|
||||
remapped_segments = {}
|
||||
for key, value in vacuum["segment_map"].items():
|
||||
old_room_id = str(value)
|
||||
remapped_segments[key] = old_room_ids.get(old_room_id, value)
|
||||
if remapped_segments[key] != old_room_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.vacuum.segment_map",
|
||||
new_id, old_room_id,
|
||||
)
|
||||
vacuum["segment_map"] = remapped_segments
|
||||
if binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
virtualized_targets.add(old_id)
|
||||
@@ -1003,6 +1374,10 @@ def build_space_merge(
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
remapped.append("marker:" + target)
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.controls",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
marker["controls"] = remapped or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
@@ -1012,11 +1387,30 @@ def build_space_merge(
|
||||
target = None if old_target in virtualized_targets else marker_map.get(old_target)
|
||||
if target:
|
||||
source["ref"] = "marker:" + target
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.value_badge",
|
||||
str(marker.get("id", "?")), old_target,
|
||||
)
|
||||
else:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
if marker.get("id") is not None
|
||||
}
|
||||
marker_link_map = {
|
||||
old_id: new_id for old_id, new_id in marker_map.items()
|
||||
if (target := output_markers_by_id.get(new_id)) is not None
|
||||
and target.get("removed") is not True
|
||||
and target.get("is_light") is True
|
||||
}
|
||||
target_config, target_layout, repaired_target_refs = _repair_target_space_refs(
|
||||
current_config, current_layout, id_maps, marker_link_map,
|
||||
reference_report, bounded_seen,
|
||||
)
|
||||
|
||||
output_layout: dict[str, Any] = {}
|
||||
for key, pos in (document["payload"].get("layout") or {}).items():
|
||||
if key in skipped:
|
||||
@@ -1042,6 +1436,16 @@ def build_space_merge(
|
||||
new_key = orphan["id"]
|
||||
if layout_binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
if new_key != key:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.owner", new_key, key,
|
||||
)
|
||||
old_layout_space = str(pos.get("s", "")) if isinstance(pos, dict) else ""
|
||||
if old_layout_space != new_space_id:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "layout.space", new_key,
|
||||
old_layout_space,
|
||||
)
|
||||
output_layout[new_key] = {**pos, "s": new_space_id}
|
||||
|
||||
merged_config = target_config
|
||||
@@ -1079,6 +1483,8 @@ def build_space_merge(
|
||||
merged_config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
raise ImportFailure("capacity_exceeded", "Merged configuration exceeds the store limit")
|
||||
if dropped_marker_links:
|
||||
reference_report["droppedIncomingLinks"]["marker.links"] = dropped_marker_links
|
||||
return merged_config, merged_layout, {
|
||||
"space_id": new_space_id,
|
||||
"space_title": space["title"],
|
||||
@@ -1088,6 +1494,10 @@ def build_space_merge(
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
"preserved_unresolved_refs": sum(
|
||||
int(value) for value in reference_report["preservedUnresolved"].values()
|
||||
),
|
||||
"reference_report": reference_report,
|
||||
}
|
||||
|
||||
|
||||
@@ -1211,6 +1621,84 @@ def _missing_areas(
|
||||
return sorted(incoming - available)
|
||||
|
||||
|
||||
def _materialize_import_candidate(
|
||||
document: dict[str, Any],
|
||||
current_config: dict[str, Any],
|
||||
current_layout: dict[str, Any],
|
||||
*,
|
||||
duplicate_policy: str,
|
||||
same_source: bool,
|
||||
content: list[dict[str, Any]],
|
||||
confirmation_required: bool,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""Build once the normalized config/layout that the preview may apply."""
|
||||
prepared = _json_copy(document)
|
||||
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,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
config = imported_config
|
||||
if not requires_v8 and incoming_model > 0:
|
||||
config["model_version"] = incoming_model
|
||||
_materialize_global_background(config)
|
||||
layout = _json_copy(prepared["payload"]["layout"])
|
||||
if not same_source:
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(layout)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_layout", str(err)) from err
|
||||
details = _json_copy(details)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return config, layout, details
|
||||
|
||||
|
||||
def _candidate_digest(candidate: dict[str, Any]) -> str:
|
||||
"""Bind a token to both its source and the exact normalized target pair."""
|
||||
return _document_digest({
|
||||
"document": candidate.get("document"),
|
||||
"duplicate_policy": candidate.get("duplicate_policy"),
|
||||
"config_rev": candidate.get("config_rev"),
|
||||
"layout_rev": candidate.get("layout_rev"),
|
||||
"content": candidate.get("content"),
|
||||
"confirmation_required": candidate.get("confirmation_required"),
|
||||
"same_source": candidate.get("same_source"),
|
||||
"target_config": candidate.get("target_config"),
|
||||
"target_layout": candidate.get("target_layout"),
|
||||
"details": candidate.get("details"),
|
||||
})
|
||||
|
||||
|
||||
def create_preview(
|
||||
runtime: HouseplanData,
|
||||
raw: bytes,
|
||||
@@ -1236,16 +1724,10 @@ def create_preview(
|
||||
validate_opening_passages(incoming_config, validate_all=True)
|
||||
except OpeningPassageError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if document["kind"] == "space":
|
||||
_merged_config, _merged_layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=same_source,
|
||||
)
|
||||
else:
|
||||
if document["kind"] == "full":
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
dropped_marker_links += _drop_invalid_import_marker_links(incoming_config)
|
||||
document["transfer"]["dropped_marker_links"] = dropped_marker_links
|
||||
details["dropped_marker_links"] = dropped_marker_links
|
||||
try:
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
@@ -1253,13 +1735,19 @@ def create_preview(
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=same_source,
|
||||
content=content,
|
||||
confirmation_required=confirmation,
|
||||
)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
candidate = {
|
||||
"owner_id": owner_id,
|
||||
"created": now,
|
||||
"expires": now + IMPORT_PREVIEW_TTL_S,
|
||||
"digest": _document_digest(document),
|
||||
"document": document,
|
||||
"duplicate_policy": duplicate_policy,
|
||||
"config_rev": int(current_config_data.get("rev", 0)),
|
||||
@@ -1267,7 +1755,11 @@ def create_preview(
|
||||
"content": content,
|
||||
"confirmation_required": confirmation,
|
||||
"same_source": same_source,
|
||||
"target_config": target_config,
|
||||
"target_layout": target_layout,
|
||||
"details": details,
|
||||
}
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
# Opportunistic expiry and per-user oldest-first eviction.
|
||||
runtime.import_previews = {
|
||||
key: value for key, value in runtime.import_previews.items()
|
||||
@@ -1319,7 +1811,7 @@ def get_candidate(runtime: HouseplanData, token: str, owner_id: str, *, consume:
|
||||
raise ImportFailure("preview_expired", "Import preview expired")
|
||||
if candidate.get("owner_id") != owner_id:
|
||||
raise ImportFailure("preview_owner_mismatch", "Preview belongs to another user")
|
||||
if candidate.get("digest") != _document_digest(candidate.get("document") or {}):
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
runtime.import_previews.pop(token, None)
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
if consume:
|
||||
@@ -1342,21 +1834,26 @@ def revalidate_candidate(
|
||||
"spaces": [], "markers": [], "settings": {},
|
||||
}
|
||||
current_layout = current_layout_data.get("layout") or {}
|
||||
details: dict[str, Any] = {}
|
||||
if document["kind"] == "space":
|
||||
_config, _layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
if config_root is not None:
|
||||
content, confirmation = _content_state(
|
||||
document, bool(candidate.get("same_source")), config_root
|
||||
)
|
||||
candidate["content"] = content
|
||||
candidate["confirmation_required"] = confirmation
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
duplicate_policy=duplicate_policy,
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
content=candidate.get("content") or [],
|
||||
confirmation_required=bool(candidate.get("confirmation_required")),
|
||||
)
|
||||
candidate["duplicate_policy"] = duplicate_policy
|
||||
candidate["config_rev"] = int(current_config_data.get("rev", 0))
|
||||
candidate["layout_rev"] = int(current_layout_data.get("rev", 0))
|
||||
candidate["target_config"] = target_config
|
||||
candidate["target_layout"] = target_layout
|
||||
candidate["details"] = details
|
||||
candidate["digest"] = _candidate_digest(candidate)
|
||||
incoming = document["payload"]
|
||||
return {
|
||||
"preview": {
|
||||
@@ -1388,35 +1885,17 @@ def prepare_apply(
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
if candidate.get("confirmation_required") and not confirm_missing_content:
|
||||
raise ImportFailure("content_confirmation_required", "Missing content must be confirmed")
|
||||
document = candidate["document"]
|
||||
# Detachment is defined in the exported identity space. Do it before a
|
||||
# space import remaps owners to fresh ids.
|
||||
imported_config = _json_copy(document["payload"]["config"])
|
||||
if candidate.get("confirmation_required"):
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
# downgrades the stored plan to an unversioned document.
|
||||
model_version = document.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(document["payload"]["layout"])
|
||||
if not candidate.get("same_source"):
|
||||
settings = config.get("settings") or {}
|
||||
settings.pop("known_devices", None)
|
||||
settings.pop("new_device_ids", None)
|
||||
details = {"counts": _counts(config, layout)}
|
||||
else:
|
||||
local_document = {**document, "payload": {**document["payload"], "config": imported_config}}
|
||||
config, layout, details = build_space_merge(
|
||||
local_document, current_config, current_layout,
|
||||
duplicate_policy or candidate.get("duplicate_policy", "skip"),
|
||||
same_source=bool(candidate.get("same_source")),
|
||||
)
|
||||
details["counts"] = _counts(config, layout)
|
||||
return CONFIG_SCHEMA(config), LAYOUT_SCHEMA(layout), details
|
||||
if duplicate_policy is not None and duplicate_policy != candidate.get("duplicate_policy"):
|
||||
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
|
||||
if candidate.get("digest") != _candidate_digest(candidate):
|
||||
raise ImportFailure("invalid_format", "Import preview candidate changed")
|
||||
config = candidate.get("target_config")
|
||||
layout = candidate.get("target_layout")
|
||||
details = candidate.get("details")
|
||||
if not isinstance(config, dict) or not isinstance(layout, dict) \
|
||||
or not isinstance(details, dict):
|
||||
raise ImportFailure("invalid_format", "Import preview has no materialized candidate")
|
||||
# The current stores are intentionally unused: revisions are checked by
|
||||
# the websocket handler under the write lock before this function runs.
|
||||
_ = current_config, current_layout
|
||||
return _json_copy(config), _json_copy(layout), _json_copy(details)
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.67.0-beta.5"
|
||||
"version": "1.68.1"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -76,14 +78,551 @@ 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.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
_DEFAULT_ROOM_WALL_CM = 15.0
|
||||
_OPTIMIZE_REHOST_EPSILON = 1e-8
|
||||
|
||||
|
||||
def _room_polygon(room: dict) -> list[list[float]]:
|
||||
poly = room.get("poly")
|
||||
if isinstance(poly, list) and len(poly) >= 3:
|
||||
return poly
|
||||
if all(key in room for key in ("x", "y", "w", "h")):
|
||||
x, y = float(room["x"]), float(room["y"])
|
||||
w, h = float(room["w"]), float(room["h"])
|
||||
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
|
||||
return []
|
||||
|
||||
|
||||
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
return dx, dy, math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _line_distance(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return abs((float(point[0]) - float(a[0])) * dy
|
||||
- (float(point[1]) - float(a[1])) * dx) / length
|
||||
|
||||
|
||||
def _projection(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return ((float(point[0]) - float(a[0])) * dx
|
||||
+ (float(point[1]) - float(a[1])) * dy) / length
|
||||
|
||||
|
||||
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
|
||||
"""Return whether one exact collinear segment covers the target."""
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
for point in (target_a, target_b):
|
||||
along = _projection(point, a, b)
|
||||
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
|
||||
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
|
||||
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
|
||||
|
||||
|
||||
def _angle_delta_mod_180(first: float, second: float) -> float:
|
||||
return abs((first - second + 90.0) % 180.0 - 90.0)
|
||||
|
||||
|
||||
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
|
||||
"""Whether a collinear union covers one complete positive target span."""
|
||||
dx, dy, length = _segment_metrics(target_a, target_b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
ranges = []
|
||||
for a, b in segments:
|
||||
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
continue
|
||||
lo, hi = sorted((_projection(a, target_a, target_b),
|
||||
_projection(b, target_a, target_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
|
||||
ranges.append((lo, hi))
|
||||
reached = 0.0
|
||||
for lo, hi in sorted(ranges):
|
||||
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
reached = max(reached, hi)
|
||||
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
|
||||
|
||||
|
||||
def _known_optimize_partition(partition: dict) -> bool:
|
||||
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
|
||||
|
||||
|
||||
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
|
||||
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
|
||||
if not (isinstance(a, list) and len(a) == 2
|
||||
and isinstance(b, list) and len(b) == 2):
|
||||
return None
|
||||
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return None
|
||||
_, _, length = _segment_metrics(axis_a, axis_b)
|
||||
lo, hi = sorted((_projection(a, axis_a, axis_b),
|
||||
_projection(b, axis_a, axis_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
|
||||
|
||||
|
||||
def _axis_point(a: list, b: list, along: float) -> list[float]:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
return [float(a[0]) + dx / length * along,
|
||||
float(a[1]) + dy / length * along]
|
||||
|
||||
|
||||
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
|
||||
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
|
||||
partition_id = str(old_partition.get("id", ""))
|
||||
current = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if current == old_partition:
|
||||
return False
|
||||
if current is None:
|
||||
return True
|
||||
if not (_known_optimize_partition(old_partition)
|
||||
and _known_optimize_partition(current)):
|
||||
return False
|
||||
try:
|
||||
if float(current.get("cm")) != float(old_partition.get("cm")):
|
||||
return False
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
|
||||
_, _, old_length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return current_range is not None and (
|
||||
current_range[0] > _OPTIMIZE_REHOST_EPSILON
|
||||
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
"""Independently prove every removed atom of one old partition axis."""
|
||||
if not _known_optimize_partition(old_partition):
|
||||
return False
|
||||
try:
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
old_cm = float(old_partition["cm"])
|
||||
_, _, length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
|
||||
return False
|
||||
|
||||
rooms: list[tuple[str, list[tuple[list, list]]]] = []
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
if len(poly) < 3:
|
||||
return False
|
||||
rooms.append((str(room.get("id", "")), [
|
||||
(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))
|
||||
]))
|
||||
|
||||
candidate_partitions = space.get("partitions") or []
|
||||
retained: list[tuple[float, float]] = []
|
||||
breakpoints = [0.0, length]
|
||||
|
||||
def note_segment(a: list, b: list) -> tuple[float, float] | None:
|
||||
projected = _axis_range(a, b, old_a, old_b)
|
||||
if projected:
|
||||
breakpoints.extend(projected)
|
||||
return projected
|
||||
|
||||
for partition in candidate_partitions:
|
||||
try:
|
||||
projected = note_segment(partition["a"], partition["b"])
|
||||
candidate_cm = float(partition["cm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
|
||||
retained.append(projected)
|
||||
for _, edges in rooms:
|
||||
for edge_a, edge_b in edges:
|
||||
note_segment(edge_a, edge_b)
|
||||
for collection in (space.get("walls") or [], space.get("open_spans") or []):
|
||||
for segment in collection:
|
||||
try:
|
||||
note_segment(segment["a"], segment["b"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
and end >= hi - _OPTIMIZE_REHOST_EPSILON
|
||||
for start, end in retained):
|
||||
continue
|
||||
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
|
||||
owners = {room_id for room_id, edges in rooms
|
||||
if _segments_cover_target(edges, target_a, target_b)}
|
||||
collinear = {room_id for room_id, edges in rooms
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, edge_a, edge_b
|
||||
) for edge_a, edge_b in edges)}
|
||||
if len(owners) not in (1, 2) or collinear != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, span["a"], span["b"]
|
||||
) for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, partition["a"], partition["b"]
|
||||
) for partition in candidate_partitions):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
try:
|
||||
wall_a, wall_b = wall["a"], wall["b"]
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((
|
||||
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM)
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
|
||||
host = opening.get("host")
|
||||
try:
|
||||
if not isinstance(host, dict) or host.get("kind") != "partition":
|
||||
return None
|
||||
t = float(host["t"])
|
||||
a, b = partition["a"], partition["b"]
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
|
||||
return None
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if angle >= 90:
|
||||
angle -= 180
|
||||
elif angle < -90:
|
||||
angle += 180
|
||||
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
|
||||
|
||||
|
||||
def _safe_optimize_residual_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Prove a hosted opening rebound to a newly-created residual id."""
|
||||
old_host, new_host = old_opening.get("host"), opening.get("host")
|
||||
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
|
||||
and old_host.get("kind") == new_host.get("kind") == "partition"):
|
||||
return False
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
|
||||
new_partition = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
|
||||
if old_partition is None or new_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
if ({key: value for key, value in old_opening.items() if key not in ignored}
|
||||
!= {key: value for key, value in opening.items() if key not in ignored}):
|
||||
return False
|
||||
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
|
||||
new_geometry = _hosted_opening_geometry(opening, new_partition)
|
||||
if old_geometry is None or new_geometry is None:
|
||||
return False
|
||||
old_x, old_y, old_angle, old_length = old_geometry
|
||||
new_x, new_y, new_angle, new_length = new_geometry
|
||||
try:
|
||||
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
|
||||
explicit_angle = float(opening["angle"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
|
||||
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Independently prove the #276/#296 partition-to-room-wall transition."""
|
||||
old_host = old_opening.get("host")
|
||||
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
|
||||
return False
|
||||
partition_id = str(old_host.get("id", ""))
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
return False
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
|
||||
new_stable = {key: value for key, value in opening.items() if key not in ignored}
|
||||
if old_stable != new_stable:
|
||||
return False
|
||||
try:
|
||||
t = float(old_host["t"])
|
||||
x, y = float(opening["x"]), float(opening["y"])
|
||||
opening_angle = float(opening["angle"])
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if not (0 <= t <= 1 and opening_length > 0):
|
||||
return False
|
||||
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
|
||||
expected_angle = math.degrees(math.atan2(dy, dx))
|
||||
if expected_angle >= 90:
|
||||
expected_angle -= 180
|
||||
elif expected_angle < -90:
|
||||
expected_angle += 180
|
||||
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
|
||||
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
|
||||
return False
|
||||
along = t * length
|
||||
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
|
||||
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
|
||||
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
|
||||
target_a = [expected_x - half_dx, expected_y - half_dy]
|
||||
target_b = [expected_x + half_dx, expected_y + half_dy]
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if _segments_cover_target(edges, target_a, target_b):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
|
||||
for item in space.get("partitions") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
for other in space.get("openings") or []:
|
||||
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
|
||||
continue
|
||||
try:
|
||||
center = [float(other["x"]), float(other["y"])]
|
||||
other_angle = float(other["angle"])
|
||||
other_length = float(other["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
|
||||
continue
|
||||
other_along = _projection(center, a, b)
|
||||
if (min(along + opening_length / 2, other_along + other_length / 2)
|
||||
- max(along - opening_length / 2, other_along - other_length / 2)
|
||||
> _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
@@ -113,18 +652,38 @@ def validate_partition_opening_hosts(
|
||||
for opening in space.get("openings") or []:
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
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:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host changed"
|
||||
)
|
||||
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", "")))
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
@@ -156,6 +715,14 @@ def validate_partition_opening_hosts(
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
if allow_optimize_rehost and old_space:
|
||||
for old_partition in old_space.get("partitions") or []:
|
||||
if (_optimize_partition_needs_proof(old_partition, space)
|
||||
and not _safe_optimize_partition_delta(space, old_partition)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; partition={old_partition.get('id', '')}; "
|
||||
"optimize delta unproved"
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
@@ -480,7 +1047,11 @@ MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 500
|
||||
# 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_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
@@ -645,6 +1216,10 @@ 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,
|
||||
),
|
||||
@@ -816,6 +1391,26 @@ 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):
|
||||
@@ -831,7 +1426,10 @@ 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.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
|
||||
[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)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(min=1, max=499),
|
||||
),
|
||||
@@ -853,7 +1451,7 @@ PARTITION_SCHEMA = vol.All(
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("a"): POINT,
|
||||
vol.Required("b"): POINT,
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=0, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -895,11 +1493,22 @@ 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"):
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
@@ -912,6 +1521,14 @@ 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")
|
||||
}
|
||||
@@ -920,6 +1537,8 @@ 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")
|
||||
@@ -1000,7 +1619,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
|
||||
vol.Optional("host"): OPENING_HOST_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -1009,6 +1628,10 @@ 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)
|
||||
),
|
||||
@@ -1137,9 +1760,124 @@ 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(
|
||||
@@ -1174,4 +1912,5 @@ CONFIG_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
_config_wall_segment_invariants,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
"""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
|
||||
@@ -24,6 +24,7 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
@@ -60,13 +61,19 @@ 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, sanitize_filename,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -511,6 +518,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"repaired_target_refs": details.get("repaired_target_refs", 0),
|
||||
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
|
||||
"reference_report": details.get("reference_report", {}),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
@@ -1149,6 +1158,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1257,7 +1269,9 @@ def _missing_internal_attachments(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
# Semantic stale-client detection needs the stored v8 document and
|
||||
# therefore runs inside the write lock before CONFIG_SCHEMA.
|
||||
vol.Required("config"): dict,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@@ -1307,6 +1321,10 @@ 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"))
|
||||
@@ -1314,10 +1332,13 @@ 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,
|
||||
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
|
||||
) 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
|
||||
@@ -1575,7 +1596,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"): CONFIG_SCHEMA,
|
||||
vol.Required("config"): dict,
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
@@ -1620,17 +1641,53 @@ 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"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
PartitionOpeningJambMarginError, WallModelClientOutdatedError,
|
||||
WallSegmentMigrationError,
|
||||
) 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,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// #276: same-process incremental cost of coincident-partition reconciliation
|
||||
// inside the complete Optimize candidate pass on the deterministic large house.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH_SIZE = 10;
|
||||
const RELATIVE_OVERHEAD = 0.15;
|
||||
const ABSOLUTE_OVERHEAD_MS = 25;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const emptyReconciliation = (rawSpace, _model, walls) => ({
|
||||
walls: walls || [],
|
||||
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
|
||||
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
|
||||
partitionsReconciled: 0,
|
||||
openingsRehosted: 0,
|
||||
});
|
||||
const baseline = () => optimizePlans(fixture.config, {}, {}, {
|
||||
reconcileCoincidentPartitions: emptyReconciliation,
|
||||
});
|
||||
const candidate = () => optimizePlans(fixture.config, {});
|
||||
const timed = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH_SIZE; index++) {
|
||||
const result = operation();
|
||||
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
|
||||
}
|
||||
return (performance.now() - start) / BATCH_SIZE;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
|
||||
const baselineTimes = [];
|
||||
const candidateTimes = [];
|
||||
const overheadTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
|
||||
// batching then averages timer noise before the paired p95 is computed.
|
||||
const candidateFirst = index % 2 === 1;
|
||||
const first = timed(candidateFirst ? candidate : baseline);
|
||||
const second = timed(candidateFirst ? baseline : candidate);
|
||||
const third = timed(candidateFirst ? baseline : candidate);
|
||||
const fourth = timed(candidateFirst ? candidate : baseline);
|
||||
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
|
||||
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
|
||||
baselineTimes.push(baselineMs);
|
||||
candidateTimes.push(candidateMs);
|
||||
overheadTimes.push(candidateMs - baselineMs);
|
||||
}
|
||||
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
const baselineSummary = summary(baselineTimes);
|
||||
const candidateSummary = summary(candidateTimes);
|
||||
const overheadSummary = summary(overheadTimes);
|
||||
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
|
||||
const relativeP95 = measuredOverheadP95
|
||||
/ Math.max(baselineSummary.p95, Number.EPSILON);
|
||||
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
|
||||
&& relativeP95 <= RELATIVE_OVERHEAD;
|
||||
const report = {
|
||||
issue: 276,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
batchSize: BATCH_SIZE,
|
||||
baseline: baselineSummary,
|
||||
candidate: candidateSummary,
|
||||
pairedOverhead: overheadSummary,
|
||||
budgets: {
|
||||
relativeOverhead: RELATIVE_OVERHEAD,
|
||||
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
|
||||
},
|
||||
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// #291: same-process p95 of the complete config+layout boundary versus the
|
||||
// pre-existing full-candidate clone contract. Batching makes the strict 20%
|
||||
// ratio meaningful even on coarse/loaded CI timers.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from '../test-build/coordinate-canonicalization.js';
|
||||
|
||||
const WARMUPS = 30;
|
||||
const SAMPLES = 120;
|
||||
const BATCH = 10;
|
||||
const MAX_RATIO = 1.2;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
|
||||
const baseline = () => {
|
||||
JSON.parse(JSON.stringify(fixture.config));
|
||||
JSON.parse(JSON.stringify(fixture.layout || {}));
|
||||
};
|
||||
const candidate = () => {
|
||||
canonicalizeConfigGeometry(fixture.config);
|
||||
canonicalizeLayoutGeometry(fixture.layout || {});
|
||||
};
|
||||
const measure = (operation) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - started) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
const baselineSamples = [];
|
||||
const candidateSamples = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
if (index % 2) {
|
||||
candidateSamples.push(measure(candidate));
|
||||
baselineSamples.push(measure(baseline));
|
||||
} else {
|
||||
baselineSamples.push(measure(baseline));
|
||||
candidateSamples.push(measure(candidate));
|
||||
}
|
||||
}
|
||||
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
|
||||
const baselineP95 = p95(baselineSamples);
|
||||
const candidateP95 = p95(candidateSamples);
|
||||
const ratio = candidateP95 / baselineP95;
|
||||
const report = {
|
||||
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
|
||||
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
|
||||
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -87,7 +87,7 @@ const viewport = { width: 1280, height: 900 };
|
||||
const { page, browser } = await launch(
|
||||
viewport, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
{}, resolve(targetRoot, 'demo/srv'), targetRoot,
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
|
||||
@@ -43,6 +43,7 @@ const { page, browser } = await launch(
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{},
|
||||
resolve(targetRoot, 'demo/srv'),
|
||||
targetRoot,
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
@@ -345,7 +346,7 @@ try {
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._rszDrag?.plan;
|
||||
const plan = card._resize?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// #277: same-run historical edge-drag baseline versus the fixed-topology
|
||||
// pointer clamp, plus the exact one-space production preflight used on release.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
planEdgeDrag, clampEdgeDrag,
|
||||
resolveSafeResize, clampSafeResize, applySafeResize,
|
||||
safeResizeCachedDeltaCount,
|
||||
} from '../test-build/resize.js';
|
||||
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH = 25;
|
||||
const POINTER_P95_MS = 16;
|
||||
const POINTER_RATIO = 1.2;
|
||||
const POINTER_NOISE_MS = 0.25;
|
||||
const PREFLIGHT_P95_MS = 75;
|
||||
|
||||
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
|
||||
for (let index = 0; index < 199; index++) {
|
||||
const x = 1000 + (index % 20) * 500;
|
||||
const y = Math.floor(index / 20) * 500;
|
||||
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
|
||||
}
|
||||
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
|
||||
const oldPlan = planEdgeDrag(rooms, 'active', 1);
|
||||
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
|
||||
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
|
||||
const safePlan = safeResolution.plan;
|
||||
|
||||
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
|
||||
const safePointer = () => {
|
||||
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
|
||||
return applySafeResize(rooms, [], safePlan, delta);
|
||||
};
|
||||
const large = makeLargeHouseFixture();
|
||||
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
|
||||
const precomputeStart = performance.now();
|
||||
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
|
||||
const renderPrecomputeMs = performance.now() - precomputeStart;
|
||||
if (!cachedProductionGeometry.ok
|
||||
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
|
||||
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
|
||||
}
|
||||
// The final preview frame owns the expensive production union. pointerup reads
|
||||
// the exact cached result for that cfg epoch; this measures commit latency, not
|
||||
// the existing large-house render budget measured by benchmark:large-house.
|
||||
const preflight = () => {
|
||||
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
|
||||
return cachedProductionGeometry.fingerprint;
|
||||
};
|
||||
|
||||
const timedBatch = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - start) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baselinePointer(); safePointer(); preflight();
|
||||
}
|
||||
const baselineTimes = [];
|
||||
const safeTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
const safeFirst = index % 2 === 1;
|
||||
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
|
||||
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
|
||||
}
|
||||
const preflightTimes = Array.from({ length: SAMPLES }, () => {
|
||||
const start = performance.now(); preflight(); return performance.now() - start;
|
||||
});
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
const baseline = summary(baselineTimes);
|
||||
const candidate = summary(safeTimes);
|
||||
const commitPreflight = summary(preflightTimes);
|
||||
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
|
||||
const pass = candidate.p95 <= POINTER_P95_MS
|
||||
&& candidate.p95 <= relativeLimit
|
||||
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
|
||||
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
|
||||
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
|
||||
baseline, candidate, commitPreflight,
|
||||
renderPrecomputeMs,
|
||||
cacheEntries: safeResizeCachedDeltaCount(safePlan),
|
||||
budgets: {
|
||||
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
|
||||
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
|
||||
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,78 @@
|
||||
// #277: warm Resize-layer render cost on the supported large-house ceiling.
|
||||
// The deterministic snapshot-call assertion catches the original regression
|
||||
// even when runner timing noise happens to keep the p95 below its budget.
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const RENDER_P95_MS = 25;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async ({ config, warmups, samples }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(config);
|
||||
card._space = config.spaces[0].id;
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const originalSnapshot = card._rszSnapshot.bind(card);
|
||||
let snapshotCalls = 0;
|
||||
card._rszSnapshot = () => {
|
||||
snapshotCalls++;
|
||||
return originalSnapshot();
|
||||
};
|
||||
|
||||
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
|
||||
snapshotCalls = 0;
|
||||
const times = [];
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const started = performance.now();
|
||||
card._renderResizeLayer(view);
|
||||
times.push(performance.now() - started);
|
||||
}
|
||||
card._rszSnapshot = originalSnapshot;
|
||||
return {
|
||||
times,
|
||||
snapshotCalls,
|
||||
roomCount: card._rszRooms().length,
|
||||
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
|
||||
};
|
||||
}, { config, warmups: WARMUPS, samples: SAMPLES });
|
||||
|
||||
await browser.close();
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const render = {
|
||||
min: Math.min(...result.times),
|
||||
median: quantile(result.times, 0.5),
|
||||
p95: quantile(result.times, 0.95),
|
||||
max: Math.max(...result.times),
|
||||
};
|
||||
const pass = result.snapshotCalls === SAMPLES
|
||||
&& result.roomCount === 20
|
||||
&& result.handleCount === 80
|
||||
&& render.p95 <= RENDER_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
roomCount: result.roomCount,
|
||||
handleCount: result.handleCount,
|
||||
snapshotCalls: result.snapshotCalls,
|
||||
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
|
||||
render,
|
||||
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,118 @@
|
||||
// #278: component projection overhead and degraded-fixture completion budget.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 50;
|
||||
// One projection is sub-millisecond. Time a batch and report per-operation
|
||||
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
|
||||
const VALID_BATCH = 100;
|
||||
const RELATIVE_RATIO = 1.1;
|
||||
const OVERHEAD_P95_MS = 20;
|
||||
const DEGRADED_P95_MS = 100;
|
||||
|
||||
const prepare = (config) => {
|
||||
const models = spaceModels(config);
|
||||
return config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
};
|
||||
const args = (input) => [
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
];
|
||||
const large = makeLargeHouseFixture();
|
||||
const validInputs = prepare(large.config);
|
||||
const degradedFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const degradedInput = prepare(degradedFixture.config)[0];
|
||||
|
||||
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
|
||||
if (validResults.some((result) => result.status !== 'ok'))
|
||||
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
|
||||
const validGeometry = () => {
|
||||
for (const result of validResults) {
|
||||
// The previous production projection also serialized primary + paper.
|
||||
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
|
||||
throw new Error('valid legacy projection');
|
||||
}
|
||||
};
|
||||
const validProjection = () => {
|
||||
for (const result of validResults) {
|
||||
const paths = result.components.map((component) => polyclipToPathD(component.geom));
|
||||
const paper = polyclipToPathD(result.paperGeom);
|
||||
if (!paths.length || paths.some((path) => !path) || !paper)
|
||||
throw new Error('valid component projection');
|
||||
}
|
||||
};
|
||||
const degradedProjection = () => {
|
||||
const result = wallBodiesUnionPath(...args(degradedInput));
|
||||
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
|
||||
throw new Error(`degraded projection: ${result?.status || 'null'}`);
|
||||
};
|
||||
const timed = (operation, iterations = 1) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < iterations; index++) operation();
|
||||
return performance.now() - started;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => timed(operation));
|
||||
};
|
||||
const runPairs = () => {
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
validGeometry();
|
||||
validProjection();
|
||||
}
|
||||
const baseline = [], candidate = [], overhead = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
let baseMs, candidateMs;
|
||||
if (index % 2 === 0) {
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
} else {
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
}
|
||||
baseline.push(baseMs);
|
||||
candidate.push(candidateMs);
|
||||
overhead.push(candidateMs - baseMs);
|
||||
}
|
||||
return { baseline, candidate, overhead };
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
|
||||
const pairs = runPairs();
|
||||
const baseline = summary(pairs.baseline);
|
||||
const candidate = summary(pairs.candidate);
|
||||
const degraded = summary(run(degradedProjection));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
|
||||
const overheadP95 = quantile(pairs.overhead, 0.95);
|
||||
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
|
||||
&& degraded.p95 <= DEGRADED_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
|
||||
validBatch: VALID_BATCH,
|
||||
baseline, candidate, degraded, overheadP95,
|
||||
budgets: {
|
||||
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
|
||||
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Local-only visual proof for #275; external backup contents are never printed. */
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const input = process.argv[2];
|
||||
const outputDir = resolve(process.argv[3] || '.');
|
||||
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
|
||||
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
|
||||
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
|
||||
if (!input) {
|
||||
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
|
||||
+ '[--optimized] [--nodes=x,y;x,y]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const backup = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
|
||||
const source = {
|
||||
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
|
||||
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
|
||||
};
|
||||
const rendered = mode === 'optimized'
|
||||
? optimizePlans(source.config, source.layout)
|
||||
: source;
|
||||
const config = rendered.config;
|
||||
const layout = rendered.layout;
|
||||
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
|
||||
throw new Error('backup has no spaces');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async ({ cfg, lay }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = structuredClone(lay);
|
||||
card._space = cfg.spaces[0].id;
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._isoGeometryCache.clear();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, { cfg: config, lay: layout });
|
||||
|
||||
const fullPath = resolve(outputDir, `${mode}-full.png`);
|
||||
await page.screenshot({ path: fullPath, animations: 'disabled' });
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const center = await page.evaluate(([x, y]) => {
|
||||
const card = window.__card;
|
||||
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!matrix) return null;
|
||||
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
}, nodes[index]);
|
||||
if (!center) continue;
|
||||
const width = 420, height = 360;
|
||||
const clip = {
|
||||
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
|
||||
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
await page.screenshot({
|
||||
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
|
||||
clip,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
|
||||
@@ -3,6 +3,8 @@
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
@@ -213,8 +215,14 @@ export const makeLargeHouseFixture = () => {
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
// The key must be the real one. A label like `perf-wall-0-3` does not
|
||||
// parse as coordinates, so neither the exact match nor the tolerant
|
||||
// fallback in lookupWall finds the record: every solid edge of this
|
||||
// fixture resolved to zero thickness and the plan carried no wall bodies
|
||||
// at all, while the same fixture backs four golden scenes and all six
|
||||
// performance budgets (#260).
|
||||
walls: segments.map((wall) => ({
|
||||
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* #314 anonymised dense-plan fixture.
|
||||
*
|
||||
* It preserves the structural shape of the owner's report without names,
|
||||
* markers, layout, or original coordinates: 13 rooms, 44 catalogue walls,
|
||||
* 24 intentional partitions, and one pre-existing two-point draft. The last
|
||||
* object is known debt and must remain the only invariant finding before and
|
||||
* after drawing another room.
|
||||
*/
|
||||
import { WALL_KEY_PITCH } from './wall-key.mjs';
|
||||
|
||||
export function makeV8DraftRegressionFixture() {
|
||||
const step = WALL_KEY_PITCH;
|
||||
const point = (x, y) => [x * step, y * step];
|
||||
const segmentIds = new Map();
|
||||
const wallSegments = [];
|
||||
const keyOf = (a, b) => {
|
||||
const left = `${a[0]},${a[1]}`;
|
||||
const right = `${b[0]},${b[1]}`;
|
||||
return left < right ? `${left}|${right}` : `${right}|${left}`;
|
||||
};
|
||||
const wallId = (a, b) => {
|
||||
const key = keyOf(a, b);
|
||||
let id = segmentIds.get(key);
|
||||
if (!id) {
|
||||
id = `wall-314-${String(wallSegments.length + 1).padStart(3, '0')}`;
|
||||
segmentIds.set(key, id);
|
||||
wallSegments.push({ id, a: [...a], b: [...b], cm: 15 });
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const rooms = Array.from({ length: 13 }, (_, index) => {
|
||||
const x0 = 4 + index * 12;
|
||||
const x1 = x0 + 12;
|
||||
const y0 = 4;
|
||||
const y1 = 16;
|
||||
const poly = [point(x0, y0), point(x1, y0), point(x1, y1)];
|
||||
// Four split top edges make the catalogue population exactly 44 while
|
||||
// keeping all rooms valid and non-overlapping.
|
||||
if (index < 4) poly.push(point(x0 + 6, y1));
|
||||
poly.push(point(x0, y1));
|
||||
return {
|
||||
id: `room-314-${String(index + 1).padStart(2, '0')}`,
|
||||
name: `Room ${index + 1}`,
|
||||
poly,
|
||||
wall_ids: poly.map((a, edge) => wallId(a, poly[(edge + 1) % poly.length])),
|
||||
};
|
||||
});
|
||||
|
||||
const partitions = Array.from({ length: 24 }, (_, index) => {
|
||||
const row = Math.floor(index / 8);
|
||||
const x = 4 + (index % 8) * 18;
|
||||
const y = 28 + row * 8;
|
||||
return {
|
||||
id: `partition-314-${String(index + 1).padStart(2, '0')}`,
|
||||
a: point(x, y),
|
||||
b: point(x + 8, y + 2),
|
||||
cm: 15 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
if (wallSegments.length !== 44) {
|
||||
throw new Error(`fixture catalogue drifted: expected 44, got ${wallSegments.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
model_version: 8,
|
||||
spaces: [{
|
||||
id: 'space-314-dense',
|
||||
title: 'Dense regression fixture',
|
||||
cell_cm: 5,
|
||||
view_box: [0, 0, 400, 400],
|
||||
rooms,
|
||||
wall_segments: wallSegments,
|
||||
partitions,
|
||||
room_drafts: [{
|
||||
id: 'draft-314-known-debt',
|
||||
points: [point(4, 56), point(14, 64)],
|
||||
segments: [{ id: 'wall-314-known-draft', cm: 15 }],
|
||||
}],
|
||||
}],
|
||||
markers: [],
|
||||
settings: {},
|
||||
};
|
||||
}
|
||||
@@ -4,22 +4,13 @@ const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Golden fixtures must use the same persisted wall-key contract as real plan
|
||||
// data. Arbitrary labels make every configured wall look virtual to the
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed.
|
||||
const WALL_KEY_PITCH = 1 / 240;
|
||||
export const fixtureWallKey = (a, b) => {
|
||||
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
|
||||
const mx = quantize((a[0] + b[0]) / 2);
|
||||
const my = quantize((a[1] + b[1]) / 2);
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) { dx = 1; dy = 0; }
|
||||
else { dx /= length; dy /= length; }
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed — and a
|
||||
// key that merely differs in precision is found only through the tolerant
|
||||
// fallback in lookupWall, which is luck rather than contract (#260). The
|
||||
// formula lives in one place for every fixture and is pinned to the product one
|
||||
// by test/fixture-wall-key.test.mjs.
|
||||
export { fixtureWallKey } from './wall-key.mjs';
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Ключ записи толщины стены — один на все фикстуры проекта.
|
||||
*
|
||||
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
|
||||
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
|
||||
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
|
||||
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
|
||||
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
|
||||
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
|
||||
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
|
||||
* валидность golden-эталонов, и переиспользование гейтов.
|
||||
*
|
||||
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
|
||||
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
|
||||
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
|
||||
*
|
||||
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
|
||||
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
|
||||
* четырьмя расходилась с продуктом на каждой записи и находилась только через
|
||||
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
|
||||
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
|
||||
*/
|
||||
|
||||
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
|
||||
export const WALL_KEY_PITCH = 1 / 240;
|
||||
|
||||
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
|
||||
const direction = (a, b) => {
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) return [1, 0];
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
||||
return [dx, dy];
|
||||
};
|
||||
|
||||
/**
|
||||
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
|
||||
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
|
||||
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
|
||||
* стене нечётной длины в шагах разводила один и тот же ключ на два.
|
||||
*/
|
||||
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||||
const canonical = (value, pitch) => {
|
||||
if (!(pitch > 0) || !Number.isFinite(value)) return value;
|
||||
const snapped = Math.round(value / pitch) * pitch;
|
||||
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
|
||||
};
|
||||
|
||||
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
|
||||
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
|
||||
? Math.round(value / pitch) * pitch : value);
|
||||
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
|
||||
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
|
||||
const mx = quantise((ca[0] + cb[0]) / 2);
|
||||
const my = quantise((ca[1] + cb[1]) / 2);
|
||||
const [dx, dy] = direction(ca, cb);
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||||
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,61 +1,73 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 40,
|
||||
"acceptedAt": "2026-08-23T15:57:19.621Z",
|
||||
"sourceFingerprint": "b9a6dbc468131fd3b2e2056fd2b205e3a51ad875c1b462edda33fff11310f3fa",
|
||||
"matrixVersion": 47,
|
||||
"acceptedAt": "2026-08-26T20:41:35.180774Z",
|
||||
"sourceFingerprint": "80bd29ef9692067b706f221eaf8d62b4030e3a51b91bc9cff8d854aaee373700",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
|
||||
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "98d82986061569ec9309f22bc50454cf63dbcb83105550f3e0439d39bfbc0142",
|
||||
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
|
||||
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "d4b3918e0597dee2e059153d98812a5035da0f82da20e43ae810b83b8e0ac965",
|
||||
"isometric-geometry-view-light": "e68183fed65d263a2bd80e7600f548a4ab03c03cbfcffd2abea0e011fec0f353",
|
||||
"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": "cf0a7bcc90aaaeb41a70f32ae737ee517e7099add72db03b7938a4a8049886d5",
|
||||
"geometry-view-dark-fit": "e680793d24a575727d5b87910e824f9ba1fbdb9d7a0c6f60b8bfe839afe73a98",
|
||||
"geometry-view-light-fit": "8a2fa3c2c7f28cb55fb3119b645fa68b92dbda71706642e38dba569a7f770eea",
|
||||
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
|
||||
"room-label-parity-plan-dark": "64827483632ce5b66e2ae5cdf7c6858ca7062c3499b1967e8ca31ebca0cb5af1",
|
||||
"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": "0f98689d2fbc21e14a321918ab46c35a9d8463d7db68d78f3c5b00d026c9cc50",
|
||||
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "aa332443ebe5e93b11c569cd4804a0995e1e76b7a5b6aa6844d001a0f6c12991",
|
||||
"day-cycle-day-dark": "af1ab469144fbb3c73d99ff2817f86ec9b8704eadfaef83905cac645eb2eab07",
|
||||
"day-cycle-dusk-dark": "19fdca90fbd9c902bc734b3dc4e5e50c1aa06b22a6e8a19079ad35cd86beb1fb",
|
||||
"day-cycle-night-dark": "b5530a74422e577db90b340a395c0c097907244b330c535169a8e5c4edc8c24b",
|
||||
"geometry-plan-editor-dark": "e937867f27e04d5c3cd6d84cbdb8fa5fc0a8ed9769358368ecee7a8850d57d1f",
|
||||
"space-tab-drop-before-light": "820ebdceaa08b0319a9cf0222c24766aabb78263a7eb3d9474607724618e55d1",
|
||||
"space-tab-drop-after-dark": "fae316777d79e643000eb809e9a2106fe7a4cd39f7cfe12e960286c5f88b490a",
|
||||
"plan-snap-endpoint-light": "59539dd563fa4af530b6e5aa4489937eeaf36df922efd31fc12340bfb779695b",
|
||||
"plan-snap-line-gaps-dark": "066ee3921eec10fb14d829735402e631f81ebfb91dd5c6e8e8cf20ee1e69c483",
|
||||
"wall-junctions-plan-preview-light": "1482b01209a796f93ebc23e231b33fe869ee96b6f46d1fb2d32ccf677a94e602",
|
||||
"wall-junctions-plan-t-dark": "7a7e47db083a737669d4c803b4552bbe7c8700615cde5edaea5b8c553f9345d1",
|
||||
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
|
||||
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
|
||||
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
|
||||
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
|
||||
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
|
||||
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
|
||||
"safe-resize-handles-clamp-dark": "9cb3cb83d2aea4260fdbf1bc1d4281deecedeb9c9dbb0adbed921470b9aaf397",
|
||||
"space-tab-drop-before-light": "19c699fd035030318ce578fdb6427d6243cd3f1d955ea0fbf958cad385e9c7db",
|
||||
"space-tab-drop-after-dark": "1f802274c3015ad6422ee9b50a8d67a412407513da09de188be3db2f7a3873e4",
|
||||
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
|
||||
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
|
||||
"wall-junctions-plan-preview-light": "d92aad64d7aa8641c6743b51a7d13d9166b15c49f3d8aee17b8e61ff263a57a6",
|
||||
"wall-junctions-plan-t-dark": "45a98164ff93b01fbb7f3ac936ee8636c36dd791c09ac3b160b7c7ad803696d1",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "ac3e1818d3e85e8fd4e2a24695deb4461ee8b7f49a87c29528c95a352ef93a8d",
|
||||
"junction-patch-resilience-view-dark": "b91397ccbb9e7d94622f15303c486146b0903c000f517e36efb7abcfa28a7a0a",
|
||||
"multiwall-junction-bevel-view-dark": "ae486afba2a275d95d9a64b13210cc3b43cf01633ba2df2e2898f943e3d44869",
|
||||
"wall-key-roundtrip-view-dark": "28895aa02eb995f976b26a2d2b3675954611641b60910f40427b15a95ef53e3e",
|
||||
"junction-patch-resilience-plan-dark": "90191f637f4d1345d82c63977513524fde353c4e3f87de1a723bf7d3b1f8e91f",
|
||||
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
|
||||
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
|
||||
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
|
||||
"multiwall-junction-bevel-view-dark": "6b02d1d0c5af8b386791cc5764b3bc407129f6576b6d9591c64fd388a8a6865d",
|
||||
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
|
||||
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
|
||||
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "6bb2211e9640800d5b3e1ad413297c7aa93a60bc13bb481b26f09a6abe122838",
|
||||
"opening-placement-passage-thick-wall-dark": "d5befd61224ef98cbe340938e6db321faad354ec167cf3244923bbdafdfbfe17",
|
||||
"opening-placement-passage-thick-wall-light": "950ca5d6011682942e1f216bbf93d76aac50b8f5e335993516c2bade3d62797c",
|
||||
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
|
||||
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
|
||||
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
|
||||
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "209e602446ed79de7f7c5931f96e7d8dc0fdeb311083f01ef73b4df9c5d9e1b6",
|
||||
"geometry-decor-editor-dark": "3a98ded3a0c13306bbb61681dec12879c9baec5e2969a6fb06188693f1de32b7",
|
||||
"tray-wide-selection-en": "74b72708c49a2e3619854eb92fc3ddf1fdfe204e62df8df8a8f1ec70a9c13a90",
|
||||
"tray-wide-tool-ru": "c8cd7a1f8fcc94d3280a958a0b9fac4c974f076c4d4c488baceb509441a386c5",
|
||||
"tray-medium-group-en": "730268ac23f6f7c007b8a21db149b2eabee6a26c8fd054579cf705c92a842596",
|
||||
"tray-medium-selection-ru": "951c3c68516f8d400dd5bb6d5eddfb1fda2b29923281c8c73da342ba55f22408",
|
||||
"tray-narrow-palette-en": "405db03787009eccf3faf7dd93e0b7f557d27d66f02fc5dcb815883bf673a3b7",
|
||||
"tray-narrow-tool-ru": "fce66752a474bc5a330306fad21fc42a0f863576fb73b770cb03423fcbfb6a24",
|
||||
"geometry-diagonal-45-opening-dark": "41b62c9f308b5d8d2e97d4e4d26170c66f40470c11c0ed6df16d6ff7281cb49e",
|
||||
"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",
|
||||
@@ -80,28 +92,48 @@
|
||||
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
|
||||
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
|
||||
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
|
||||
"hover-nested-room-dark": "6ed270f62e7c1cb5e28e800cae46b4839987467bf6b2933c1af212f9b716f2fe",
|
||||
"large-house-zoom-040-dark": "4f52ad1b5b3bff3149429322d23517ef590d8d1c5ea431538a51359955a6d150",
|
||||
"large-house-zoom-250-dark": "cc6776eaa14513e83828107fb2ec2fc8f14ad48ed40b1af4fa3d6e92660d7334",
|
||||
"large-house-warm-remount-dark": "f96230f5a4505da2173d109d4bb7e35f488774daf90f2531d49df322c8c589a3",
|
||||
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
|
||||
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
|
||||
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-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",
|
||||
"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": "46e78386308cd109e25e7b41967421dfa4fbbfb4639710c59e9cc2d88a10fde0",
|
||||
"decor-color-popover-desktop-en": "98d769f5393afb2bad3fb5143a562da5b8bb9a5c8049f4c707706bbcb4f99f8d",
|
||||
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "99fbe17ab8ec383988b944f7db72eeb4e933cab18b0020350ae44befd0b9ae44",
|
||||
"backup-full-preview-desktop-en": "434567d8f97cb09bf217c353bb38e3c4e3aa4c1b837c722a946d75c2c40f34af",
|
||||
"backup-plan-only-export-desktop-en": "8cb234a8d71be11acc6e7bae775f409d3c4b41705072c610f13f40dde06c9c0d",
|
||||
"backup-space-preview-mobile-ru": "698e81b952a26e50b60d2ec85321c43035ff4376a5a348eeed39726804cead78",
|
||||
"optimize-preflight-dialog-dark-en": "d42cab423d2838b7592b661548f7d524d6403db5258419e635733af9ffdda685",
|
||||
"optimize-preflight-dialog-light-ru": "d8262ed5bf93347c4727365aab71897dcb2f24f0c8196e4fcdcbf43b07919b5a",
|
||||
"optimize-orphan-references-dark-en": "a39b2946ea69939941b9f22784111707513d4a4a951df77ed6e8893539ab69eb",
|
||||
"optimize-orphan-references-light-ru": "cb21ccfa4426e5ff92a547ef4f0f4592affbda7f235f419dc84640ef40501892",
|
||||
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
|
||||
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
|
||||
"backup-plan-only-export-desktop-en": "f3c01ae191af41fcc815d27586fe40f965ae3fddbf782553076703ce695457d6",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
|
||||
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
|
||||
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
|
||||
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 351 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 335 KiB |