fix: junction limits judge both sides after the same migration (#329)

The baseline for inheritance was the raw previous document, which for a
legacy space carries no wall catalogue at all — so every inherited short
segment of a real plan looked new and the resize smoke's legitimate write was
refused (executed: two 5 cm segments against their own 30 cm thickness).
Both sides now cross commitWallSegmentModel first, and inheritance is counted
per rule rather than per subject, because a structural write re-keys the
carriers it re-atomises.

Issue: #329
User-Visible: no
This commit is contained in:
Codex
2026-08-27 23:30:52 +03:00
parent 214355f424
commit 4758767e0c
6 changed files with 80 additions and 19 deletions
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceFingerprint": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+15 -4
View File
@@ -87,7 +87,7 @@ import {
} from './wall-thickness';
import {
checkNodeDistances, checkNodes, checkRoomClearance, checkSegmentLengths,
newViolations, type JunctionLimitViolation, type LimitSegment,
increasedViolations, type JunctionLimitViolation, type LimitSegment,
} from './junction-limits';
import {
pointOnOpenCut, sanitizeOpenSpans,
@@ -7450,13 +7450,24 @@ class HouseplanCard extends LitElement {
private _junctionLimitsIntroduced(
candidate: any, previousConfig: any, spaceId: string,
): JunctionLimitViolation[] {
// The baseline must be the previous document AS THE CANDIDATE SEES IT: a
// legacy space carries no wall catalogue at all, so comparing it raw with
// a migrated candidate reported every inherited short segment as new and
// refused legitimate resizes of real plans. Both sides therefore cross
// the same identity barrier first.
let inherited: JunctionLimitViolation[] = [];
try { inherited = this._junctionLimitViolations(previousConfig, spaceId); }
catch { inherited = []; }
try {
const migrated = commitWallSegmentModel(previousConfig).config;
inherited = this._junctionLimitViolations(migrated, spaceId);
} catch {
// An unmigratable baseline proves nothing about inheritance; never
// refuse the write on that basis.
return [];
}
let next: JunctionLimitViolation[] = [];
try { next = this._junctionLimitViolations(candidate, spaceId); }
catch { return []; }
return newViolations(next, inherited);
return increasedViolations(next, inherited);
}
private _commitPhysicalGeometry(
+28
View File
@@ -195,6 +195,34 @@ export function checkRoomClearance(
return [];
}
/**
* Violations introduced BY THIS WRITE, counted per rule.
*
* Subject identity churns across a structural write (segments are re-atomised
* and re-keyed), so matching by subject would report an inherited violation as
* new the moment its carrier is re-keyed — that alone refused legitimate
* resizes of a real plan. Counting per rule keeps the spec's boundary (§3)
* without depending on identity: a write may keep existing violations, never
* add one.
*/
export function increasedViolations(
candidate: readonly JunctionLimitViolation[],
previous: readonly JunctionLimitViolation[],
): JunctionLimitViolation[] {
const before = new Map<JunctionLimitRule, number>();
for (const item of previous || []) before.set(item.rule, (before.get(item.rule) || 0) + 1);
const after = new Map<JunctionLimitRule, JunctionLimitViolation[]>();
for (const item of candidate || []) {
after.set(item.rule, [...(after.get(item.rule) || []), item]);
}
const introduced: JunctionLimitViolation[] = [];
for (const [rule, items] of after) {
const grew = items.length - (before.get(rule) || 0);
if (grew > 0) introduced.push(...items.slice(0, grew));
}
return introduced;
}
/** Violations introduced BY THIS WRITE: inherited ones are never reported. */
export function newViolations(
candidate: readonly JunctionLimitViolation[],
+22
View File
@@ -135,3 +135,25 @@ test('§4: вырожденная вершина сходится в точку
// Нулевая толщина не создаёт вырожденной зоны.
assert.equal(isDegenerateApexCorner(spike, [0, 0, 0], 1), false);
});
test('наследование считается по правилам, а не по id носителя', async () => {
const { increasedViolations } = await import('../test-build/junction-limits.js');
const inherited = [
{ rule: 'length', subject: 'wall-old-1', actual: 5, limit: 20 },
{ rule: 'length', subject: 'wall-old-2', actual: 7, limit: 20 },
];
// Структурная запись переатомизировала стены: те же два нарушения под
// новыми id — запись законна (ровно кейс ресайза реального плана).
const rekeyed = [
{ rule: 'length', subject: 'wall-new-a', actual: 5, limit: 20 },
{ rule: 'length', subject: 'wall-new-b', actual: 7, limit: 20 },
];
assert.deepEqual(increasedViolations(rekeyed, inherited), []);
// Появилось третье — вот оно и есть новое.
const worse = [...rekeyed, { rule: 'length', subject: 'wall-new-c', actual: 3, limit: 20 }];
assert.equal(increasedViolations(worse, inherited).length, 1);
// Новое правило на чистом плане.
assert.equal(increasedViolations(
[{ rule: 'angle', subject: 'n', actual: 9, limit: 15 }], [],
).length, 1);
});