mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: stabilize wall keys across storage round-trips
Issue: #258 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
Vendored
+112
-112
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
- Изменение размера комнаты больше не превращает толстую стену в тонкую осевую
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
Reference in New Issue
Block a user