t.depth-e.depth||t.polygon-e.polygon||t.ring-e.ring||t.edge-e.edge),{topPath:s.join(" "),sides:n,contactPath:o.join(" "),edgeCount:a}}(s.walls),r={geometry:a,floor:Fp(s.floor,10),openings:s.openings,frame:o};return s_(this._isoGeometryCache,t.key,r,8),{key:t.key,...r}}_effectiveProjection(){if("iso"!==this._desiredProjection||!this._model.length)return"flat";const t=this._isoSceneKey()||`${this._space}|invalid`;if(this._isoFallback.has(t))return"flat";try{const t=this._isoScene();return t?(e="iso",i=t.key,s=this._isoFallback,"iso"===e&&i&&!s.has(i)?"iso":"flat"):"flat"}catch(e){if(!this._isoFallback.has(t)){this._isoFallback.add(t);const i=t.split("|");console.warn(`HOUSEPLAN ISO FALLBACK: #89, space ${this._space}, fingerprint ${i[i.length-1]}, ${e instanceof Error?e.message:"renderer error"}`)}return"flat"}var e,i,s}_scenePoint(t){return"iso"===this._renderProjection?Mp(t,0):t}_floorView(t){if("iso"!==this._renderProjection)return t;const e=Cp([t.x,t.y]),i=Cp([t.x+t.w,t.y+t.h]);return{x:e[0],y:e[1],w:i[0]-e[0],h:i[1]-e[1]}}_baseVb(){if("iso"===this._effectiveProjection()){if(!this._spaceDisplayForRender().showBorders){const t=Tp({rect:this._frameOf().rect,wallHeight:xp});return[t.x,t.y,t.w,t.h]}const t=this._isoScene()?.frame??this._frameOf().rect;return[t.x,t.y,t.w,t.h]}const t=this._frameOf().rect;return[t.x,t.y,t.w,t.h]}get _outliers(){return this._showFar?0:this._frameOf().outliers}_fitFar(){this._showFar=!0,this._frame=null,this._resetZoom()}_fitAll(){this._showFar=!0,this._frame=null,this._resetZoom()}_renderFarHint(){return this._kiosk||"view"!==this._mode||this._booting||!this._outliers?G:W`
${this._t("canvas.far_objects",{n:this._outliers})}
diff --git a/docs/TESTING.md b/docs/TESTING.md
index f93415cf..3db465b9 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -38,7 +38,7 @@
model returns `undefined`; exact lookup of a stale saved id never falls
back to another space [unit: `space-model-selection.test.mjs`].
- [ ] There are no unguarded `_spaceModel().…` dereferences, explicit-id calls
- use `_spaceModelById()`, and marker persistence validates its target
+ use `_spaceModelById()`, and marker/position persistence validates its target
before config/file/WS side effects
[unit: `optional-space-model-contract.test.mjs`].
- [ ] Delete the last space while an editor gesture and debounced write are
diff --git a/docs/images/06-device-editor.png b/docs/images/06-device-editor.png
index 5910d291..fec6ef66 100644
Binary files a/docs/images/06-device-editor.png and b/docs/images/06-device-editor.png differ
diff --git a/docs/images/screenshots.json b/docs/images/screenshots.json
index 6cbd4ab3..9effdd8d 100644
--- a/docs/images/screenshots.json
+++ b/docs/images/screenshots.json
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
- "sourceFingerprint": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceFingerprint": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "5ca7f24642926072ccbc5bf48341c5310329918631b147c116f9c29e20e4927e"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "164b79fd9075a1425cffe3c85c88b689dfda90b79856bb4ed6655507b166b3e0"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
- "imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
+ "imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
- "sourceSha256": "11932bbb848bf3cda4f3e4f9f898c1817e3e51d6e009e25df073385675841a28",
+ "sourceSha256": "b324ee6d7896c4c2aaef51f5681b4818a6ffae5d779bf9d38faaf85a0d998903",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index 8fa4edfd..61fb30ca 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -40,6 +40,18 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
+ {
+ id: 'stale-space-position-guard-removed',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="stable space ids" test/optional-space-model-contract.test.mjs',
+ because: 'position persistence with a stable space id must fail closed before layout, dirty-set '
+ + 'and websocket side effects when that space has been deleted or renamed',
+ patches: [{
+ file: 'src/houseplan-card.ts',
+ find: ' if (!this._spaceModelById(d.space)) return;\n if (this._norm) {',
+ replace: ' if (this._norm) {',
+ }],
+ },
{
id: 'empty-space-cleanup-disabled',
guard: 'node demo/smoke_optional_space_model.mjs',
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index f620126f..e7d5ddc9 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -4328,6 +4328,7 @@ class HouseplanCard extends LitElement {
}
private _savePos(d: DevItem, x: number, y: number): void {
+ if (!this._spaceModelById(d.space)) return;
if (this._norm) {
// The icon center snaps to the nodes of the same grid as the room markup
// (docs/CANVAS.md §9). UX-05 has no free-position escape hatch.
diff --git a/test/optional-space-model-contract.test.mjs b/test/optional-space-model-contract.test.mjs
index 672397fa..5fd42b10 100644
--- a/test/optional-space-model-contract.test.mjs
+++ b/test/optional-space-model-contract.test.mjs
@@ -51,6 +51,15 @@ test('stable space ids use exact lookup and abort before side effects', () => {
const configMutationAt = saveMarker.indexOf('cfg.markers = markers');
assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt
&& busyAt < migrateAt && migrateAt < configMutationAt);
+
+ const savePos = methodBody('_savePos');
+ const positionExactAt = savePos.indexOf('_spaceModelById(d.space)');
+ const layoutMutationAt = savePos.indexOf('this._layout =');
+ const dirtyAt = savePos.indexOf('_dirtyPos.add');
+ const persistAt = savePos.indexOf('_persistLayout()');
+ assert.ok(positionExactAt >= 0 && positionExactAt < layoutMutationAt
+ && layoutMutationAt < dirtyAt && dirtyAt < persistAt,
+ 'stale position writes abort before layout, dirty and persistence side effects');
});
test('empty render keeps create/import affordances without spatial layers', () => {