fix: address zero-wall review findings

Issue: #306
User-Visible: yes
This commit is contained in:
Matysh
2026-08-26 14:01:58 +03:00
parent db4e0ef999
commit f15bdc678b
10 changed files with 104 additions and 37 deletions
File diff suppressed because one or more lines are too long
+7 -6
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+34 -14
View File
@@ -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`<p class="alignmsg">${this._t(
'gs.wall_segments_migrated', { n: String(r.wallSegmentsMigrated) },
)}</p>` : nothing}
${r.legacyZeroWallsMigrated ? html`<p class="alignmsg">${this._t(
'gs.zero_walls_migrated', { n: String(r.legacyZeroWallsMigrated) },
)}</p>` : nothing}
${modelMaintenance ? html`<p class="alignmsg">${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
+5 -2
View File
@@ -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}.",
+5 -2
View File
@@ -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}.",
+8 -2
View File
@@ -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,
+27 -2
View File
@@ -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'],
+3 -1
View File
@@ -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);