fix: stabilize wall keys across storage round-trips

Issue: #258
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-23 14:21:36 +03:00
parent 46a83de181
commit 28eaf86662
22 changed files with 810 additions and 420 deletions
File diff suppressed because one or more lines are too long
+25
View File
@@ -8,6 +8,9 @@ const junctionPatchFixture = JSON.parse(readFileSync(
const multiWallJunctionFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/249-multiwall-junction.json', import.meta.url), 'utf8',
));
const wallKeyRoundtripFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/258-wall-key-roundtrip.json', import.meta.url), 'utf8',
));
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -180,6 +183,20 @@ export function prepareGoldenFixture(scenario) {
},
});
}
if (scenario.wallKeyRoundtrip) {
const contract = scenario.wallKeyRoundtrip;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (contract.variant !== 'affected' || !validPoint(contract.node)
|| !validPoint(contract.incidentArm)) {
throw new Error(`invalid golden wallKeyRoundtrip: ${scenario.id}`);
}
const space = structuredClone(wallKeyRoundtripFixture.space);
space.id = scenario.space;
space.title = 'Wall key storage round-trip';
space.walls[0].key = wallKeyRoundtripFixture.affected_key;
fixture.config.spaces.push(space);
}
if (scenario.openingSymbolContract) {
const contract = scenario.openingSymbolContract;
if (!['room', 'partition'].includes(contract.kind)
@@ -545,6 +562,14 @@ export async function prepareGoldenScenario(page, scenario) {
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
}
}
if (scenario.wallKeyRoundtrip) {
const { node, incidentArm } = scenario.wallKeyRoundtrip;
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
if (!wall?.isPointInFill?.(at(node)) || !wall.isPointInFill(at(incidentArm))) {
throw new Error(`golden wall-key round-trip contract failed: ${scenario.id}`);
}
}
if (scenario.roomLabelParity) {
const labels = [...card.renderRoot.querySelectorAll('.roomlabel')];
if (labels.length !== 2
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 39;
export const GOLDEN_MATRIX_VERSION = 40;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -273,6 +273,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'wall-key-roundtrip-view-dark', fixture: 'visual',
space: 'golden-wall-key-roundtrip', mode: 'view',
wallKeyRoundtrip: {
variant: 'affected',
node: [0.8875, 0.345833333],
incidentArm: [0.8875, 0.333833333],
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
+131
View File
@@ -0,0 +1,131 @@
/** Issue #258: both persisted midpoint keys resolve one lossless T-junction. */
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/258-wall-key-roundtrip.json', import.meta.url), 'utf8',
));
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
const result = await page.evaluate(async (source) => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const update = async (structural = false) => {
if (structural) {
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
}
card.requestUpdate();
await settle();
};
for (const [variant, key] of [
['canonical', source.canonical_key],
['affected', source.affected_key],
]) {
const space = structuredClone(source.space);
space.walls[0].key = key;
const cfg = {
...structuredClone(card._serverCfg),
spaces: [space],
markers: [],
};
card._serverCfg = structuredClone(cfg);
card._layout = {};
card._space = space.id;
card._setProjection('flat');
card._setMode('plan');
card._tool = 'select';
await update(true);
const prefix = `${variant}_`;
const persisted = JSON.stringify(card._serverCfg.spaces[0]);
const path = root().querySelector('[data-hp="wall"]');
const planD = path?.getAttribute('d') || '';
const canonical = card._wallUnionGeometry();
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
const verticalArm = new DOMPoint(node.x, node.y - 12);
out[`${prefix}fixture_loaded`] = card._spaceModel()?.rooms.length === 2
&& card._spaceWalls.length === 2;
out[`${prefix}plan_uses_canonical_path`] = !!planD && canonical?.d === planD;
out[`${prefix}node_remains_filled`] = !!path?.isPointInFill(node);
out[`${prefix}incident_arm_remains_filled`] = !!path?.isPointInFill(verticalArm);
out[`${prefix}paper_remains_solid`] = !!canonical?.paperD
&& !!root().querySelector('.hp-paper');
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
out[`${prefix}light_uses_same_masonry`] = barriers.masonryGeometry.flat(2).length > 0
&& barriers.occluders.length > 0 && !!barriers.fingerprint;
card._setMode('view');
await update(false);
out[`${prefix}view_matches_plan`] = root().querySelector('[data-hp="wall"]')
?.getAttribute('d') === planD;
card._hoverRoom = { space: space.id, room: model.rooms[0] };
const hover = card._roomHoverPaths(model);
out[`${prefix}clean_floor_consumer_present`] = !!hover?.fillD && !!hover.outlineD;
card._hoverRoom = null;
const kioskBefore = card._config.kiosk;
card._config.kiosk = true;
await update(false);
out[`${prefix}kiosk_matches_plan`] = root().querySelector('[data-hp="wall"]')
?.getAttribute('d') === planD;
card._config.kiosk = kioskBefore;
await update(false);
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
const baseCall = card.hass.callWS.bind(card.hass);
staticCard.setConfig({
type: 'custom:houseplan-space-card', space: space.id, show_button: false,
});
staticCard.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
return { config: structuredClone(cfg), rev: 1 };
}
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]')
&& Date.now() - started < 6000) {
await new Promise((done) => setTimeout(done, 60));
}
await staticCard.updateComplete;
out[`${prefix}static_matches_plan`] = staticCard.renderRoot
?.querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
out[`${prefix}hidden_iso_uses_masonry`] = !!root().querySelector('[data-hp="iso-walls"]')
&& card._isoSource().build().walls.flat(2).length > 0;
out[`${prefix}render_never_writes_config`] = JSON.stringify(
card._serverCfg.spaces[0],
) === persisted;
}
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
+112 -112
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -494,7 +494,14 @@ resize axes or free rotation), but it cannot create off-grid coordinates.
Room Resize keeps its live geometry in an immutable-snapshot overlay. Modern
wall-thickness entries use exact `a/b` endpoints as identity; their quantised
midpoint/direction `key` is only a compatibility index. When a moved polygon
midpoint/direction `key` is only a compatibility index. `wallKey()` stabilises
endpoint coordinates already within storage precision of a grid node before
quantising the midpoint, so exact and nine-decimal forms of the same node have
one index. Resolution prefers exact key, then strict endpoint-pair identity,
then the legacy midpoint fallback; containment is intentionally excluded from
the identity step and remains an explicit parent-run compatibility path. This
lets every structural consumer repair an affected record on read without
mutating config, while explicit Optimize rewrites the stable index. When a moved polygon
edge overlaps only part of a longer entry, `rekeyWallsAfterMove()` partitions
the source interval at all overlap endpoints, maps only covered atoms and
retains the rest. Exact geometry plus `cm`, never key alone, defines a duplicate.
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Thick-wall T-junctions no longer develop white wedges after “Optimize
plans”. Wall identity now stays stable when an exact `1/240` grid endpoint is
persisted with nine decimal places, and already affected records are read by
their exact endpoint pair without broadening legacy midpoint matching.
Optimize canonically repairs the stored key and remains a no-op after reload
([#258](https://github.com/Matysh/houseplan-card/issues/258)).
## v1.67.0-beta.4 — 2026-08-23
- Resizing a room no longer turns a thick wall into a thin centreline when the
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- После «Оптимизировать планы» в T-образных стыках толстых стен больше не
появляются белые клинья. Идентификатор стены теперь остаётся стабильным,
когда точный узел сетки `1/240` сохраняется с девятью знаками, а уже
затронутые записи читаются по точной паре концов без расширения старого
поиска по середине. Optimize канонически исправляет сохранённый ключ и после
перезагрузки остаётся no-op
([#258](https://github.com/Matysh/houseplan-card/issues/258)).
## v1.67.0-beta.4 — 2026-08-23
- Изменение размера комнаты больше не превращает толстую стену в тонкую осевую
+11
View File
@@ -75,6 +75,17 @@ Consequently the normal commit, durable pending recovery, update-event reload
and a cold read all converge on one JSON value set; feeding any of them back to
Optimize is a no-op (#248).
Wall-thickness compatibility keys use the same boundary without depending on
which side of it produced the key (#258). A `wallKey` endpoint already within
`max(pitch · 10⁻⁶, 10⁻⁹)` of a grid node is treated as that exact node before
midpoint quantisation, so `83/240` and persisted `0.345833333` identify one
stretch. Existing entries whose old midpoint key landed one grid step away are
read immediately by strict equality of their lossless `a/b` endpoint pair;
read does not rewrite config and does not broaden parent/child matching.
Explicit Optimize rewrites the compatibility key, retains `cm`, endpoints and
unknown siblings, and its next in-memory or backend storage round-trip is a
no-op. Legacy key-only records continue through the previous midpoint fallback.
## Open-passage opening type (#157)
`space.openings[].type` additionally accepts the literal `passage`. Its
+12
View File
@@ -2664,6 +2664,18 @@ require hands on real hardware — they remain for the human pass.
compatibility-key collision cannot erase either result
[auto: smoke_wall_thickness + smoke_resize_virtual_thick +
smoke_resize_wall_thickness + test/wall-thickness.test.mjs + mutation-gate]
- [ ] **Wall key survives storage round-trip (#258)**: exact grid endpoints and
their nine-decimal stored form produce one midpoint key, including odd
and even lengths, negative/reversed coordinates and render-space scale.
Both known persisted key variants resolve the same exact span immediately
without accepting a parent, child, neighbour or parallel wall. The
affected T-node stays filled in Plan, View, kiosk, Static and hidden Iso;
clean-floor and light barriers use the same masonry. Explicit Optimize
rewrites the stable key and the next in-memory/backend echo is a no-op
[unit: test/wall-thickness.test.mjs + test/plan-optimizer.test.mjs +
test/model-invariants.test.mjs; auto: smoke_wall_key_roundtrip; golden:
wall-key-roundtrip-view-dark; mutation: wall-key-storage-normalization-disabled +
wall-exact-span-fallback-disabled + invariant-wall-key-storage-normalization-disabled].
- [ ] **Virtual T-junction**: when two real thick arms from different room
contours meet at an `open_span` endpoint, the outside corner is a clean
mitre with no stair-step. In every editor the saved dash and the two-click
+21 -14
View File
@@ -21,20 +21,27 @@ carry exact endpoints `a` / `b` in normalised plan coordinates. Config always
stores centimetres. Old `{key, cm}` data remains readable and is upgraded when
the affected boundary is edited. Open boundaries refuse thickness. One physical
stretch has one thickness (atomic collinear spans when neighbours overlap only
partially). The lookup key is computed from the
edge coordinates **as stored in the configuration**, not from a lattice-snapped
copy of them. That matters because `wallKey` quantises the midpoint with
`Math.round`: a wall whose length is an odd number of grid steps has its
midpoint exactly on a rounding tie, so the exact node `83/240` and a stored
`0.345833333` land in different buckets and the same edge acquires two possible
keys, one grid step apart. A record carrying the other one is still found — the
tolerant fallback in `lookupWall` reaches half a pitch, which is exactly that
distance — but only through the fallback, and the comparison sits on its own
boundary. Measured on two real configurations differing in exactly this way:
identical wall bodies, byte for byte (#258). `scripts/model-invariants.mjs`
(`checkWallKeys`, #259) grades it accordingly — a drift within the fallback's
reach is an observation, a key beyond it or one that does not parse as
coordinates at all is a violation, because nothing will find that record. Exact endpoints make a thickness boundary independent of whichever
partially). For valid plan geometry, **the key is computed from lattice-stable
endpoints**, never from a storage-rounded approximation of the same node.
`wallKey` first replaces only a coordinate already within
`max(pitch · 10⁻⁶, 10⁻⁹)` of its nearest node; arbitrary off-grid geometry is
not silently snapped. It then quantises the midpoint with `Math.round`. A wall
whose length is an odd number of grid steps has its midpoint exactly on a
rounding tie, and the two representations of one vertex — the exact node
`83/240` and a stored `0.345833333` — otherwise fall on opposite sides of it.
That is how #258 lost two records whose keys had drifted by one step.
Lookup order is exact key, strict same-span `a/b` (both endpoints, either
direction, within the same storage-noise epsilon), then the legacy
midpoint/direction fallback. The exact-span step repairs an already affected
plan immediately without writing it; it never treats a containing parent as
the same stretch. Parent-to-atomic inheritance remains the separate
`exactCoveringWall()` / `cmsForPoly()` contract. Explicit Optimize rewrites the
entry to the stable key and is idempotent after the nine-decimal storage
round-trip. `scripts/model-invariants.mjs` (`checkWallKeys`, #259) independently
grades a different stored key as an observation, not a violation: valid exact
endpoints now prove that the record is resolvable even when its compatibility
key is old or unparsable. Exact endpoints make a thickness boundary independent of whichever
room topology later happens to split the same straight line. Normalisation
merges consecutive solid pieces into each maximal run of equal thickness; a
different thickness or a virtual gap remains a real break. Likewise,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

+12 -12
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceFingerprint": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "abc6ca91ef76fe4892b95414c24b5d4452a403aaa42de89c7f8559cf3cf60ec6"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "367d0ff05c14f945d60ff6ab1369a8747fc72dac5f2d599266ac0a2e848587bb"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "5cf937653db3861091f6526b2bffda936d2a707bcd753cb25afa3d711b8b9e8f"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "e03b27d42dd84b203cd025eab4da5748a90d2fa312518de6c865ee02e6cdd01b"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "25b0e13e28ec406b0202440d10fb51b48ad9889269533e5789812ec0011a2da3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "4dcd28f886af304219d4ad6762c398cfdf3af85c1cb2aec31a5fa9c12d8033be"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "833a3ed64337a848110d826c45b3878bd99668ab4271018469acaefee3569571"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
},
"device-info": {
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "7cc4660bf918a33879b8b7ed32647bf075849e681d30957b918b94276cfcab64",
"imageSha256": "baa3eb88ac1a95644899c7e658dbd6319eac4997830bb943aa4b0ac15f2cb29d"
"sourceSha256": "2084fe1c162a78a5407cdabbe2d9bf3b13fed28a556c516cc3436282b8b71b51",
"imageSha256": "094aaea598ea1d8f96bcaa059e66922a94adc27340baa28c3c275cc2efa400c1"
}
}
}
+29 -63
View File
@@ -202,6 +202,13 @@ export function checkWallRecordsPreserved(before, after, { allowClear = false }
const quantise = (value, pitch) => (!(pitch > 0) || !Number.isFinite(value)
? value : Math.round(value / pitch) * pitch);
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonicalKeyCoordinate = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = quantise(value, pitch);
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
const segmentDirection = (a, b) => {
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
@@ -212,9 +219,11 @@ const segmentDirection = (a, b) => {
};
export function wallKey(a, b, pitch = GRID_STEP_N) {
const mx = quantise((a[0] + b[0]) / 2, pitch);
const my = quantise((a[1] + b[1]) / 2, pitch);
const [dx, dy] = segmentDirection(a, b);
const ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2, pitch);
const my = quantise((ca[1] + cb[1]) / 2, pitch);
const [dx, dy] = segmentDirection(ca, cb);
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
@@ -231,34 +240,17 @@ export function keyMidpoint(key) {
}
/**
* Инвариант 3: ключ записи толщины опознаёт своё же ребро (#258, #259).
* Диагностика совместимого ключа записи толщины (#258, #259).
*
* Что здесь измерено, а не предположено. Продукт ищет запись по ключу,
* посчитанному от координат ребра **как они лежат в конфигурации** —
* `keyOf(a, b, pitch, coordScale)` в `lookupWall`. Проверено исполнением на
* экспортах владельца: `wallIntervals` даёт для спорного ребра ключ запроса
* `0.887500,0.195833@1.5706`, то есть форму от сырых концов. Первая редакция
* этой проверки сверяла с концами, приведёнными к узлам решётки, и была
* неверна: на решёточную форму продукт не смотрит нигде.
*
* Отсюда две степени, а не одна. `lookupWall` после промаха по строке даёт
* терпимый запас — полшага решётки по середине при совпадении направления:
*
* - расхождение в пределах запаса — НАБЛЮДЕНИЕ. Запись находится, план
* рисуется верно. Проверено: два конфига владельца, различающиеся ровно
* такими ключами, дают побайтово одинаковые тела стен. Объявить это
* нарушением значит покрасить здоровый план — а проверка с ложными
* срабатываниями отключается первой;
* - расхождение больше запаса либо ключ, который вообще не разбирается как
* координаты, — НАРУШЕНИЕ. Такую запись не находит ни точное совпадение,
* ни запас. Ровно это происходит с `demo/fixtures/large-house.mjs` (#260):
* метки вида `perf-wall-0-3` не разбираются, и все 80 сплошных рёбер
* фикстуры остаются с нулевой толщиной — 0 тел стен на плане, который
* служит перф-бюджетом.
* После #258 точная пара `a/b` является строгой идентичностью того же span и
* разрешается до legacy midpoint fallback. Поэтому любой отличный или даже
* неразбираемый compatibility key у записи с валидными endpoints — наблюдение,
* а не нарушение: runtime найдёт запись по endpoints, а явный Optimize
* перепишет стабильный key. Legacy key-only запись проверить и исправить по
* догадке нельзя, поэтому она по-прежнему пропускается.
*/
export function checkWallKeys(config, { notes = [] } = {}) {
const violations = [];
const reach = GRID_STEP_N * 0.5;
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
for (const wall of space?.walls || []) {
@@ -271,42 +263,16 @@ export function checkWallKeys(config, { notes = [] } = {}) {
if (wall.key === expected) continue;
const owner = `${spaceId}:${wall.key}`;
const stored = keyMidpoint(wall.key);
if (!stored) {
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
reference: expected,
detail: `ключ записи ${wall?.cm} см не разбирается как координаты —`
+ ' её не найдёт ни точное совпадение, ни терпимый запас' });
continue;
}
const drift = Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2);
// Сравнение с запасом обязано иметь собственный допуск. Сдвиг ключа на
// один шаг решётки даёт середину ровно на полшага, то есть ровно на
// границе запаса — и исход у продукта решает шум в последних битах.
// Измерено: продукт такую запись НАХОДИТ (тела стен двух конфигов
// владельца, различающихся именно этим, побайтово одинаковы). Приговор,
// который решает шум, — не приговор.
// Допуск задан в шагах, а не в долях от `reach`: сдвиг на один шаг даёт
// ровно 0.5, и разные представления одной вершины (точный узел против
// девяти знаков в конфигурации) двигают эту величину на ~5e-5 шага. С
// относительным допуском 1e-6 четыре одинаковых записи одного плана
// делились на «нарушение» и «наблюдение» по последним битам — проверка
// повторяла ту самую болезнь, которую должна показывать. Ближайший
// настоящий класс — 1.5 шага, до него 200%, так что 1e-3 ничего не прячет.
const steps = drift / GRID_STEP_N;
if (steps > 0.5 + 1e-3) {
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
reference: expected,
detail: `середина ключа ушла на ${steps.toFixed(2)} шага —`
+ ' дальше терпимого запаса в полшага, запись не найдётся' });
continue;
}
const edge = Math.abs(steps - 0.5) <= 1e-3
? ' и ровно на его границе: у продукта исход решает шум в последних битах'
: '';
const drift = stored
? Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2)
/ GRID_STEP_N
: null;
const driftText = drift === null ? 'ключ не разбирается как координаты'
: `середина отличается на ${drift.toFixed(2)} шага`;
notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,
reference: expected,
detail: `ключ не равен ключу своего ребра, расхождение ${steps.toFixed(2)}`
+ ` шага — запись находится только через терпимый запас${edge}` });
detail: `${driftText}; запись находится по точной паре endpoints,`
+ ' явный Optimize перепишет совместимый ключ' });
}
}
return violations;
@@ -326,7 +292,7 @@ function noteSummary(notes) {
for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1);
const titles = {
unknown_owner: 'позиции без записи маркера',
stale_wall_key: 'записей толщины находятся только через терпимый запас',
stale_wall_key: 'записей толщины используют exact endpoints вместо своего ключа',
};
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
}
@@ -336,7 +302,7 @@ function report(violations, notes = []) {
const tail = notes.length
? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes)
: '';
return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся по ключу.'
return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся.'
+ tail;
}
const lines = [`Нарушений: ${violations.length}.`, ''];
+50 -24
View File
@@ -205,6 +205,45 @@ export const MUTANTS = [
+ ' candidate.entry.key === keyOf(ca, cb, pitch, scale));',
}],
},
{
id: 'wall-key-storage-normalization-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 258 wallKey" '
+ 'test/wall-thickness.test.mjs',
because: 'one grid endpoint has exact and nine-decimal storage forms; keying their midpoint '
+ 'without first stabilising the endpoints recreates the one-grid-step key fork from #258',
patches: [{
file: 'src/wall-thickness.ts',
find: ' return Math.abs(snapped - v) <= keyEpsilon(pitch) ? snapped : v;',
replace: ' return v;',
}],
},
{
id: 'wall-exact-span-fallback-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 258 exact-span" '
+ 'test/wall-thickness.test.mjs',
because: 'already affected plans must resolve the same physical a/b span immediately even '
+ 'when their persisted midpoint key is on the other side of the rounding tie (#258)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const exactEps = keyEpsilon(pitch) * scale;',
replace: ' const exactEps = -1;',
}],
},
{
id: 'invariant-wall-key-storage-normalization-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="копия wallKey" '
+ 'test/model-invariants.test.mjs',
because: 'the raw-model invariant keeps a deliberate copy of production wallKey; if its '
+ 'storage-noise normalisation drifts, the diagnostic and runtime disagree again (#258)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;',
replace: ' return value;',
}],
},
{
id: 'unit-formatting-escapes-the-formatter',
guard: 'node --test --test-name-pattern="детектор действительно ловит" '
@@ -232,39 +271,26 @@ export const MUTANTS = [
},
{
id: 'invariant-keys-cry-wolf',
guard: 'node --test --test-name-pattern="ушедший на шаг решётки" '
guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" '
+ 'test/model-invariants.test.mjs',
because: 'запись, ушедшую на полшага, продукт НАХОДИТ — измерено на двух конфигах '
+ 'владельца с побайтово одинаковыми телами стен; объявить это нарушением значит '
+ 'покрасить исправный план, а такую проверку отключают первой (#258)',
because: 'valid exact endpoints prove the same physical span before every legacy key '
+ 'fallback; calling a stale or unparsable index a violation would cry wolf (#258)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (steps > 0.5 + 1e-3) {',
replace: ' if (steps > 0) {',
find: " notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,",
replace: " violations.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,",
}],
},
{
id: 'invariant-keys-tolerate-any-drift',
guard: 'node --test --test-name-pattern="дальше терпимого запаса" '
id: 'invariant-keys-hide-stale-observation',
guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" '
+ 'test/model-invariants.test.mjs',
because: 'ключ, ушедший дальше полшага, не находит ни точное совпадение, ни запас — '
+ 'если проверка это пропустит, запись толщины молча перестанет существовать',
because: 'a mismatched compatibility key is repairable data debt; hiding the observation '
+ 'would make Optimize repair invisible and let the two representations accumulate (#258)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (steps > 0.5 + 1e-3) {',
replace: ' if (false) {',
}],
},
{
id: 'invariant-keys-accept-a-label',
guard: 'node --test --test-name-pattern="не разбирается как координаты" '
+ 'test/model-invariants.test.mjs',
because: 'метка вместо ключа (perf-wall-0-3 в перф-фикстуре, #260) оставляет все 80 '
+ 'сплошных рёбер без толщины: проверка обязана отличать её от сдвига на полшага',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (!stored) {',
replace: ' if (false) {',
find: ' if (wall.key === expected) continue;',
replace: ' continue;',
}],
},
{
+8
View File
@@ -27,6 +27,14 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
// #258: the browser sees only the resulting path/caches; it cannot call
// the pure identity helpers by name through the production bundle.
symbols: ['wallKey', 'lookupWall', 'canonicalKeyCoordinate', 'keyEpsilon'],
smokes: ['smoke_wall_key_roundtrip.mjs'],
because: 'the affected and canonical midpoint keys must produce the same T-junction in '
+ 'Plan, View, kiosk, Static, hidden Iso, clean-floor and light-barrier consumers',
},
{
// #253: the pure interval transformer is invoked inside the resize preview;
// the smoke can observe only the resulting wall records and rendered body.
+36 -4
View File
@@ -149,6 +149,22 @@ function q(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch;
}
/** Storage-noise tolerance used only to stabilise wall identity near a node. */
function keyEpsilon(pitch: number): number {
return Math.max(Math.abs(pitch) * 1e-6, 1e-9);
}
/**
* Treat a coordinate already within storage precision of a grid node as that
* exact node. Arbitrary off-grid geometry remains off-grid: this is identity
* canonicalisation, not an implicit geometry snap.
*/
function canonicalKeyCoordinate(v: number, pitch: number): number {
if (!(pitch > 0) || !Number.isFinite(v)) return v;
const snapped = q(v, pitch);
return Math.abs(snapped - v) <= keyEpsilon(pitch) ? snapped : v;
}
/**
* Direction of a wall, modulo 180° (a wall is the same from either end),
* as a unit vector with a stable sign (prefer +x, then +y).
@@ -169,9 +185,11 @@ export function wallDir(a: number[], b: number[]): [number, number] {
* survives whole-grid moves when re-keyed by the resize commit.
*/
export function wallKey(a: number[], b: number[], pitch: number): string {
const mx = q((a[0] + b[0]) / 2, pitch);
const my = q((a[1] + b[1]) / 2, pitch);
const [dx, dy] = wallDir(a, b);
const ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
const mx = q((ca[0] + cb[0]) / 2, pitch);
const my = q((ca[1] + cb[1]) / 2, pitch);
const [dx, dy] = wallDir(ca, cb);
// angle bucket: round to ~0.1° so float noise does not fork keys
let ang = Math.atan2(dy, dx);
if (ang < 0) ang += Math.PI;
@@ -266,8 +284,22 @@ export function lookupWall(
const want = keyOf(a, b, pitch, coordScale);
const hit = walls.find((w) => w.key === want);
if (hit) return hit;
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
// A lossless entry can prove that it names this exact physical stretch even
// when an older midpoint key landed on the other side of a rounding tie.
// This is deliberately same-span only: parent containment remains the
// separate exactCoveringWall/cmsForPoly compatibility contract.
const scale = coordScale > 0 ? coordScale : 1;
const exactEps = keyEpsilon(pitch) * scale;
const closePoint = (x: number[], y: number[]): boolean => (
Math.abs(x[0] - y[0]) <= exactEps && Math.abs(x[1] - y[1]) <= exactEps
);
for (const wall of walls) {
const span = entrySpan(wall, scale);
if (!span) continue;
if ((closePoint(span[0], a) && closePoint(span[1], b))
|| (closePoint(span[0], b) && closePoint(span[1], a))) return wall;
}
// tolerant fallback: same direction bucket, midpoint within half pitch (norm)
const mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2;
const ang = segAngle(a, b);
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
+50
View File
@@ -0,0 +1,50 @@
{
"node": [0.8875, 0.345833333],
"canonical_key": "0.887500,0.200000@1.5706",
"affected_key": "0.887500,0.195833@1.5706",
"space": {
"id": "wall-key-roundtrip",
"title": "Wall key round-trip",
"view_box": [0.72, 0.02, 0.27, 0.51],
"cell_cm": 5,
"settings": {
"fill_mode": "none",
"show_borders": true,
"show_names": false
},
"rooms": [
{
"id": "lower",
"poly": [
[0.75, 0.05],
[0.8875, 0.05],
[0.8875, 0.345833333],
[0.75, 0.345833333]
]
},
{
"id": "upper",
"poly": [
[0.75, 0.345833333],
[0.95, 0.345833333],
[0.95, 0.5],
[0.75, 0.5]
]
}
],
"walls": [
{
"key": "0.887500,0.200000@1.5706",
"cm": 29,
"a": [0.8875, 0.05],
"b": [0.8875, 0.345833333]
},
{
"key": "0.850000,0.345833@0.0000",
"cm": 20,
"a": [0.75, 0.345833333],
"b": [0.95, 0.345833333]
}
]
}
}
+18 -1
View File
@@ -143,6 +143,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
'wall-key-roundtrip',
'washer-active-cycle', 'washer-idle-cycle', 'space-tab-drop-before',
'space-tab-drop-after', 'decor-over-opaque-hover',
'decor-over-glow-base'])
@@ -321,7 +322,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 39);
assert.equal(GOLDEN_MATRIX_VERSION, 40);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -401,6 +402,22 @@ test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
assert.equal(space.settings.show_borders, true);
});
test('issue #258 golden renders the affected persisted key at its T-junction', () => {
const scenario = GOLDEN_SCENARIOS.find(
(item) => item.id === 'wall-key-roundtrip-view-dark',
);
assert.ok(scenario);
assert.equal(scenario.mode, 'view');
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.wallKeyRoundtrip.variant, 'affected');
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.equal(space.walls[0].key, '0.887500,0.195833@1.5706');
assert.deepEqual(scenario.wallKeyRoundtrip.node, [0.8875, 0.345833333]);
assert.equal(space.settings.show_borders, true);
});
test('the open color picker golden covers dark mobile and light desktop themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.dialog === 'decor-color');
assert.deepEqual(scenarios.map(({ id, language, theme, viewport }) => ({
+21 -76
View File
@@ -110,24 +110,6 @@ const demoStandModel = () => {
return { config: block('CFG'), layout: block('LAYOUT') };
};
/**
* Признанный долг по контракту ключей (#260), а не список исключений.
*
* `large-house` пишет метки вида `perf-wall-0-3`. Они не разбираются как
* координаты, поэтому их не находит ни точное совпадение, ни терпимый запас:
* измерено продуктовым `wallIntervals` — все 80 сплошных рёбер фикстуры
* остаются с нулевой толщиной и тел стен не возникает вовсе. Это вход и
* golden (4 сцены), и всех шести перф-бюджетов, поэтому правка требует
* переприёмки эталонов — решение владельца, а не правка по ходу.
*
* `visual-matrix` в этот список не входит: её ключи с четырьмя знаками вместо
* шести расходятся в пределах запаса, продукт их находит, и проверка выдаёт по
* ним наблюдения, а не нарушения.
*/
const KEY_CONTRACT_DEBT = new Map([
['large-house.mjs:makeLargeHouseFixture', 147],
]);
test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => {
const models = [];
for (const file of fixtureModules()) {
@@ -146,19 +128,11 @@ test('все модели, которые возит с собой проект,
assert.deepEqual(found.map((v) => `${v.kind}:${v.owner} → ${v.reference}`), [],
`${label}: модель, нарушающая инварианты, обесценивает и golden, и смоки на ней`);
// Ключи (#259) проверяются с одним признанным долгом: две фикстуры пишут
// ключ не по контракту (#260). Долг записан числом, а не исключением по
// имени: вырастет — тест покраснеет, починят — тоже покраснеет и потребует
// убрать запись. Молчаливого исключения здесь быть не должно, иначе
// проверка тихо перестанет что-либо значить.
const debt = KEY_CONTRACT_DEBT.get(label) ?? 0;
const keys = checkWallKeys(fixture.config, { notes: [] });
assert.equal(keys.length, debt, debt
? `${label}: признанный долг #260 — ожидалось ${debt} записей с ключом не по`
+ ` контракту, найдено ${keys.length}. Починили — уберите запись из`
+ ' KEY_CONTRACT_DEBT; стало больше — фикстура добавила новые.'
: `${label}: ключ записи толщины не равен ключу решёточного ребра —`
+ ' при отрисовке такая запись не находится (#258)');
// Compatibility-key debt remains visible as notes, while exact `a/b`
// proves every shipped record resolvable before midpoint parsing.
const notes = [];
const keys = checkWallKeys(fixture.config, { notes });
assert.deepEqual(keys, [], `${label}: exact wall entry declared unresolvable`);
}
});
@@ -221,66 +195,38 @@ test('readModel понимает экспорт, ответ config/get и сыр
});
// --------------------------- инвариант 3: ключи ------------------------------
// #258/#259. Числа ниже не придуманы: это записи из экспортов владельца.
//
// Главное, что здесь закреплено, — ГРАДАЦИЯ. Первая редакция проверки считала
// сдвиг ключа на шаг решётки нарушением и сверяла ключ с концами, приведёнными
// к узлам. И то и другое неверно: продукт ключует от координат как они лежат в
// конфигурации (проверено `wallIntervals`: для спорного ребра ключ запроса
// `0.887500,0.195833@1.5706`), а запись, ушедшую на полшага, он НАХОДИТ —
// два конфига владельца, различающиеся ровно такими ключами, дают побайтово
// одинаковые тела стен.
// #258/#259. После строгого same-span resolver валидные exact endpoints
// доказывают, что запись найдётся независимо от старого compatibility key.
// Несовпадение остаётся наблюдением для явного Optimize, но не нарушением.
const KEYED = (key, cm, a, b) => ({
spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }],
});
test('#258: ключ, ушедший на шаг решётки, — наблюдение, а не нарушение', () => {
test('#258: старый и неразбираемый compatibility key — наблюдение по exact endpoints', () => {
for (const [key, cm, a, b] of [
['0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]],
['0.979167,0.450000@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]],
['0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]],
['0.979167,0.445833@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]],
['perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]],
['0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]],
]) {
const notes = [];
assert.deepEqual(checkWallKeys(KEYED(key, cm, a, b), { notes }), [],
'проверка, красящая план, который рисуется верно, отключается первой');
'exact endpoints make the record resolvable before legacy key fallback');
assert.equal(notes.length, 1);
assert.equal(notes[0].kind, 'stale_wall_key');
// Граница названа прямо: попадание у продукта решает шум в последних битах.
assert.match(notes[0].detail, /ровно на его границе/);
assert.match(notes[0].detail, /точной паре endpoints/);
}
});
test('#258: ключ, равный ключу своего ребра, не даёт даже наблюдения', () => {
const notes = [];
assert.deepEqual(checkWallKeys(KEYED(
'0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333],
'0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333],
), { notes }), []);
assert.deepEqual(notes, []);
});
test('#260: ключ, который не разбирается как координаты, — нарушение', () => {
const notes = [];
const found = checkWallKeys(
KEYED('perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]), { notes },
);
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'wall_key');
assert.equal(found[0].reference, '0.300000,0.100000@0.0000');
assert.match(found[0].detail, /не разбирается как координаты/);
assert.deepEqual(notes, [], 'нарушение не дублируется наблюдением');
});
test('ключ, ушедший дальше терпимого запаса, — нарушение', () => {
// Два шага вместо полшага: столько запас не покрывает ни при каком округлении.
const notes = [];
const found = checkWallKeys(
KEYED('0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]), { notes },
);
assert.equal(found.length, 1);
assert.match(found[0].detail, /2\.00 шага/);
assert.deepEqual(notes, []);
});
test('запись без концов ключевую проверку не роняет', () => {
// Совместимость: у старых записей есть только ключ. Сверять не с чем, и это
// не повод объявлять их сломанными.
@@ -338,11 +284,10 @@ test('копия wallKey в скрипте совпадает с продукт
}
});
test('градация по шагам не решается последними битами (#259)', () => {
// Четыре записи второго пространства владельца ушли на одни и те же полшага.
// С относительным допуском 1e-6 две из них становились нарушением, две —
// наблюдением: проверка повторяла ту самую ничью округления, которую должна
// показывать. Все четыре обязаны попасть в один класс.
test('near-grid key normalization removes last-bit grading (#258, #259)', () => {
// Four records from the owner's second space differ only in nine-decimal
// endpoint representation. Stable wallKey makes all four canonical directly;
// neither a violation nor a stale-key observation is left to grade by bits.
const walls = [
['-0.637500,-0.208333@0.0000', 30, [-1.670833333, -0.208333333], [0.4, -0.208333333]],
['-0.637500,1.266667@0.0000', 20, [0.4, 1.266666667], [-1.670833333, 1.266666667]],
@@ -351,5 +296,5 @@ test('градация по шагам не решается последним
].map(([key, cm, a, b]) => ({ key, cm, a, b }));
const notes = [];
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []);
assert.equal(notes.length, 4, 'все четыре — один класс, а не два');
assert.deepEqual(notes, []);
});
+34
View File
@@ -30,6 +30,10 @@ const storageRoundtripFixture = JSON.parse(readFileSync(
new URL('./fixtures/optimize-storage-roundtrip.json', import.meta.url),
'utf8',
));
const wallKeyRoundtripFixture = JSON.parse(readFileSync(
new URL('./fixtures/258-wall-key-roundtrip.json', import.meta.url),
'utf8',
));
const assertNoPersistedChanges = (result) => {
assert.equal(result.changed, false);
@@ -164,6 +168,36 @@ test('issue 248 Optimize stays a no-op across the nine-decimal storage round-tri
assert.deepEqual(backendEcho.layout, first.layout);
});
test('issue 258 Optimize canonicalizes an affected wall key across storage round-trip', () => {
const space = structuredClone(wallKeyRoundtripFixture.space);
space.walls[0].key = wallKeyRoundtripFixture.affected_key;
const config = {
model_version: PLAN_MODEL_VERSION,
spaces: [space],
markers: [], settings: {},
};
const before = structuredClone(config);
const first = optimizePlans(config, {});
assert.deepEqual(config, before, 'preview must not mutate the affected source');
assert.equal(first.changed, true);
assert.equal(first.report.canonicalized, 1);
const repaired = first.config.spaces[0].walls.find((wall) => wall.cm === 29);
assert.ok(repaired);
assert.equal(repaired.cm, 29);
assert.deepEqual(repaired.a, wallKeyRoundtripFixture.space.walls[0].a);
assert.deepEqual(repaired.b, wallKeyRoundtripFixture.space.walls[0].b);
assert.equal(repaired.key, wallKeyRoundtripFixture.canonical_key);
assert.equal(repaired.key, wallKey(repaired.a, repaired.b, S));
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);
const inMemorySecond = optimizePlans(first.config, first.layout);
assertNoPersistedChanges(inMemorySecond);
assert.deepEqual(inMemorySecond.config, first.config);
const backendEcho = optimizePlans(canonicalizeConfigGeometry(first.config), first.layout);
assertNoPersistedChanges(backendEcho);
assert.deepEqual(backendEcho.config, first.config);
});
test('issue 248 every persisted geometry surface converges at every supported scale', () => {
for (const cellCm of [1, 3, 5, 1000]) {
const config = structuredClone(coordinateFixture.configInput);
+96
View File
@@ -170,6 +170,102 @@ test('wallKey changes when the wall moves by one grid step', () => {
assert.notEqual(wallKey(a, b, pitch), wallKey(a2, b2, pitch));
});
test('issue 258 wallKey survives the nine-decimal storage round-trip', () => {
const cases = [
[[0.8875, 0.05], [0.8875, 83 / 240], [0.8875, 0.05], [0.8875, 0.345833333]],
[[235 / 240, 83 / 240], [235 / 240, 0.55], [0.979166667, 0.345833333], [0.979166667, 0.55]],
[[-83 / 240, -12 / 240], [-83 / 240, -47 / 240], [-0.345833333, -0.05], [-0.345833333, -0.195833333]],
[[12 / 240, 7 / 240], [48 / 240, 7 / 240], [0.05, 0.029166667], [0.2, 0.029166667]],
];
for (const [exactA, exactB, storedA, storedB] of cases) {
const exact = wallKey(exactA, exactB, pitch);
assert.equal(wallKey(storedA, storedB, pitch), exact);
assert.equal(wallKey(storedB, storedA, pitch), exact);
}
assert.notEqual(wallKey([0, 0], [pitch - pitch * 2e-6, 0], pitch),
wallKey([0, 0], [pitch, 0], pitch), 'coordinates beyond key epsilon are not snapped');
});
test('issue 258 exact-span lookup repairs either persisted key without leaking', () => {
const a = [0.8875, 0.05], b = [0.8875, 0.345833333];
const exactA = [213 / 240, 12 / 240], exactB = [213 / 240, 83 / 240];
const canonical = wallKey(exactA, exactB, pitch);
const affected = canonical.replace(',0.200000@', ',0.195833@');
for (const key of [canonical, affected]) {
const walls = [{ key, cm: 29, a, b }];
assert.equal(lookupWall(walls, exactA, exactB, pitch)?.cm, 29);
assert.equal(lookupWall(walls, exactB, exactA, pitch)?.cm, 29);
assert.equal(thicknessCmAt(walls, exactA, exactB, pitch), 29);
}
const unrelated = [
{ key: 'broken-parent', cm: 31, a: [0.8875, 0], b: [0.8875, 0.4] },
{ key: 'broken-child', cm: 32, a: [0.8875, 0.05], b: [0.8875, 0.2] },
{ key: 'broken-neighbour', cm: 33, a: [0.8875 + pitch, 0.05], b: [0.8875 + pitch, 0.345833333] },
{ key: 'broken-parallel', cm: 34, a: [0.05, 0.8875], b: [0.345833333, 0.8875] },
];
assert.equal(lookupWall(unrelated, exactA, exactB, pitch), null);
const scale = 1000;
const renderA = exactA.map((v) => v * scale), renderB = exactB.map((v) => v * scale);
const renderWalls = [{ key: affected, cm: 29, a, b }];
assert.equal(lookupWall(renderWalls, renderA, renderB, pitch, scale)?.cm, 29);
assert.equal(thicknessCmAt(renderWalls, renderA, renderB, pitch, scale), 29);
});
test('issue 258 repaired span reaches intervals, junction nodes and masonry', () => {
const scale = NORM_W;
const node = [213 / 240 * scale, 83 / 240 * scale];
const rooms = [
{ id: 'lower', poly: [
[0.75 * scale, 12 / 240 * scale],
[213 / 240 * scale, 12 / 240 * scale],
node,
[0.75 * scale, 83 / 240 * scale],
] },
{ id: 'upper', poly: [
[0.75 * scale, 83 / 240 * scale],
[0.95 * scale, 83 / 240 * scale],
[0.95 * scale, 0.5 * scale],
[0.75 * scale, 0.5 * scale],
] },
];
let walls = setWallThicknessForRoom([], rooms, 'lower', 20, pitch, [], scale);
walls = setWallThicknessForRoom(walls, rooms, 'upper', 20, pitch, [], scale);
const a = [0.8875, 0.05], b = [0.8875, 0.345833333];
const target = walls.findIndex((wall) => (
wall.a && wall.b
&& Math.abs(wall.a[0] - a[0]) < 1e-9 && Math.abs(wall.b[0] - b[0]) < 1e-9
));
assert.ok(target >= 0, 'fixture must contain the affected vertical wall');
walls[target] = { key: '0.887500,0.195833@1.5706', cm: 29, a, b };
const intervals = wallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH, scale);
const affected = intervals.find((interval) => (
Math.abs(interval.a[0] - node[0]) < 1e-6
&& Math.abs(interval.b[0] - node[0]) < 1e-6
&& Math.min(interval.a[1], interval.b[1]) < node[1] - 1
&& Math.max(interval.a[1], interval.b[1]) >= node[1] - 1e-6
));
assert.equal(affected?.cm, 29);
const nodes = buildMultiWallNodeMap(intervals, pitch * scale * 0.04 * 4, scale);
const junction = nodes.nodes.find((candidate) => (
Math.hypot(candidate.point[0] - node[0], candidate.point[1] - node[1]) < 1e-6
));
assert.ok(junction && junction.rays.length >= 3);
assert.ok(junction.rays.some((ray) => (
Math.abs(ray.halfDepth - wallCmToUnits(29, cellCm, GRID_PITCH) / 2) < 1e-9
&& Math.abs(ray.u[0]) < 1e-9 && ray.u[1] < -0.999
)), `multi-wall node lost the affected incident wall: ${JSON.stringify(junction.rays)}`);
const geometry = wallBodiesGeometry(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH, scale,
);
assert.ok(geometry);
assertProbeInside(geometry.geom, node, 'the affected T-node contains a white wedge');
});
test('lookupWall finds an entry and thicknessCmAt reads it', () => {
const a = [0.1, 0.2], b = [0.4, 0.2];
const walls = [{ key: wallKey(a, b, pitch), cm: 20 }];