fix: preserve migrated wall interactions

Issue: #282
User-Visible: yes
This commit is contained in:
Matysh
2026-08-26 02:21:26 +03:00
parent 20f5c39699
commit cfc1bb063e
11 changed files with 135 additions and 32 deletions
+3 -2
View File
@@ -75,9 +75,10 @@ const out = await page.evaluate(async (sourceSpace) => {
const north = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'north-west');
const south = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'south-west');
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
const samePoint = (left, right) => JSON.stringify(left) === JSON.stringify(right);
result.applyMovesBothOwnersToExactAxis = north.poly[1][1] === north.poly[2][1]
&& JSON.stringify(north.poly[1]) === JSON.stringify(south.poly[0])
&& JSON.stringify(north.poly[2]) === JSON.stringify(south.poly[3]);
&& south.poly.some((point) => samePoint(point, north.poly[1]))
&& south.poly.some((point) => samePoint(point, north.poly[2]));
result.applyRekeysWall = card._serverCfg.spaces[0].walls.some((wall) => (
wall.a[1] === wall.b[1] && wall.key.endsWith('@0.0000')
));
+3 -3
View File
@@ -93,9 +93,9 @@ const res = await page.evaluate(async () => {
const probeR3 = [802, 480];
const beforeReachesR3 = isLit(probeR3);
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
// Create the second span through the same production boundary tool. Direct
// legacy open_to injection would make the first click close that already
// open wall instead of anchoring a new partial span.
c._setMode('plan'); c._tool = 'boundary';
// shared r2|r3 at y=460, x 550..960
c._boundaryClick([700, 460]);
+9 -5
View File
@@ -7,6 +7,8 @@ const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const clone = (value) => JSON.parse(JSON.stringify(value));
const wallHostResolves = (space, opening) => opening.host?.kind === 'wall'
&& (space.wall_segments || []).some((segment) => segment.id === opening.host.id);
const sharedX = 0.5;
const original = {
model_version: 7,
@@ -64,11 +66,13 @@ const out = await page.evaluate(async () => {
card._openAlignDialog(); await card.updateComplete;
const preview = card._alignDialog;
const previewSpace = preview?.config?.spaces?.[0];
result.previewIsExact = !!preview?.changed && preview.preflight?.ok === true
&& preview.report.partitionsReconciled === 1
&& preview.report.openingsRehosted === 1
&& preview.config.spaces[0].partitions == null
&& preview.config.spaces[0].openings[0].host == null;
&& preview.config.model_version === 8
&& previewSpace.partitions == null
&& wallHostResolves(previewSpace, previewSpace.openings[0]);
result.previewDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
result.reportRendersBothCounters = card.renderRoot.querySelectorAll('hp-dialog .alignmsg').length >= 2;
@@ -77,7 +81,7 @@ const out = await page.evaluate(async () => {
const applied = card._serverCfg.spaces[0];
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyKeepsOpeningFields = applied.partitions == null
&& applied.openings[0].host == null
&& wallHostResolves(applied, applied.openings[0])
&& applied.openings[0].id === 'door'
&& applied.openings[0].contact === 'binary_sensor.test_door'
&& applied.openings[0].lock === 'lock.test_door'
@@ -88,7 +92,7 @@ const out = await page.evaluate(async () => {
await card._loadFromServer(); await card.updateComplete;
result.reloadKeepsCanonicalBody = card._serverCfg.spaces[0].partitions == null
&& card._serverCfg.spaces[0].openings[0].host == null;
&& wallHostResolves(card._serverCfg.spaces[0], card._serverCfg.spaces[0].openings[0]);
await card._undoPlanOptimization(); await card.updateComplete;
result.undoRestoresHostedPartition = JSON.stringify(card._serverCfg.spaces[0].partitions)
@@ -113,7 +117,7 @@ const out = await page.evaluate(async () => {
const finalSpace = card._serverCfg.spaces[0];
result.thicknessChangesSingleBody = finalSpace.partitions == null
&& finalSpace.walls.length === 1 && finalSpace.walls[0].cm === 10
&& finalSpace.openings[0].host == null;
&& wallHostResolves(finalSpace, finalSpace.openings[0]);
return result;
});
+7 -4
View File
@@ -14,6 +14,8 @@ const out = await page.evaluate(async (fixture) => {
const result = {};
const card = window.__card;
const copy = (value) => JSON.parse(JSON.stringify(value));
const wallHostResolves = (space, opening) => opening.host?.kind === 'wall'
&& (space.wall_segments || []).some((segment) => segment.id === opening.host.id);
let serverConfig = copy(fixture);
let serverLayout = {};
const sent = [];
@@ -82,21 +84,22 @@ const out = await page.evaluate(async (fixture) => {
card._openAlignDialog();
await card.updateComplete;
const previewSpace = card._alignDialog?.config?.spaces?.[0];
result.previewProvesOuterRewrite = card._alignDialog?.changed === true
&& card._alignDialog?.preflight?.ok === true
&& card._alignDialog?.report?.partitionsReconciled === 3
&& card._alignDialog?.report?.openingsRehosted === 2
&& card._alignDialog?.config?.spaces?.[0]?.partitions == null
&& card._alignDialog?.config?.spaces?.[0]?.openings?.every((opening) => opening.host == null);
&& card._alignDialog?.config?.model_version === 8
&& previewSpace?.partitions == null
&& previewSpace?.openings?.every((opening) => wallHostResolves(previewSpace, opening));
await card._runAlignToGrid();
await card.updateComplete;
result.optimizeUsesOneWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
const optimized = card._serverCfg.spaces[0];
result.optimizePreservesOpeningFields = optimized.partitions == null
&& optimized.openings[0].host == null
&& optimized.openings.every((opening) => wallHostResolves(optimized, opening))
&& optimized.openings[0].cover === 'cover.left'
&& optimized.openings[0].future_field?.keep === 'left'
&& optimized.openings[1].host == null
&& optimized.openings[1].contact === 'binary_sensor.right';
await resetResize();
+24 -7
View File
@@ -150,8 +150,22 @@ const res = await page.evaluate(async () => {
&& c._openCuts().length === 1;
c._closeOpenSpan(c._openCuts()[0]);
await upd();
const restoredPieces = sp().walls || [];
const restoredLength = restoredPieces.reduce((sum, wall) => sum + Math.hypot(
wall.b[0] - wall.a[0], wall.b[1] - wall.a[1],
) * 1000, 0);
const orderedRestored = restoredPieces.slice().sort((a, b) => a.a[1] - b.a[1]);
const restoredContinuous = orderedRestored.length > 0
&& Math.abs(orderedRestored[0].a[1] * 1000 - 140) < 1e-6
&& Math.abs(orderedRestored.at(-1).b[1] * 1000 - 460) < 1e-6
&& orderedRestored.every((wall, index) => Math.abs(wall.a[0] - 0.55) < 1e-9
&& Math.abs(wall.b[0] - 0.55) < 1e-9
&& (!index || Math.abs(wall.a[1] - orderedRestored[index - 1].b[1]) < 1e-9));
out.closeRejoinsUniformWall = !sp().open_spans
&& (sp().walls || []).length === 1
&& restoredPieces.length > 0
&& restoredPieces.every((wall) => wall.cm === 20)
&& restoredContinuous
&& Math.abs(restoredLength - 320) < 1e-6
&& c._intervalCm(restoredCut) === 20;
} else {
out.adjacentSpansPersistAsOne = false;
@@ -166,6 +180,8 @@ const res = await page.evaluate(async () => {
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
];
c._serverCfg.model_version = 7;
delete sp().wall_segments;
delete sp().walls;
delete sp().open_spans;
c._tool = 'wallthick';
@@ -200,6 +216,8 @@ const res = await page.evaluate(async () => {
{ id: 'middle', name: 'Middle', poly: [[0.3, 0.5], [0.6, 0.5], [0.6, 0.9], [0.3, 0.9]] },
{ id: 'right', name: 'Right', poly: [[0.6, 0.5], [0.9, 0.5], [0.9, 0.9], [0.6, 0.9]] },
];
c._serverCfg.model_version = 7;
delete sp().wall_segments;
sp().walls = [{
key: '0.600000,0.500000@0.0000', cm: 22,
a: [0.3, 0.5], b: [0.9, 0.5],
@@ -208,9 +226,7 @@ const res = await page.evaluate(async () => {
delete sp().openings;
c._tool = 'boundary';
await upd();
const atomicParentBefore = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
});
const atomicParentBefore = JSON.stringify(sp().open_spans);
const atomicParentCut = c._openCuts()[0];
const atomicParentPlan = atomicParentCut && c._planClosedOpenSpan(atomicParentCut);
out.atomicParentClosePreviewInherits = atomicParentPlan?.cm === 22;
@@ -220,9 +236,10 @@ const res = await page.evaluate(async () => {
&& c._intervalCm([100, 500, 300, 500]) === 22;
c._undoGeometry();
await upd();
out.atomicParentCloseUndo = JSON.stringify({
rooms: sp().rooms, walls: sp().walls, open_spans: sp().open_spans,
}) === atomicParentBefore;
out.atomicParentCloseUndo = JSON.stringify(sp().open_spans) === atomicParentBefore
&& c._intervalCm([100, 500, 300, 500]) === 0
&& c._intervalCm([300, 500, 600, 500]) === 22
&& c._intervalCm([600, 500, 900, 500]) === 22;
return out;
});
+11 -6
View File
@@ -218,12 +218,14 @@ const rayInfo = () => page.evaluate(() => {
const c = window.__card;
const rays = c._sunRaysCache?.rays || [];
const r = rays.find((q) => q.openingId === 'wE');
const space = c._serverCfg.spaces.find((s) => s.id === 'f1');
const opening = space.openings.find((o) => o.id === 'wE');
return {
ids: rays.map((q) => q.openingId).sort(),
midY: r ? (r.a[1] + r.b[1]) / 2 : NaN,
minX: r ? Math.min(...r.polys.flat().map((p) => p[0])) : NaN,
rev: c._cfgRev,
wEy: c._serverCfg.spaces.find((s) => s.id === 'f1').openings.find((o) => o.id === 'wE').y,
wEy: opening.y,
};
});
@@ -276,8 +278,9 @@ check('b701_ray_not_stale', Math.abs(moved.midY - 600) > 50, true);
const clipped = await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
const before = c._geometrySnapshot();
sp.rooms.find((r) => r.id === 'r3').poly = [[0.90, 0.46], [0.96, 0.46], [0.96, 0.86], [0.90, 0.86]];
c._saveConfig(); // the one true local-save entry point
c._commitPhysicalGeometry('sun smoke room edit', before);
c.requestUpdate(); await c.updateComplete;
return true;
});
@@ -321,10 +324,12 @@ const thr = await page.evaluate(async () => {
// either absent or at FULL strength, and crossing 3° fades the whole LAYER
// (never the geometry) in or out over exactly two seconds
await setSun(90, 10);
out.aboveThresholdDrawn = domPolys().length > 0 && !layer().classList.contains('out');
out.aboveThresholdDrawn = domPolys().length > 0 && !layer()?.classList.contains('out');
{
const cs = getComputedStyle(layer());
out.fadeInTakesTwoSeconds = cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
const current = layer();
const cs = current && getComputedStyle(current);
out.fadeInTakesTwoSeconds = !!cs
&& cs.animationName === 'hp-sunfade-in' && cs.animationDuration === '2s';
}
out.fullAlphaWellAboveThreshold = Math.abs(stopAlpha() - 0.3) < 1e-6;
await setSun(90, 3.2);
@@ -346,7 +351,7 @@ const thr = await page.evaluate(async () => {
await setSun(90, 1);
out.coldStartBelowThresholdEmpty = domPolys().length === 0 && !layer();
await setSun(90, 5);
out.backAboveThreshold = domPolys().length > 0 && !layer().classList.contains('out');
out.backAboveThreshold = domPolys().length > 0 && !layer()?.classList.contains('out');
return out;
});
Object.assign(res, thr);
+32
View File
@@ -13,6 +13,34 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const hostContourOpenings = () => {
for (const opening of sp().openings || []) {
if (opening.host) continue;
const centre = [Number(opening.x), Number(opening.y)];
const candidates = (sp().wall_segments || []).filter((segment) => {
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > 0)) return false;
const t = ((centre[0] - segment.a[0]) * dx
+ (centre[1] - segment.a[1]) * dy) / len2;
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
const delta = Math.abs((((angle - Number(opening.angle)) % 180) + 270) % 180 - 90);
const half = Number(opening.length) / 2;
const len = Math.sqrt(len2);
return t >= 0 && t <= 1 && Math.hypot(centre[0] - q[0], centre[1] - q[1]) < 1e-8
&& Math.min(delta, Math.abs(180 - delta)) < 1e-6
&& t * len >= half - 1e-9 && (1 - t) * len >= half - 1e-9;
});
if (candidates.length === 1) {
const segment = candidates[0];
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const t = ((centre[0] - segment.a[0]) * dx
+ (centre[1] - segment.a[1]) * dy) / (dx * dx + dy * dy);
opening.host = { kind: 'wall', id: segment.id, t };
}
}
};
const floorAreaOf = (id) => {
const rooms = c._spaceModel().rooms;
const walls = sp().walls || [];
@@ -76,6 +104,7 @@ const res = await page.evaluate(async () => {
sp().openings = [{
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
}];
hostContourOpenings();
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
@@ -107,6 +136,7 @@ const res = await page.evaluate(async () => {
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: false,
}];
hostContourOpenings();
await upd();
out.doorDrawn = !!sr().querySelector('[data-hp="opening"][data-kind="door"]');
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
@@ -133,6 +163,7 @@ const res = await page.evaluate(async () => {
{ id: 'wtGateFlipped', type: 'gate', x: 0.55, y: 0.30, angle: 90, length: 0.08,
flip_v: true },
];
hostContourOpenings();
await upd();
const sharedGateDefault = firstGateTurn('wtGateDefault');
const sharedGateFlipped = firstGateTurn('wtGateFlipped');
@@ -158,6 +189,7 @@ const res = await page.evaluate(async () => {
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: true,
}];
hostContourOpenings();
await upd();
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
+3 -1
View File
@@ -25,7 +25,9 @@
An invalid open passage also retains its specific actionable error instead
of being masked by the generic wall-model migration blocker. The compact
space card keeps wall-hosted doors, windows, gates and passages visible after
the migration as well.
the migration as well. Wall-hosted windows keep casting sunlight after an
edit, partial open boundaries remain writable on migrated plans, and clearing
wall thickness no longer restores its previous value.
Ambiguous legacy geometry is
left unchanged with a recovery prompt. No Plan editor controls or
zero-thickness-wall behaviour changed
+3
View File
@@ -30,6 +30,9 @@
открытый проход по-прежнему получает своё точное сообщение об ошибке, а не
общий блокер миграции стен. Компактная карточка пространства также не теряет
после миграции двери, окна, ворота и проходы, привязанные к стенам.
Окна на стенах продолжают пропускать солнечные лучи после правки, частичные
открытые границы сохраняются и на уже мигрированных планах, а удалённая
толщина стены больше не восстанавливается в прежнее значение.
Неоднозначная старая геометрия остаётся без изменений с подсказкой по
восстановлению. Кнопки редактора плана и поведение стен нулевой толщины не
менялись
+23 -4
View File
@@ -7293,7 +7293,11 @@ class HouseplanCard extends LitElement {
return this._t(`wall_model.reason.${reason}`);
}
private _commitPhysicalGeometry(name: string, before: SpaceGeometryState | null): boolean {
private _commitPhysicalGeometry(
name: string,
before: SpaceGeometryState | null,
additionalAuthoredPoints: readonly (readonly number[])[] = [],
): boolean {
if (!before || !this._serverCfg) return false;
const liveCandidate = this._serverCfg;
const editedState = this._geometrySnapshotFromConfig(liveCandidate, before.spaceId);
@@ -7360,6 +7364,7 @@ class HouseplanCard extends LitElement {
const authoredPoints = this._path.length >= 2
? this._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W])
: [];
authoredPoints.push(...additionalAuthoredPoints.map((point) => [point[0], point[1]]));
safe = wallModelOffGridValueCount(afterSpace)
<= wallModelOffGridValueCount(historyBefore, authoredPoints)
&& this._checkSpacePhysicalGeometry(committedCandidate, before.spaceId).ok;
@@ -11650,7 +11655,10 @@ class HouseplanCard extends LitElement {
sp.openings = purgeOpeningsOnSpan(sp.openings, sg, NORM_W, this._gridPitch * 6);
const removedOpenings = (sp.openings || []).length < beforeOp;
this._persistOpenCuts(next);
if (this._commitPhysicalGeometry(this._t('history.open_boundary'), before)) {
if (this._commitPhysicalGeometry(this._t('history.open_boundary'), before, [
[sg[0] / NORM_W, sg[1] / NORM_W],
[sg[2] / NORM_W, sg[3] / NORM_W],
])) {
if (removedOpenings) this._showToast(this._t('toast.openwall_openings_removed'));
this._showToast(this._t('toast.boundary_opened'));
}
@@ -11994,6 +12002,15 @@ class HouseplanCard extends LitElement {
next = setWallThickness(sp.walls, d.a, d.b, cm, this._wallKeyPitch, NORM_W);
}
next = this._normalizeWalls(next, openCuts);
// ADR 282 Stage 1: this writer knows that absence from `next` is an
// explicit zero chosen by the user, not a missing compatibility record.
// Mirror that intent into the canonical catalogue before the common
// barrier; otherwise its lineage fallback would resurrect the old cm.
for (const segment of sp.wall_segments || []) {
segment.cm = thicknessCmAt(
next, segment.a, segment.b, GRID_STEP_N, 1,
);
}
if (next.length) sp.walls = next;
else delete sp.walls;
this._wallDialog = null;
@@ -15448,8 +15465,10 @@ class HouseplanCard extends LitElement {
.map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
.filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
const windows = this._openingsR
// An independent-wall window is not an exterior sun source (#132).
.filter((o) => o.type === 'window' && !o.host)
// A contour-wall host is stable identity metadata, not a different
// physical carrier. Only an independent partition window is excluded
// from exterior sunlight (#132, ADR 282 Stage 1).
.filter((o) => o.type === 'window' && o.host?.kind !== 'partition')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((p) => p.segs);
+17
View File
@@ -84,6 +84,23 @@ test('v7 rectangle migrates atomically to the complete id catalogue and is idemp
assert.deepEqual(second.config, first.config);
});
test('an explicit canonical zero is not resurrected from the old thickness projection', () => {
const base = commitWallSegmentModel(configOf({
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
walls: [{
key: wallKey([0, 0], [1, 0], GRID_STEP_N),
a: [0, 0], b: [1, 0], cm: 20,
}],
})).config;
const edited = structuredClone(base);
const topId = edited.spaces[0].rooms[0].wall_ids[0];
edited.spaces[0].wall_segments.find((segment) => segment.id === topId).cm = 0;
delete edited.spaces[0].walls;
const result = commitWallSegmentModel(edited).config.spaces[0];
assert.equal(result.wall_segments.find((segment) => segment.id === topId).cm, 0);
assert.equal(result.walls, undefined);
});
test('partial shared side is atomised once and every atom has one or two owners', () => {
const result = commitWallSegmentModel(configOf({
id: 'floor', title: 'Floor',