The editor's grown frame stays in the editor

Audit dev@2c947f4, DEV-2C947-02 (P2). Move the only room from 0.1..0.9 to
5.1..5.9 inside the Plan editor and go back to View: the frame stayed 5880
units wide instead of the room's 880, and only a manual `_frame = null` put it
right. Anything that moves, deletes or heavily resizes geometry in an editor
left View looking at ground the plan no longer occupies — until some unrelated
model/layout/device change happened to invalidate the memo.

The growth itself is deliberate and stays (docs/CANVAS.md §4.3): inside an
editor the frame bounds pan and defines what zoom 1 means, and one that shrank
the instant a room was deleted would move the ground under a live gesture. The
bug was that the growth was invisible to the memo — `_frame`'s key carried the
space, the model, the layout, the devices and the show-far flag, but not the
mode, so the accumulated union was handed straight back in View.

`grow` (`_mode !== 'view'`) is now part of the key, and the union is only ever
taken against a frame the same editor session produced. Leaving an editor
recomputes from the content; entering one starts from the current geometry
instead of resurrecting the union of a previous session.

smoke_canvas_frame.mjs grows the auditor's scenario: the frame before, the
union inside the editor (asserted, so the growth cannot be "fixed" by deleting
it), the frame after exit — 5060..5940 — and re-entry. Two checks are red on
the parent commit.
This commit is contained in:
Matysh
2026-08-04 01:43:28 +03:00
parent f4ad843619
commit 05a2a838d6
7 changed files with 83 additions and 8 deletions
File diff suppressed because one or more lines are too long
+50
View File
@@ -4,6 +4,10 @@
// frame — hiding a marker that had wandered into the yard left the visible
// house a dot in the corner (the full card AND the static
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
//
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
// mid-drag would move the ground under the pointer); that union was memoised
// without the mode, so it survived the way back into View.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
@@ -114,5 +118,51 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// ------------------------------------------------------- 02: editor -> view
const oneRoomAt = (x0) => ({
spaces: [{
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
}],
markers: [], settings: { filter_seeded: true },
});
await load(oneRoomAt(0.1), {}, 'e1');
await raf();
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
const round = () => c._baseVb().map((n) => Math.round(n));
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
// move the only room 5 canvases to the right, inside the Plan editor
c._setMode('plan');
await c.updateComplete;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
c._serverCfg = cfg;
c._modelCache = null;
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
const inEditor = round();
// inside the editor the frame is the UNION — that is deliberate: it bounds
// pan and defines zoom 1, and it must not shrink under a live gesture
o.editorFrameOnlyGrows = inEditor[2] > 5000;
c._setMode('view');
await c.updateComplete;
const back = round();
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
// and back into an editor it starts from the CURRENT geometry, not the old union
c._setMode('plan');
await c.updateComplete;
o.reenteringTheEditorDoesNotResurrectTheUnion =
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
c._setMode('view');
await c.updateComplete;
return o;
}));
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -139,6 +139,19 @@ When `outliers > 0` the card shows an unobtrusive inline hint (no
modal) — "there are objects far from the plan" with a **Show** action
that fits `all`.
### §4.3 The frame in an editor
Inside an editor the frame only ever **grows** (`unionRect` with the previous
one): it bounds pan and defines what zoom 1 means, and a frame that shrank the
instant a room was deleted would move the ground under a live gesture.
That union belongs to the editor session and to nothing else. The memo key
carries the growth flag, so leaving for View recomputes the frame from the
content instead of inheriting the union — otherwise a room dragged five
canvases away in the Plan editor kept View framing the empty ground it had
left behind, until some unrelated model change happened to invalidate the memo
(audit DEV-2C947-02).
### §4.2 Degenerate frames
An SVG `viewBox` with a zero axis paints nothing, so a frame still has
+5
View File
@@ -998,6 +998,11 @@ require hands on real hardware — they remain for the human pass.
frame, on the full card and on `houseplan-space-card`. Untick it and the
frame takes it in again; room LQI counted it the whole time
[auto: smoke_canvas_frame]
- [ ] **The editor frame does not follow you out** (audit DEV-2C947-02): move
the only room five canvases away in the Plan editor (the frame grows
there, deliberately), close the editor — View frames the room where it
is NOW, not the union with where it was; re-entering the editor starts
from the current geometry [auto: smoke_canvas_frame]
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
order of magnitude away leaves the opening view alone and raises the
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
+12 -5
View File
@@ -1843,7 +1843,7 @@ class HouseplanCard extends LitElement {
*/
private _frame:
| { id: string; model: SpaceModel; layout: unknown; devs: unknown; far: boolean;
rect: Rect; all: Rect; outliers: number }
grow: boolean; rect: Rect; all: Rect; outliers: number }
| null = null;
/** The outlier hint's «Показать» is on: the frame takes in the far strays
* too, so zoom, pan and the fit button all agree about what "everything"
@@ -1857,21 +1857,28 @@ class HouseplanCard extends LitElement {
// content changes, so this catches a marker drag and a server push alike —
// an epoch would have to be bumped at every one of those call sites.
const f = this._frame;
// `grow` is part of the KEY, not just of the computation (DEV-2C947-02):
// the union an editor accumulated is an editor's frame, and leaving for
// View has to recompute rather than inherit it — otherwise a room moved
// far away in the Plan editor kept View framing the empty ground it left
// behind, until some unrelated model change happened to invalidate memo.
const grow = this._mode !== 'view';
if (f && f.id === m.id && f.model === m && f.layout === this._layout
&& f.devs === this._devices && f.far === this._showFar) return f;
&& f.devs === this._devices && f.far === this._showFar && f.grow === grow) return f;
const cf = contentFrame(this._contentItems(m));
let all = cf.all || spaceFrame(m);
let rect = this._showFar ? all : (cf.core || spaceFrame(m));
if (f && f.id === m.id && this._mode !== 'view') {
if (f && f.id === m.id && grow && f.grow) {
// Inside an editor the frame only GROWS: it bounds pan and defines what
// zoom 1 means, and a frame that shrank the instant a room was deleted
// would move the ground under the pointer mid-gesture.
// would move the ground under the pointer mid-gesture. Only ever unions
// with a frame the SAME editor session produced (f.grow).
rect = unionRect(f.rect, rect);
all = unionRect(f.all, all);
}
this._frame = {
id: m.id, model: m, layout: this._layout, devs: this._devices,
far: this._showFar, rect, all, outliers: cf.outliers,
far: this._showFar, grow, rect, all, outliers: cf.outliers,
};
return this._frame;
}