${this._t("backup.export_hint")}
@@ -5663,6 +5663,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f45b05525f679e990630ddd05566d43528cd
${e.removedDrafts?W`
${this._t("gs.align_removed_drafts",{n:String(e.removedDrafts)})}
`:G}
${e.redundantDraftsRemoved?W`
${this._t("gs.optimize_redundant_drafts",{n:String(e.redundantDraftsRemoved)})}
`:G}
${e.wallSegmentsMigrated?W`
${this._t("gs.wall_segments_migrated",{n:String(e.wallSegmentsMigrated)})}
`:G}
+ ${e.legacyZeroWallsMigrated?W`
${this._t("gs.zero_walls_migrated",{n:String(e.legacyZeroWallsMigrated)})}
`:G}
${c?W`
${this._t("gs.optimize_changes",{m:String(e.migrated),c:String(e.canonicalized),p:String(e.coordsCanonicalized),w:String(e.wallsMerged),s:String(e.spansMerged),i:String(e.partitionsMerged)})}
`:G}
${e.partitionsReconciled?W`
${this._t("gs.optimize_coincident_partitions",{n:String(e.partitionsReconciled)})}
`:G}
${e.openingsRehosted?W`
${this._t("gs.optimize_openings_rehosted",{n:String(e.openingsRehosted)})}
`:G}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 2df72677..5d1a4d6e 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,7 +10,10 @@
into stable model-v9 wall segments; all existing `cm:0` segments deliberately
receive the same semantics, so a small number of old plans may change
appearance or light transmission. Zero walls create no masonry, area or
- opening tunnel and cannot host an opening
+ opening tunnel and cannot host an opening. Export a backup before the first
+ structural save, import or **Optimize plans** that writes model v9: downgrade
+ to an older version does not understand the new wall model, and restoring
+ that backup is the supported way back
([#306](https://github.com/Matysh/houseplan-card/issues/306)).
## v1.68.0-beta.2 — 2026-08-26
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 08bb1c7e..d9ffec7c 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -17,7 +17,10 @@
мигрируют в стабильные сегменты model v9; все прежние `cm:0` намеренно
получают ту же семантику, поэтому у небольшой части старых планов может
измениться вид или прохождение света. Нулевая стена не создаёт кладку,
- площадь или тоннель и не может содержать проём
+ площадь или тоннель и не может содержать проём. Перед первым структурным
+ сохранением, импортом или **«Оптимизировать планы»**, записывающим model v9,
+ экспортируйте резервную копию: старая версия не понимает новую модель стен,
+ и поддерживаемый путь назад — восстановление этой копии
([#306](https://github.com/Matysh/houseplan-card/issues/306)).
## v1.68.0-beta.2 — 2026-08-26
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index f92b29e8..ee789cb2 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -7347,6 +7347,25 @@ class HouseplanCard extends LitElement {
return this._t(`wall_model.reason.${reason}`);
}
+ /** A legacy virtual-wall projection is still present only until its first
+ * structural v9 write. Keep its atomic-failure copy distinct from generic
+ * wall-model validation without persisting provenance on the new atoms. */
+ private _hasLegacyZeroWallFields(config: any = this._serverCfg): boolean {
+ return (config?.spaces || []).some((space: any) => (
+ (Array.isArray(space?.open_spans) && space.open_spans.length > 0)
+ || (space?.rooms || []).some((room: any) => (
+ Array.isArray(room?.open_to) && room.open_to.length > 0
+ ))
+ ));
+ }
+
+ private _showWallModelMigrationBlocked(error: unknown): void {
+ const key = this._hasLegacyZeroWallFields()
+ ? 'toast.zero_wall_migration_blocked'
+ : 'toast.wall_model_migration_blocked';
+ this._showToast(this._t(key, { reason: this._wallModelBlockerLabel(error) }));
+ }
+
private _commitPhysicalGeometry(
name: string,
before: SpaceGeometryState | null,
@@ -7417,9 +7436,7 @@ class HouseplanCard extends LitElement {
} catch (error) {
this._clearGeometryGesture();
this._restoreGeometryStateLocal(before);
- this._showToast(this._t('toast.wall_model_migration_blocked', {
- reason: this._wallModelBlockerLabel(error),
- }));
+ this._showWallModelMigrationBlocked(error);
return false;
}
let safe = false;
@@ -7551,9 +7568,7 @@ class HouseplanCard extends LitElement {
committedCandidate = commitWallSegmentModel(restoredCandidate).config;
} catch (error) {
this._restoreGeometryStateLocal(before);
- this._showToast(this._t('toast.wall_model_migration_blocked', {
- reason: this._wallModelBlockerLabel(error),
- }));
+ this._showWallModelMigrationBlocked(error);
return false;
}
if (physicalChanged) {
@@ -8124,7 +8139,9 @@ class HouseplanCard extends LitElement {
});
if (repair.kind === 'ambiguous') {
this._wallRepairDiagnostic = repair.proposals[0] || null;
- this._showToast(this._t('toast.wall_repair_ambiguous'));
+ this._showToast(this._t(
+ this._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous',
+ ));
return;
}
if (repair.kind === 'repair' && !this._overlapRoom(repair.face.ring)) {
@@ -8200,7 +8217,9 @@ class HouseplanCard extends LitElement {
});
if (result.kind === 'ambiguous') {
this._wallRepairDiagnostic = result.proposals[0] || null;
- this._showToast(this._t('toast.wall_repair_ambiguous'));
+ this._showToast(this._t(
+ this._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous',
+ ));
return true;
}
if (result.kind === 'repair') {
@@ -8327,7 +8346,7 @@ class HouseplanCard extends LitElement {
if (p && cmRaw === 0 && zeroWallHasOpening(sp.openings, {
kind: 'partition', id: d.id,
})) {
- this._showToast(this._t('toast.zero_wall_opening'));
+ this._showToast(this._t('toast.zero_wall_opening_conflict'));
return;
}
if (p) p.cm = cmRaw;
@@ -11699,7 +11718,7 @@ class HouseplanCard extends LitElement {
if (cmRaw === 0 && zeroWallHasOpening(sp.openings, {
kind: 'partition', id: partition.id,
})) {
- this._showToast(this._t('toast.zero_wall_opening'));
+ this._showToast(this._t('toast.zero_wall_opening_conflict'));
return;
}
partition.cm = cmRaw;
@@ -11739,7 +11758,7 @@ class HouseplanCard extends LitElement {
return point.every(Number.isFinite) && distToSegment(point, target) <= eps;
});
if (blocked) {
- this._showToast(this._t('toast.zero_wall_opening'));
+ this._showToast(this._t('toast.zero_wall_opening_conflict'));
return;
}
}
@@ -15657,9 +15676,7 @@ class HouseplanCard extends LitElement {
this._optimizeReferenceContext(removeLiveMissingPositions),
);
} catch (error) {
- this._showToast(this._t('toast.wall_model_migration_blocked', {
- reason: this._wallModelBlockerLabel(error),
- }));
+ this._showWallModelMigrationBlocked(error);
return;
}
const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null;
@@ -16917,6 +16934,9 @@ class HouseplanCard extends LitElement {
${r.wallSegmentsMigrated ? html`
${this._t(
'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) },
)}
` : nothing}
+ ${r.legacyZeroWallsMigrated ? html`
${this._t(
+ 'gs.zero_walls_migrated', { n: String(r.legacyZeroWallsMigrated) },
+ )}
` : nothing}
${modelMaintenance ? html`
${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a84e8b81..d409468d 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -534,7 +534,9 @@
"toast.wallthick_set": "Wall thickness set",
"toast.wallthick_cleared": "Wall thickness removed",
"toast.physical_range": "Enter a value from {min} to {max} {unit}",
- "toast.zero_wall_opening": "Remove the opening on this wall stretch first",
+ "toast.zero_wall_opening_conflict": "Remove the opening on this wall segment first.",
+ "toast.zero_wall_ambiguous": "The wall segment is ambiguous. Simplify or adjust the junction.",
+ "toast.zero_wall_migration_blocked": "The space was not converted: {reason}. No data was changed.",
"toast.physical_angle": "Enter a rotation angle from 0° up to, but not including, 90°",
"toast.physical_limit": "The space has reached the limit for this type of geometry",
"toast.geometry_unsafe": "Change canceled: wall geometry could not be built safely.",
@@ -815,7 +817,8 @@
"gs.preflight_update_hint": "The card and integration versions differ — update House Plan and retry.",
"gs.align_preflight_space": "Space {n}",
"gs.align_preflight_more": ", and {n} more",
- "gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
+ "gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; merged zero-thickness wall fragments: {s}; independent walls: {i}.",
+ "gs.zero_walls_migrated": "Virtual wall spans converted: {n}.",
"gs.wall_segments_migrated": "Wall segments stabilised: {n}.",
"gs.optimize_lattice_summary": "Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.",
"gs.optimize_lattice_space": "{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 866d46ee..285d3ca1 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -534,7 +534,9 @@
"toast.wallthick_set": "Толщина стены задана",
"toast.wallthick_cleared": "Толщина стены убрана",
"toast.physical_range": "Введите значение от {min} до {max} {unit}",
- "toast.zero_wall_opening": "Сначала удалите проём на этом участке стены",
+ "toast.zero_wall_opening_conflict": "Сначала удалите проём на этом участке стены.",
+ "toast.zero_wall_ambiguous": "Не удалось однозначно выбрать участок стены. Уточните геометрию узла.",
+ "toast.zero_wall_migration_blocked": "Пространство не преобразовано: {reason}. Данные не изменены.",
"toast.physical_angle": "Введите угол поворота от 0° до 90°, не включая 90°",
"toast.physical_limit": "В пространстве достигнут лимит объектов этого типа",
"toast.geometry_unsafe": "Изменение отменено: геометрию стен нельзя безопасно построить.",
@@ -815,7 +817,8 @@
"gs.preflight_update_hint": "Версии карточки и интеграции различаются — обновите House Plan и повторите.",
"gs.align_preflight_space": "Пространство {n}",
"gs.align_preflight_more": " и ещё {n}",
- "gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
+ "gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; объединено отрезков стен нулевой толщины: {s}; независимых: {i}.",
+ "gs.zero_walls_migrated": "Преобразовано виртуальных участков: {n}.",
"gs.wall_segments_migrated": "Стабилизировано сегментов стен: {n}.",
"gs.optimize_lattice_summary": "Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.",
"gs.optimize_lattice_space": "{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.",
diff --git a/src/plan-optimizer.ts b/src/plan-optimizer.ts
index c3cafba8..0a228e50 100644
--- a/src/plan-optimizer.ts
+++ b/src/plan-optimizer.ts
@@ -57,9 +57,11 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport {
canonicalized: number;
/** Contour-wall atoms materialised into the stable v8 catalogue. */
wallSegmentsMigrated: number;
+ /** Read-compatible legacy virtual spans converted into zero-wall atoms. */
+ legacyZeroWallsMigrated: number;
/** Redundant equal-thickness wall entries removed by canonicalisation. */
wallsMerged: number;
- /** Touching/overlapping virtual pieces merged on the same room pair. */
+ /** Touching/overlapping zero-wall atoms merged on the same room pair. */
spansMerged: number;
/** Collinear independent-wall records collapsed into one (#229). */
partitionsMerged: number;
@@ -480,6 +482,7 @@ export function optimizePlans(
} : { ...aligned.report };
let wallsMerged = 0;
+ let legacyZeroWallsMigrated = 0;
let spansMerged = 0;
let partitionsMerged = 0;
let partitionsReconciled = 0;
@@ -513,7 +516,9 @@ export function optimizePlans(
// the final identity barrier assign stable catalogue IDs. No production
// maintenance pass writes open_spans/open_to back into the candidate.
const knownZeroKeys = new Set(sourceZeroWalls.map((wall) => wall.key));
- for (const line of legacyZeroContourLines(before, oldModel.rooms, NORM_W, eps)) {
+ const legacyZeroLines = legacyZeroContourLines(before, oldModel.rooms, NORM_W, eps);
+ legacyZeroWallsMigrated += legacyZeroLines.length;
+ for (const line of legacyZeroLines) {
const a = [line[0] / NORM_W, line[1] / NORM_W];
const b = [line[2] / NORM_W, line[3] / NORM_W];
const key = wallKey(a, b, GRID_STEP_N);
@@ -693,6 +698,7 @@ export function optimizePlans(
glowRoomsMigrated: changed ? migration.glowRooms : 0,
canonicalized: changed ? canonicalized : 0,
wallSegmentsMigrated: changed ? wallSegmentsMigrated : 0,
+ legacyZeroWallsMigrated: changed ? legacyZeroWallsMigrated : 0,
wallsMerged: changed ? wallsMerged : 0,
spansMerged: changed ? spansMerged : 0,
partitionsMerged: changed ? partitionsMerged : 0,
diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs
index d0993f19..ac8bc0da 100644
--- a/test/i18n.test.mjs
+++ b/test/i18n.test.mjs
@@ -51,12 +51,15 @@ test('issue 251 unavailable controls toast has exact singular and plural copy',
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
assert.equal(
en['gs.optimize_changes'],
- 'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.',
+ 'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; merged zero-thickness wall fragments: {s}; independent walls: {i}.',
);
assert.equal(
ru['gs.optimize_changes'],
- 'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.',
+ 'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; объединено отрезков стен нулевой толщины: {s}; независимых: {i}.',
);
+ assert.equal(en['gs.zero_walls_migrated'], 'Virtual wall spans converted: {n}.');
+ assert.equal(ru['gs.zero_walls_migrated'], 'Преобразовано виртуальных участков: {n}.');
+ assert.match(cardSource, /gs\.zero_walls_migrated/);
assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
// #229: the independent-wall counter is reported, not silently accumulated
assert.match(cardSource, /i: String\(r\.partitionsMerged\)/);
@@ -66,6 +69,28 @@ test('Optimize distinguishes updated spaces from cleaned coordinate noise', () =
/d\.report\.coordsCanonicalized \+ d\.report\.latticeCoordinatesCanonicalized/);
});
+test('issue 306 zero-wall failures have dedicated symmetric copy', () => {
+ const expected = {
+ 'toast.zero_wall_opening_conflict': [
+ 'Remove the opening on this wall segment first.',
+ 'Сначала удалите проём на этом участке стены.',
+ ],
+ 'toast.zero_wall_ambiguous': [
+ 'The wall segment is ambiguous. Simplify or adjust the junction.',
+ 'Не удалось однозначно выбрать участок стены. Уточните геометрию узла.',
+ ],
+ 'toast.zero_wall_migration_blocked': [
+ 'The space was not converted: {reason}. No data was changed.',
+ 'Пространство не преобразовано: {reason}. Данные не изменены.',
+ ],
+ };
+ for (const [key, [english, russian]] of Object.entries(expected)) {
+ assert.equal(en[key], english);
+ assert.equal(ru[key], russian);
+ assert.match(cardSource, new RegExp(key.replaceAll('.', '\\.')));
+ }
+});
+
test('issue 291 Optimize reports lattice cleanup separately in both languages', () => {
assert.equal(
en['gs.optimize_lattice_summary'],
diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs
index 1e838ed6..63b306c9 100644
--- a/test/plan-optimizer.test.mjs
+++ b/test/plan-optimizer.test.mjs
@@ -47,7 +47,8 @@ const assertNoPersistedChanges = (result) => {
for (const field of [
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
- 'spansMerged', 'partitionsMerged', 'partitionsReconciled', 'openingsRehosted',
+ 'legacyZeroWallsMigrated', 'spansMerged', 'partitionsMerged',
+ 'partitionsReconciled', 'openingsRehosted',
'redundantDraftsRemoved',
'wallsStraightened',
'spaceRefsRemapped', 'roomRefsRemapped',
@@ -596,6 +597,7 @@ test('optimizePlans migrates, aligns and canonicalises idempotently', () => {
const sharedIds = left.wall_ids.filter((id) => right.wall_ids.includes(id));
assert.ok(sharedIds.some((id) => first.config.spaces[0].wall_segments
.some((segment) => segment.id === id && segment.cm === 0)));
+ assert.equal(first.report.legacyZeroWallsMigrated, 1);
assert.equal(first.config.spaces[0].walls.length, 1);
assert.equal(first.layout.m1.x % S, 0);