Icons are measured by the same plan the frame is

Audit dev@2c947f4, DEV-2C947-03 (P2). Three rooms in the core plus one dragged
90 canvases out: the frame rejected the stray exactly as §4.1 promises, and
then a perfectly ordinary marker on the main plan came out 90.89x too big and
covered the house. `contentFrame` voted; `iconUnit` did not — it took
`boxOf(every room)`, so the distance to the stray the frame had just thrown
away lived on in the numerator of `iconCqw`.

`iconUnit` now takes `contentFrame(roomItems, { pad: 0 }).core`: the same
main-mass vote, over the same rooms it always used (rooms only is what keeps
the full card and the static card bit-identical), with no padding, because
this is a UNIT and not a viewport. Below MIN_VOTERS nothing is declared an
outlier, so every ordinary plan — and every genuinely wide one, where the
majority veto applies — keeps exactly the unit it had. `defaultPositions`
takes its declump distance from the same call, so the auto-placement spacing
follows without a second rule.

Unit (test/canvas.test.mjs): a far room leaves both the frame and the icon
unit alone, `iconCqw` on the strayed plan equals `iconCqw` on the same plan
without the stray, and a plan that is honestly two canvases wide still scales.
smoke_canvas_frame.mjs measures the rendered badge in px with and without the
far room. Both are red on the parent commit.
This commit is contained in:
Matysh
2026-08-04 01:44:08 +03:00
parent 05a2a838d6
commit ade8daab16
8 changed files with 106 additions and 6 deletions
File diff suppressed because one or more lines are too long
+44
View File
@@ -8,6 +8,10 @@
// 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.
//
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
// measured ALL rooms — so the stray it had just rejected still blew every
// marker of the main plan up ~91x.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
@@ -164,5 +168,45 @@ Object.assign(out, await page.evaluate(async () => {
return o;
}));
// ---------------------------------------------------- 03: icons and outliers
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
// the stray (that part always worked); the ICON UNIT used to measure all four
// rooms, so every marker of the plan the user is looking at came out ~91x too
// big and covered the house.
const withFarRoom = (far) => ({
spaces: [{
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
rooms: [
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
],
}],
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
settings: { filter_seeded: true },
});
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
const iconPx = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const el = sr.querySelector('.devlayer .dev');
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
});
await load(withFarRoom(false), ICON_POS, 'i1');
await raf();
const plain = await iconPx();
await load(withFarRoom(true), ICON_POS, 'i1');
await raf();
const strayed = await iconPx();
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
out.strayRoomDoesNotInflateTheIcons =
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
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
+10 -2
View File
@@ -139,6 +139,11 @@ 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`.
Whatever the vote rejects is rejected **everywhere the plan is measured**,
not only in the viewBox: §6's `iconUnit` runs the same vote over the rooms.
One notion of "the plan", or a stray the frame had just thrown out came back
as icons ninety times too big (audit DEV-2C947-03).
### §4.3 The frame in an editor
Inside an editor the frame only ever **grows** (`unionRect` with the previous
@@ -229,7 +234,9 @@ there would have been worse than wrong: on a plan drawn 2 canvases
wide the frame is ~2.2 canvases, so every marker would come out 2.2x
smaller than on an ordinary plan — and 55x smaller on a plan 50
canvases out, i.e. an invisible dot. `iconUnit(space) =
max(NORM_W, roomsExtent)` is:
max(NORM_W, mainMassOfTheRooms)` — the extent of the rooms **after the same
outlier vote §4.1 applies to the frame** (rooms only, so the full card and the
static card cannot drift apart), and it is:
* **exactly `NORM_W` for every plan that fits the old square**, and the
editor has only ever stored `view_box: [0,0,1,1]`, so `iconUnit ===
@@ -250,7 +257,8 @@ have made its markers shrink as the frame tightened.
**Auto-placement spacing** (`defaultPositions` -> `declump`) is measured
in render units and uses the same `iconUnit`, so the icon's footprint
and the distance markers are pushed apart by can never drift apart.
and the distance markers are pushed apart by can never drift apart — and
the outlier vote reaches the spacing through the very same call.
## §7 Adaptive grid
+5
View File
@@ -1003,6 +1003,11 @@ require hands on real hardware — they remain for the human pass.
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 inflate the icons either** (audit DEV-2C947-03):
one ROOM dragged an order of magnitude away is rejected from the frame
(as before) and the markers of the main plan keep the size they have
without it; auto-placement spacing goes with them
[auto: smoke_canvas_frame + unit canvas.test.mjs]
- [ ] **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.
+11 -1
View File
@@ -292,11 +292,21 @@ export function spaceCenter(space: SpaceModel): Pt {
* proportionally bigger unit, which is what keeps its markers from
* degenerating into dots once the frame is the content (see `iconCqw`).
* Rooms only — deterministic, so the full card and the static card agree.
*
* The MAIN MASS only, by the very same vote the frame uses (§4.1). A single
* room dragged into the far corner is already rejected from `contentFrame.core`
* — but it used to keep its distance in this numerator, so the plan the user
* actually looks at grew icons ~91x too big while the frame stayed correct
* (DEV-2C947-03). What is out of the frame is out of the icon unit: one notion
* of "the plan", not two.
*/
export function iconUnit(space: SpaceModel): number {
const items: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) items.push(it); }
const b = boxOf(items);
// pad 0: this is a UNIT, not a viewport — the frame's 5 % breathing room has
// no business inflating the icons. Degenerate axes are still lifted off zero,
// which cannot matter here (FLOOR < NORM_W).
const b = contentFrame(items, { pad: 0 }).core;
if (!b) return NORM_W;
return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
}
+33
View File
@@ -106,6 +106,39 @@ test('icon size: no view yet is a plain percentage, never NaN', () => {
assert.equal(iconCqw(2.5, m, 0, 1.5), 3.75);
});
// DEV-2C947-03: what is out of the frame is out of the icon unit
test('a room-outlier is excluded from the frame AND from the icon unit', () => {
// three rooms in the core, a fourth one dragged 90 canvases out — MIN_VOTERS
// is exactly met, so the vote runs and the frame rejects the stray.
const rooms = [
{ id: 'a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
{ id: 'b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
{ id: 'c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
{ id: 'far', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] },
];
const m = model({ id: 's', rooms });
const f = contentFrame(contentItems(m));
assert.equal(f.outliers, 1, 'the far room is rejected from the frame');
assert.ok(f.core.w < 1000, 'the opening view is the house');
// ...and the icon unit agrees: it used to be boxOf(ALL rooms) = 91000 units,
// which made every marker of the ordinary plan ~91x too big while the frame
// stayed correct (audit dev@2c947f4).
assert.equal(iconUnit(m), NORM_W, 'the stray does not stretch the icon unit');
const houseOnly = model({ id: 's', rooms: rooms.slice(0, 3) });
assert.equal(iconUnit(m), iconUnit(houseOnly), 'same unit as the plan without it');
// one shared notion of "the plan": the unit follows the frame's core
assert.ok(iconCqw(2.5, m, 1000) < 3, 'the marker is a marker, not a wall');
assert.equal(iconCqw(2.5, m, 1000), iconCqw(2.5, houseOnly, 1000));
// a genuinely wide plan still scales — no vote, no rejection
const wide = model({ id: 's', rooms: [
{ id: 'a', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
{ id: 'b', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
{ id: 'c', poly: [[0, 1], [1, 1], [1, 2], [0, 2]] },
{ id: 'd', poly: [[1, 1], [2, 1], [2, 2], [1, 2]] },
] });
assert.equal(iconUnit(wide), 2 * NORM_W);
});
// ------------------------------------------------------------- the outlier
test('an outlier does not command the frame, but "show all" reaches it', () => {
const items = [